跳到主要内容

秒级恢复的核心:内存快照 + userfaultfd 惰性缺页

30 秒导读: 一个暂停过的沙箱可能带着几 GB 的 guest 内存。恢复它时,E2B 把这几 GB 整块读回来——那要花好几秒。它改用一个 Linux 内核特性 userfaultfd:先把 guest 的整片内存标成"缺页"(还没有真实内容),让 VM 立刻跑起来;等 VM 真的访问到某一页时,内核才发一个缺页事件,E2B 的用户态处理器只把那一页从快照里拉出来填进去。绝大多数页永远不会被碰到,于是也永远不用加载。这一章讲清楚"为什么恢复能这么快"。

本章是全仓工程含量最高的一支。它假设你已经读过沙箱生命周期(知道 Firecracker microVM、Pause/Resume 大致是什么);存储侧(内存 diff 如何分层去重、上传下载)只点到为止,细节交给写时复制块存储


1. 这是什么(零基础也能懂)

先说问题:恢复一个 VM,慢在哪

一个运行中的沙箱,它的"活着"分两半:

状态是什么有多大
磁盘(rootfs)文件系统交给块存储层,本章不管
内存(guest RAM)VM 里进程的堆栈、页缓存、内核数据……几百 MB 到几 GB

暂停(Pause)时,这几 GB 内存被存成一个内存快照文件。恢复(Resume)时,理论上你得把这个文件的内容重新灌回 VM 的物理内存,VM 才能从暂停的那一刻继续跑。

问题就在这:几 GB 整块读回来,是秒级甚至十几秒级的开销。 而 E2B 卖的就是"沙箱秒起"。整块加载,做不到。

直觉:大部分页,VM 根本不会碰

关键观察是:一个 VM 刚恢复的头几秒,它真正访问到的内存页只是一小撮——正在跑的那个进程的几页栈、几页代码、几页堆。那几 GB 里的绝大部分(睡着的进程、冷的页缓存、内核里没人动的结构),恢复后很久都不会被读到。

于是有了**惰性(lazy / on-demand)**的思路:

恢复时先不加载任何内存。把 guest 的整片内存标成"空的、缺页的",让 VM 立刻开跑。谁被访问到,谁才被加载。

这就像开一个几百页的 PDF:阅读器不会一次把每页都渲染出来,你翻到哪页才渲染哪页。E2B 对 VM 内存干的是同一件事。

这件事靠什么做到:userfaultfd

userfaultfd(user-space page fault handling,用户态缺页处理)是 Linux 内核提供的机制。平常一个进程访问一块还没有物理内存的地址,是内核替它处理缺页;userfaultfd 把这个权力交给用户态程序:

  • 你(用户态)向内核注册一段虚拟内存,声明"这段的缺页我来管"。
  • 当有人访问这段里还没内容的页,内核不自己填,而是往一个特殊的文件描述符(fd)里投递一个"缺页事件",然后把访问它的那个线程冻住
  • 你的程序从 fd 读到事件,自己决定这一页该是什么内容,用一个 ioctl(UFFDIO_COPY)把数据填进去,内核随即唤醒被冻住的线程,它就像什么都没发生一样继续跑。

在 E2B 里,"某段虚拟内存"就是 Firecracker 给 guest 分配的那片物理内存,"该是什么内容"就是从内存快照里读出对应的那一页

谁注册、谁服务:一句话分工

有个容易搞混的点,先讲清楚:

  • 注册 uffd 的是 Firecracker,不是 E2B。 Firecracker 自己创建 userfaultfd、把 guest 内存的所有 VMA 用 MISSING 模式注册好,然后把这个 fd 通过 Unix socket 递给 E2B
  • E2B 只负责"服务缺页"。 它从 socket 收下这个 fd,进入一个事件循环:读事件 → 从快照取页 → UFFDIO_COPY 填页 → 唤醒。

所以这一章讲的 E2B 代码,几乎全是"缺页服务器"的实现。

用起来什么样(从调用方看)

恢复沙箱的入口是 Factory.ResumeSandbox。它内部把 uffd 服务器拉起来的核心就一句:

// sandbox.go 内,serveMemory 把 uffd 服务器起起来
return uffd.New(memfile, fcUffdPath), nil // memfile = 内存快照;fcUffdPath = 和 FC 约定的 socket 路径

