forkd 如何实现 56 毫秒 Live BRANCH:memfd + userfaultfd 写保护与写时复制原理深度剖析
【免费下载链接】forkd高性能Agent沙箱,预热虚拟机可以在约 100 毫秒内派生出 100 个独立实例;运行过程中约 150 毫秒“分叉”出一个新的运行环境。底层使用 KVM 隔离,并利用快照+写时复制降低资源开销。项目地址: https://gitcode.com/deeplethe/forkd
forkd 是一款高性能 Agent 沙箱工具,它用memfd 共享内存 + userfaultfd 写保护(UFFDIO_WRITEPROTECT)+ 写时复制(Copy-on-Write)三件套,把正在运行的虚拟机「分叉」出一个新分支的停机窗口压到了56 毫秒(p50),比传统 Diff 快照快 3.6 倍,比全量快照快 242 倍——而且这个数字与磁盘速度无关。本文带你从「为什么慢」讲起,拆解 forkd live BRANCH 的完整实现原理。
一、BRANCH 到底慢在哪:暂停窗口问题
对运行中的沙箱做快照分叉,最疼的不是「分叉出子实例要多久」,而是源虚拟机被暂停的时长(pause-window):暂停期间源沙箱的 TCP 连接、kvmclock 全部「卡住」,这是用户能直接感知的卡顿。
forkd 在 1.5 GiB 的 Python+numpy 源沙箱、机械硬盘(最差的存储条件)上测得四种模式:
| 模式 | 暂停窗口 p50 | 说明 |
|---|---|---|
| full(全量写 memory.bin) | 13 550 ms | 被磁盘写速度锁死 |
| diff(脏页快照) | 202 ms | 脏页写入仍发生在暂停窗口内 |
| live(v0.4 写保护路径) | 56 ms | 内存拷贝移到恢复之后 |
完整数据与测试方法见 bench/live-fork-pause-window/RESULTS-v0.4.md,可用 bench-live-fork.py 复现。
关键洞察:full 和 diff 的耗时都绑死在磁盘上,而 live 模式的暂停窗口只由「vmstate 转储(约 30–50 ms)+ 写保护武装(约 0.4–0.6 ms)」决定,是与磁盘无关的 CPU 开销。存储越慢,live 的优势反而越大。
二、第一块拼图:memfd——把客户机内存变成共享内存对象
为什么不能直接对memory.bin文件做写保护?这里有一个内核层面的硬约束:
UFFDIO_WRITEPROTECT只支持匿名(anonymous)和共享内存(shmem)类型的 VMA,不支持任意文件映射。
所以 forkd 在创建源沙箱时(live_fork: true)就提前布局:用memfd_create(2)创建一个匿名文件,把快照的内存字节拷进去,再把/proc/<pid>/fd/<N>路径交给 Firecracker,让它以MAP_SHARED映射这个 memfd 作为客户机 RAM。这个设计的完整论证在 crates/forkd-vmm/src/memfd.rs 的模块注释里:
- memfd 是 shmem inode,天然满足UFFD_WP 的 VMA 要求;
forkd-controller保留 memfd 句柄,就能 mmap同一批物理页——客户机写什么,控制器立刻看得见;- memfd 随 fd 关闭而消失,不需要清理磁盘残留文件。
memfd 创建与填充的核心逻辑见 create_and_populate,还支持 2 MiB 大页后端以缓解页表压力。MemoryBackend::MemfdShared枚举与Vm::request_wp_uffd握手接口定义在 crates/forkd-vmm/src/lib.rs;FC 侧需要mem_backend.shared = true,对应的上游提案见 FIRECRACKER-UPSTREAM-PROPOSAL.md。
三、第二块拼图:写保护 + 后台拷贝,把「抄家」挪出停机窗口
这是整个方案的灵魂。传统做法是「冻结 → 把整个内存写到磁盘 → 解冻」;live BRANCH 变成「短暂冻结 → 给内存上写保护锁 → 解冻 → 边跑边抄」。
以 crates/forkd-uffd/src/wp_snapshot.rs 中WpBranch的注释为蓝图,一次 BRANCH 的时间线是:
- 暂停源 VM(Firecracker 负责 pause);
- 武装写保护:对源 VM 的 memfd 区域执行
UFFDIO_WRITEPROTECT,这就是整个 BRANCH 的临界区,每 GiB 不到 1 毫秒,见 WpBranch::begin; - 在内存「对客冻结」状态下转储 vCPU + 设备状态(vmstate);
- 立即恢复源 VM 运行——暂停窗口到此结束,共约 56 ms;
- 恢复后两路并发跑内存拷贝:
- 批量通道:
bulk_copy_clean逐页读走仍然干净的页,见 bulk_copy_clean; - 故障通道:源 VM 一旦写某个页,内核立刻向 userfaultfd 报写故障,处理线程把写前的原始内容抄进快照文件,再对该页解除写保护放行写入,见 run_handler。
- 批量通道:
这条路径有一条严格的一致性不变量:快照文件里的每一页,都持有「写保护武装那一刻」客户机能读到的值。也就是说,分叉出来的分支拿到的是一份精确的时间点视图,而源沙箱则继续向前演化,互不干扰。Phase 1–3 的 PoC(experiments/v0.4-uffd-wp-poc/ 等)在 64 MiB / 256 MiB / 1 GiB 区域、含 KVM 客户机经 EPT 的真实写入下验证了0 次不变量违例。
跨进程细节值得一提:UFFDIO_REGISTER是按进程计的,而 KVM 跑在 Firecracker 里,所以 uffd 由 FC 进程创建,再经 SCM_RIGHTS 把 fd「快递」给控制器武装写保护——对应 begin_with_external_uffd。
四、第三块拼图:写时复制——N 个子实例为什么几乎不占内存
分叉出的子实例从快照恢复时,走的是 forkd 的老本行:所有子 VM 对memory.bin做mmap(MAP_PRIVATE),干净页由内核页缓存天然共享,只有被写脏的页才按子实例 CoW 复制。
这里有个容易踩的坑:为什么不干脆让子实例也走 UFFD 按需取页?因为 UFFD 的UFFDIO_COPY是拷贝语义——N 个子实例各拿各的私有副本,内核 CoW 共享直接没了,等于把 forkd 最核心的密度优势换掉。这个权衡的完整推导见 docs/design/userfaultfd.md。
五、56 毫秒是怎么量出来的
- 硬件:i7-12700 + 30 GiB 内存 +机械硬盘(故意用最差存储,见 RESULTS-v0.4.md 的 Setup 与 Caveats);
- 源码:
POST /v1/sandboxes/<id>/branch带mode: "live",CLI 为forkd snapshot --from-sandbox <id> --live,SDK 见 sdk/python/forkd/controller.py; - 还有个隐藏大招:
wait=false时 HTTP 请求在源 VM 恢复后就返回(p5069 ms),内存拷贝在后台跑完,status字段从writing翻到ready时快照即可消费——调用方根本不用等那 13 秒的落盘。
运行前提:Linux 内核 ≥ 5.7(UFFD_WP)、vm.unprivileged_userfaultfd=1或 root /CAP_SYS_PTRACE、带 memfd 共享补丁的 Firecracker(forkd doctor会自动探测,见 crates/forkd-cli/src/doctor.rs)。
六、这 56 毫秒意味着什么 🚀
对 Agent 场景来说,56 ms 的停机意味着「分叉」从一项昂贵的操作变成廉价到可以随手做的操作:
- 破坏性操作前自动分支——
rm -rf/apt remove之前先分叉,后悔即丢弃; - 投机执行——同一状态并行尝试多种策略,挑赢家;
- 对话检查点——每 N 轮自动存档,第 47 轮跑偏就回到第 45 轮。
相关设计文档:docs/design/branching.md(分支语义与暂停窗口行为)、docs/design/userfaultfd.md(架构演进全记录)、DESIGN-v0.4.md 与 DESIGN-v0.4-PHASE6.md(v0.4 实现细节)。从 v0.2 的 ~13.5 s,到 v0.3.4 diff 快照的 ~200 ms,再到 v0.4 live 的56 ms,forkd 用三个版本把「分叉」打磨成了一个几乎免费的动词。
【免费下载链接】forkd高性能Agent沙箱,预热虚拟机可以在约 100 毫秒内派生出 100 个独立实例;运行过程中约 150 毫秒“分叉”出一个新的运行环境。底层使用 KVM 隔离,并利用快照+写时复制降低资源开销。项目地址: https://gitcode.com/deeplethe/forkd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考