memfile 是内存快照(一个 block.ReadonlyDevice,缺页时页就从它里读),fcUffdPath 是 E2B 和 Firecracker 约定的 Unix socket 路径。剩下的一切——收 fd、跑事件循环、填页——都藏在 Uffd 类型里。


2. 顶层全景(它大概怎么转)

参与的部件

部件干什么在哪
Firecracker (FC)创建并注册 uffd,通过 socket 把 fd 递给 E2B;VM 访问缺页时由内核冻结其 vCPU 线程外部进程
Uffd门面:监听 socket、收 fd + 内存布局、拉起底层处理器uffd/uffd.go:38
Userfaultfd真正的缺页服务器:事件循环 + 填页uffd/userfaultfd/userfaultfd.go:71
FdUFFDIO_* ioctl 的极薄封装(copy / zero / wake / write-protect)uffd/userfaultfd/fd.go:151
memory.Mapping把"缺页发生的主机虚拟地址"翻译成"快照文件里的偏移"uffd/memory/mapping.go:28
memfile(源)内存快照;缺页时页从这里 ReadAt 出来block.ReadonlyDevice
Prefetcher后台"抢跑":把已知会用到的页提前填进去,减少同步缺页uffd/prefetch/prefetcher.go:42
FdExit一根自管道,用来优雅地叫停事件循环uffd/fdexit/fdexit.go:11

一次缺页的旅程(高层,不进代码)

先看恢复后运行期最核心的那条线:VM 碰到一页没内容的内存,发生了什么。

guest 里某进程读一个还没填的地址


内核冻住这个 vCPU 线程,往 uffd 投一个 PAGEFAULT 事件


① 事件循环 readEvents 从 fd 读出事件 (userfaultfd.go:208)
│ 拿到缺页的主机虚拟地址 addr

② Mapping.GetOffset(addr) 把 addr 翻成快照偏移 (memory/mapping.go:56)


③ 查 pageTracker:这页是"没填过 / 已知全零 / 已填"?

┌────┴─────────────┬──────────────────┐
▼ ▼ ▼
NotPresent Zero Dirty(已填)
从 memfile 读 直接零填 短路返回,只补个 wake
UFFDIO_COPY UFFDIO_ZEROPAGE (别人已经填过了)
└────────┬─────────┴──────────────────┘

④ 内核唤醒被冻的线程,它继续跑,浑然不觉

要读这张图,记住一句话:恢复本身"零加载",所有加载都是被这条缺页线路"拽"出来的。 VM 启动那一刻内存是空的,是它自己的访问行为一页页把内容拉进来。

三条时间线,别混在一起

Uffd 同时活在三个不同的时刻,分清它们能让后面不迷路:

时刻谁在动干什么
握手Uffd.handle收 FC 递来的 uffd fd + guest 内存布局,一次性
运行期缺页Userfaultfd.Serve 事件循环VM 每碰一页缺页就服务一次,持续整个生命周期
暂停时Uffd.DiffMetadataPause 时反过来问:哪些页被写脏了,要存进新快照

本章按"握手 → 运行期缺页 → 预取加速 → 暂停产出快照"的顺序,由浅入深走一遍。


3. 握手:E2B 怎么拿到 uffd 和内存布局

这节讲: 服务缺页之前,E2B 得先从 Firecracker 手里接过两样东西——那个 uffd 文件描述符,和"guest 内存长什么样"的布局表。

socket 上收到的两样东西

Uffd.Start 在约定路径上开一个 Unix socket 并 Accept,然后 handle 读第一条消息。这条消息很特别:它同时带普通数据文件描述符(fd 只能通过 Unix socket 的辅助数据"带外"传递)。

// uffd/uffd.go:138 — 一次 ReadMsgUnix 同时收数据和 fd
numBytesMappings, numBytesFd, _, _, err := unixConn.ReadMsgUnix(regionMappingsBuf, fdBuf)
  • regionMappingsBuf(普通数据):一段 JSON,描述 guest 内存的分区布局(见下)。
  • fdBuf(辅助数据):1 或 2 个 fd。第一个永远是 uffd;第二个是可选的 memfd(见 §7)。注释点明 Firecracker may send 1 fd (UFFD) or 2 (UFFD + memfd, on newer versions)(uffd.go:135)。

fd 的解析走内核的 SCM_RIGHTS 协议:ParseSocketControlMessage + ParseUnixRights(uffd.go:152-161)。

内存布局:一张"地址 ↔ 偏移"翻译表

JSON 解析出来是一组 memory.Region。每个 region 描述 guest 内存的一段,记录它在主机虚拟地址空间的位置和它对应快照文件里的偏移:

// uffd/memory/region.go:8 — 一段 guest 内存的映射
type Region struct {
BaseHostVirtAddr uintptr // 这段在主机虚拟地址空间的起点
Size uintptr // 长度
Offset uintptr // 对应快照文件里的偏移
PageSize uintptr // 4 KiB 或 2 MiB(大页)
}

为什么需要它?因为缺页事件报的是主机虚拟地址 addr,而快照文件是按偏移存的。两者差一个基址,shiftedOffset 做的就是这个减法:

// uffd/memory/region.go:29 — 地址 → 快照偏移,就是减基址再加偏移
func (r *Region) shiftedOffset(addr uintptr) int64 {
return int64(addr - r.BaseHostVirtAddr + r.Offset)
}

Mapping.GetOffset(memory/mapping.go:56)遍历所有 region 找到 addr 落在哪段,再调上面这个换算。这就是缺页旅程里的第 ② 步。

收齐后,创建底层处理器

拿到 fd、布局、页大小(所有 region 必须一致,否则报错),handle 创建真正的服务器 Userfaultfd,标记就绪(关掉 readyCh,让等待的预取器出发),然后进 Serve 循环:

// uffd/uffd.go:179 — 用 FC 递来的 fd 建服务器
uffd, err := userfaultfd.NewUserfaultfdFromFd(
uintptr(fds[0]), // 第一个 fd 就是 uffd
u.memfile, // 缺页时页从这个快照读
m, // 地址↔偏移翻译表
generation, // 快照代数,仅用于给指标打标签
...
)

generation(快照的 pause/resume 循环次数,来自 memfile.Header().Metadata.Generation,uffd.go:175)不影响功能,只是给指标打标签,用来观察"恢复延迟随快照链深度怎么变"。


4. 核心机制:缺页服务的事件循环

这节讲: VM 跑起来后,每一次缺页是怎么被服务掉的。这是本章的心脏。

直觉:一个 poll 循环 + 一池工人

Serve(userfaultfd.go:254)是个经典的事件循环:用 poll 同时盯三个 fd,谁有事就处理谁。

┌──────────── unix.Poll 同时盯三个 fd ────────────┐
│ │
uffd fd (有缺页) exit fd (该收工了) wakeupPipe (有延迟任务要重试)
│ │ │
▼ ▼ ▼
readEvents 读出一批 Wait 所有工人后返回 drain 后把延迟的缺页重新入队
PAGEFAULT / REMOVE


每个缺页 → u.wg.Go(一个工人 goroutine 去填这一页)

为什么盯三个而不是一个?

fd作用
uffd fd缺页和 REMOVE 事件的来源
exit fdFdExit 的读端;暂停/销毁时往写端写一字节,循环就能干净地退出(fdexit.go)
wakeupPipe自管道;某个填页因 EAGAIN 被延迟了,工人写一下它,把 poll 唤醒,让延迟的缺页在下一轮被重试(userfaultfd.go:98)

关键设计:读事件是串行的,填页是并发的

readEvents(userfaultfd.go:208)在一个循环里把内核 uffd 队列一次性抽干(读到 EAGAIN 为止),攒成一批缺页。然后对每个缺页,Serve 不在循环线程里填,而是 u.wg.Go(...) 甩给一个工人 goroutine(并发上限 maxRequestsInProgress = 4096,userfaultfd.go:33)。

这么设计的原因很实在:填一页可能要从远端存储读数据(慢),不能让它卡住读事件的循环。 读循环必须始终能抽干内核队列,否则填页时触发的 madvise 会死锁(代码里专门有注释和测试 TestNoMadviseDeadlockWithInflightCopy 守着这条,userfaultfd.go:87)。

工人怎么决定"这一页填什么":三态短路

每个工人先查 pageTracker(一个记录每页状态的位图,block/tracker.go:11)——这页是不是已经有人填过了?据此分三种走法:

// userfaultfd.go:455 — 按页当前状态决定怎么服务
switch state := u.pageTracker.Get(idx); state {
case block.Dirty:
// 已经被填过(别人抢先了)。不用再填,直接返回,补个 wake 即可。
return nil
case block.Zero:
// 已知这页全零。零填,不必去读快照。
case block.NotPresent:
// 从没填过。要从 memfile(快照)读真实内容。
source = u.src
}
状态含义怎么填
Dirty已有真实内容(并发工人或预取抢先填了)短路返回,只补 wake
Zero已知全零零填,不读快照(省一次 I/O)
NotPresent从没填过从 memfile ReadAt 出这一页

这个短路是并发正确性的关键:同一页可能被多个 vCPU 线程同时缺页,或被预取器和缺页同时盯上。pageTracker + 一把读写锁(settleRequests)保证"查状态 → 填页 → 记状态"这一串不会互相踩。

真正的填页:UFFDIO_COPY / ZEROPAGE

faultPage(userfaultfd.go:540)是落地动作。核心分支:

  • 有 source(要读快照):从 memfile.ReadAt 读出这一页(带指数退避重试,最多 4 次,userfaultfd.go:606),再 UFFDIO_COPY 拷进 guest。
  • 无 source + 全零:UFFDIO_ZEROPAGE(4 KiB 页)或 copy 一个空大页(EmptyHugePage,2 MiB)。

ioctl 本身是 fd.go 里几行的极薄封装,例如 copy:

// uffd/userfaultfd/fd.go:154 — UFFDIO_COPY 就是一个 ioctl
func (f Fd) copy(addr, pagesize uintptr, data []byte, mode CULong) error {
...
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(f), UFFDIO_COPY,
uintptr(unsafe.Pointer(&cpy))); errno != 0 {
return errno
}
return classifyCopyResult(int64(cpy.copy), int64(pagesize))
}

填完,内核会自动唤醒被冻的线程(除非用了 DONTWAKE);VM 那个访问就此完成,毫无察觉。

三个必须处理的错误码(精华)

UFFDIO_COPY 的返回码在这个高并发、随时可能销毁沙箱的环境里全是坑。E2B 把每个都精确分类了:

errno什么意思怎么办
EEXIST这页已经被映射了(并发工人/预取抢先)当作 faultAlreadyPresent,补个 wake 就好(userfaultfd.go:663)
ESRCH缺页的那个线程已经没了(沙箱正在销毁)faultDiscarded,重试无意义,放弃(userfaultfd.go:673)
EAGAIN拷贝中途 mmap_changing 被置位,或短拷faultDeferred:自己入延迟队列 + 写 wakeupPipe,下一轮重试(userfaultfd.go:684)。内核不会自动重投

EAGAIN 这条尤其精妙:内核在并发 madvise/mremap/fork 时会让 UFFDIO_COPY 失败,而且不重投。E2B 用一个 deferredFaults 队列接住它,并靠 wakeupPipe 把 poll 循环叫醒去重试(deferred.go:9)。延迟队列还会按地址去重、把同页的读+写升级成写,避免同一页被服务多次。

classifyCopyResult(fd.go:179)是把这堆语义翻成 Go error 的地方:内核在失败时把负的 errno 编码进 cpy.copy 字段,短拷则返回一个正的、不足一页的字节数——两者都归一成 EAGAIN


5. 教学示例:把惰性缺页的核心想法演一遍

这节讲: 抛开所有并发和错误处理,惰性缺页的骨架其实很短。下面用简化 Python 演示核心循环,帮你建立直觉。

# 示意,非源码 —— 惰性缺页服务器的最小骨架
def serve(uffd_fd, snapshot, mapping):
while True:
event = read_event(uffd_fd) # ① 内核投来的缺页事件(含主机虚拟地址)
if event.kind == "exit":
return

addr = event.address
offset = mapping.to_offset(addr) # ② 主机地址 → 快照偏移

if tracker.get(offset) == "dirty": # ③ 别人已填过?短路
wake(uffd_fd, addr)
continue

page = snapshot.read_page(offset) # ④ 只读被访问到的这一页
uffdio_copy(uffd_fd, addr, page) # ⑤ 填进 guest —— 内核自动唤醒被冻线程
tracker.set(offset, "dirty") # ⑥ 记下:这页填过了

重点看第 ④ 步:整个"快"的来源就在这里——只有被访问到的页才 read_page 一个几 GB 的快照,VM 启动头几秒可能只碰几千页(几十 MB),其余永远不读。真实实现在这个骨架上加了并发工人池、三态短路、EAGAIN 延迟重试、预取,但主干就是这么回事。


6. 预取:主动"抢跑",把同步缺页变少

这节讲: 惰性缺页虽快,但每次缺页都要冻住 vCPU 等填页,累积起来也是延迟。预取的思路是:既然模板构建时就知道"这个模板启动通常会用到哪些页",那就在后台提前把它们填进去,让 VM 访问时它们已经在了。

直觉:两条流水线并行

Prefetcher(prefetch/prefetcher.go:42)把工作拆成两个并行阶段,互不阻塞:

prefetch mapping(构建时记下的"常用页"清单)

┌────┴─────────────────────────────┐
▼ ▼
fetch 阶段(多工人) copy 阶段(多工人)
从 source.Slice 拉页 等 uffd ready 后
→ 塞满本地缓存 → uffd.Prefault 填进 guest
│ ▲
└──────── copyCh 通道 ──────────────┘
  • fetch 阶段立刻开跑(fetchWorker,prefetcher.go:227):从 source 把页读进本地缓存。这一步不依赖 uffd,所以能在恢复最早期就启动,提前"暖"好数据。
  • copy 阶段等 uffd 就绪(copyWorker,prefetcher.go:268):selectp.uffd.Ready(),拿到后调 Prefault 把页填进 guest。

两阶段用 copyCh 通道连起来,各自有并行度上限(来自 feature flag),谁也不卡谁。

Prefault 和缺页的关系:抢同一把锁,谁先谁赢

Prefault(userfaultfd/prefault.go:23)本质是"主动版的缺页服务":它也查 pageTracker、也走 faultPage,只是数据是现成的(directDataSource),不用等内核事件。

关键是它和真实缺页可能撞车——预取正要填的页,VM 恰好也缺页了。代码坦然接受这点:

// prefault.go:78 — 预取前先看这页是不是已经被缺页服务抢先填了
state := u.pageTracker.Get(idx)
if state == block.Dirty || state == block.Zero {
// 缺页服务已经把它填了,这次预取来晚了 → 跳过
return false, nil
}

Prefault 返回一个 installed bool 明确告诉调用方"这次是不是我填的":如果是缺页或另一个预取工人抢先(EEXIST)、或页已驻留,就返回 falsecopyWorker 据此把统计分成 copied / skipped,让指标口径干净(prefetcher.go:309)。

恢复期到底填了多少页:startup 指标

预取和缺页最终都汇进一组"这次启动到底需要多少页"的指标,采样点选在envd 初始化完成的那一刻(WaitForEnvd):

// sandbox.go:1771 — 在 envd 起来的瞬间,采样累计缺页量
s.startupStatsOnce.Do(func() {
stats := s.memory.ServeStats()
uffdStartupPagesHistogram.Record(ctx, stats.Pages, startupAttrs) // 需要的页数
uffdStartupSourcePagesHistogram.Record(ctx, stats.SourcePages, ...) // 其中从快照读的
uffdStartupBytesHistogram.Record(ctx, stats.Bytes, startupAttrs) // 填进去的字节
})

ServeStats(userfaultfd/metrics.go:186)是每个处理器上的累计计数器,recordServeStats(metrics.go:198)在每次缺页服务结束时累加。注意预取不进这些计数(它走 Prefault,绕开了 Serve 循环)——所以这组指标反映的是"惰性缺页兜底了多少",预取填得越多,这里数字越小。这正是衡量"恢复有多快"的第一手信号。


7. 快照产物:memfd 与内存 diff 从哪来

这节讲: 前面一直说"从快照读页",快照本身是怎么产出的?这要看 Pause 那一侧。存储层的分层去重细节交给 04,这里只讲 uffd 这侧的产出。

暂停时,反过来问 uffd:哪些页脏了

Pause 的第一步是搞清楚"自上次快照以来,哪些页被 VM 写改过"——只有这些"脏页"需要存进新的 diff。Uffd.DiffMetadata(uffd.go:250)做这件事:

// uffd.go:259 — 先把在途的填页工人和 REMOVE 批次全部结清
_, empty := handler.ExportPageStates()

// uffd.go:261 — 再问 FC:哪些页是脏的(WP-async 追踪的)
diff, err := f.DirtyMemory(ctx, handler.PageSize())

脏页从两处交叉得来:

  • DirtyMemory(fc/memory.go:26):Firecracker 用 UFFD_FEATURE_WP_ASYNC(异步写保护)追踪哪些页在恢复后被过。
  • empty:uffd 侧记录的"被零填、但之后没再写"的页——这些页在 diff 里标成 Empty,存快照时可以省掉(uffd.go:268,empty.AndNot(diff.Dirty),脏优先于空)。

这里有个微妙的时序:必须先 ExportPageStates 结清在途工人,再采样 FC 的脏页位图,否则一个"零填→写"的操作可能从两个位图之间溜过去、两边都逃掉(uffd.go:256 的注释)。

memfd:一条零拷贝的快照通道

新版 Firecracker 在握手时会多递一个 memfd(内存文件描述符)——它直接是 FC 那片 guest 物理内存的句柄。E2B 把它 mmap 进来,就能零拷贝地读任意一页 guest 内存:

// block/memfd.go:31 — 把 FC 递来的 memfd mmap 进来
func NewFromFd(fd int) (*Memfd, error) {
...
b, err := unix.Mmap(fd, 0, int(st.Size), unix.PROT_READ, unix.MAP_SHARED)
...
return &Memfd{fd: fd, mmap: b}, nil
}

// block/memfd.go:49 — 取一页就是切一段 mmap,零拷贝
func (m *Memfd) Slice(offset, size int64) ([]byte, error) {
return m.mmap[offset : offset+size], nil
}

有了 memfd,Pause 时把脏页拷进本地 diff 缓存,就是从这块 mmap 里 Slice 出脏页再写盘(copyFromMemfd,block/memfd.go:103)。更妙的是这份拷贝可以异步做(NewCacheFromMemfdAsync,memfd.go:137),让 Pause 尽早返回,拷贝在后台把 memfd(可能几十 GB 的 hugetlb 页)慢慢消化、消化完再释放。

生命周期串一遍:Pause 产出 → Resume 惰性挂载

把两侧连起来看,一个完整的 pause/resume 循环:

Pause Resume
───── ──────
DiffMetadata: 问 uffd/FC 哪些页脏了 FC 创建 uffd,注册 guest 内存为 MISSING
│ │
▼ ▼
从 memfd 把脏页拷进本地 diff 把 uffd fd + 布局递给 E2B(握手)
│ │
▼ ▼
diff 分层去重后上传(→ 见 04) E2B 跑 Serve 循环:VM 一碰页就从
│ memfile(= 上次的快照+diff)惰性拉
▼ │
产出新一代 memfile 快照 ──────────────────────┘

闭环在这:这一代快照就是下一次恢复的 memfile。 恢复从不整块加载它,只在缺页时一页页拉——这就是"秒级恢复"的全部秘密。


8. 巧妙之处(可借鉴的技术)

  • 读事件串行、填页并发,且两把锁刻意不相交。 readSerial(序列化读循环)和 settleRequests(护住 tracker)是两把独立的锁,工人绝不碰 readSerial,保证读循环永远能抽干内核队列、不被慢速填页阻塞而死锁。见 userfaultfd.go:82-89 的长注释和 TestNoMadviseDeadlockWithInflightCopy

  • 三态短路省掉大量 I/O。 Zero 态直接零填、Dirty 态直接短路,只有 NotPresent 才真去读快照(userfaultfd.go:455)。零页在 VM 内存里占比很高,这个短路省下的读 I/O 相当可观。

  • EAGAIN 自管理延迟队列。 内核对 UFFDIO_COPYEAGAIN 不重投,E2B 用 deferredFaults + wakeupPipe 自己接住并重试,还顺手做地址去重和读→写升级(deferred.go:21)。

  • memfd 零拷贝 + 异步消化。 Pause 用 mmap 的 memfd 零拷贝读脏页,并把拷贝甩到后台 goroutine,让 Pause 快速返回(block/memfd.go:137)。

  • 指标口径极度克制。 预取绕开 Serve 不计入 startup 计数、teardown 期的 prefault 不记录(prefault.go:56)、throwaway 恢复不污染客户 KPI(sandbox.go:1748)——保证"恢复需要多少页"这个信号干净可信。

9. 边界与局限

  • 只服务 MISSING 缺页。 代码显式拒绝 MINORWP 缺页事件(收到就关 uffd,userfaultfd.go:407-413),因为 E2B 用的是 WP_ASYNC(写保护由 FC 异步追踪),不走同步 WP 事件。

  • 仅 Linux。 整个包 //go:build linux;userfaultfd 是 Linux 特有内核特性,没有跨平台退路。

  • 依赖 FC 的握手契约。 region 布局的 JSON 格式、fd 传递方式(1 或 2 个 fd)都和 Firecracker 版本强绑定;Region.PageSize 字段甚至带着 FC 老版本 page_size_kib(实际是字节)的历史包袱(region.go:13)。

  • 页大小必须全区一致。 只支持 4 KiB 或 2 MiB 大页,且所有 region 页大小必须相同,否则 NewUserfaultfdFromFd 直接报错(userfaultfd.go:164-172)。

  • 创建(非恢复)时没有 uffd。 全新沙箱用 NewNoopMemory(sandbox.go:528)——没有快照可拉,内存是干净分配的,不需要缺页服务。惰性缺页只服务"恢复"这条路径。

10. 代码地图(导航索引)

主题文件路径符号名
门面:监听 socket、收 fd、拉起处理器packages/orchestrator/pkg/sandbox/uffd/uffd.goUffd, Uffd.Start, Uffd.handle
后端接口packages/orchestrator/pkg/sandbox/uffd/memory_backend.goMemoryBackend
缺页服务器主体packages/orchestrator/pkg/sandbox/uffd/userfaultfd/userfaultfd.goUserfaultfd, NewUserfaultfdFromFd
事件循环(poll 三个 fd)packages/orchestrator/pkg/sandbox/uffd/userfaultfd/userfaultfd.goUserfaultfd.Serve, readEvents
落地填页 + 三态短路 + 错误分类packages/orchestrator/pkg/sandbox/uffd/userfaultfd/userfaultfd.gofaultPage
UFFDIO_* ioctl 封装packages/orchestrator/pkg/sandbox/uffd/userfaultfd/fd.goFd.copy, Fd.zero, Fd.wake, classifyCopyResult
常量(UFFD_*、事件类型)packages/orchestrator/pkg/sandbox/uffd/userfaultfd/fd.goUFFDIO_COPY, UFFD_EVENT_PAGEFAULT, UFFD_FEATURE_WP_ASYNC
EAGAIN 延迟重试队列packages/orchestrator/pkg/sandbox/uffd/userfaultfd/deferred.godeferredFaults, push, drain
主动填页(预取用)packages/orchestrator/pkg/sandbox/uffd/userfaultfd/prefault.goUserfaultfd.Prefault, directDataSource
地址 ↔ 偏移翻译packages/orchestrator/pkg/sandbox/uffd/memory/mapping.goMapping.GetOffset, Mapping.GetHostVirtAddr
内存分区定义packages/orchestrator/pkg/sandbox/uffd/memory/region.goRegion, shiftedOffset
每页状态位图packages/orchestrator/pkg/sandbox/block/tracker.goTracker, State(NotPresent/Dirty/Zero)
退出协调(自管道)packages/orchestrator/pkg/sandbox/uffd/fdexit/fdexit.goFdExit, SignalExit
后台预取两阶段流水线packages/orchestrator/pkg/sandbox/uffd/prefetch/prefetcher.goPrefetcher.Start, fetchWorker, copyWorker
startup / serve 指标packages/orchestrator/pkg/sandbox/uffd/userfaultfd/metrics.goServeSnapshot, ServeStats, recordServeStats
memfd 零拷贝快照通道packages/orchestrator/pkg/sandbox/block/memfd.goMemfd, NewFromFd, Slice, NewCacheFromMemfdAsync
暂停时求脏页packages/orchestrator/pkg/sandbox/uffd/uffd.goUffd.DiffMetadata
FC 侧脏页/内存信息packages/orchestrator/pkg/sandbox/fc/memory.goProcess.DirtyMemory, MemoryInfo
恢复主流程接线packages/orchestrator/pkg/sandbox/sandbox.goFactory.ResumeSandbox, serveMemory
快照产物结构packages/orchestrator/pkg/sandbox/snapshot.goSnapshot, DiffHeader, MemorySnapshot

下一步: 这一章讲了内存快照如何惰性挂载。快照文件本身(rootfs 和内存 diff)如何分层、去重、上传下载,见 写时复制块存储。VM 的编排与 Pause/Resume 全流程见 沙箱生命周期