开发者生态
morning
由交换导致的 40ms Go 垃圾收集器暂停
摘要
I’m writing this so you don’t slap your forehead like I almost did when I decided to run swap in production to absorb memory spikes. I had a cgroup with two processes: one is a Go process that calls i...
the
runtime
that
and
swap
faults
with
was
usr
local
2026-10-05
1 阅读
约7分钟阅读
shellpipe
字号:
我写这篇文章是为了让你不要像我决定在生产中运行交换来吸收内存峰值时那样拍拍额头。我有一个包含两个进程的 cgroup:一个是 Go 进程,它调用 io.ReadAll 然后调用 proto.Unmarshal ,创建一个 blob,然后创建一个图结构(被 Go 的分配器标记为扫描)。另一个进程是一个 HTTP 服务器,它基本上保持安静。每当收集器运行时,它都会逐个指针地读取这些扫描范围,并决定如何处理它们。所以我想:好吧,在内存压力下,内核会将页面逐出到交换设备,但由于逐出是针对每个 cgroup,而不是针对每个进程,因此两个进程的页面都将被逐出 - 因此,这只有很小的可能会变成内核和垃圾收集器之间的换入和换出的悲伤舞蹈。我错了。在尝试这个时,我发现了一个可能会伤害我的问题:Go 的垃圾收集器在停止世界的暂停中读取其元数据(在堆之外,在未释放的区域中),并且该元数据可以交换。我使用启用了 MGLRU 的内核 6.8 在 Hetzner 盒子上进行了模拟运行 1 您可以在此处找到有关这些实验的所有内容:绘图、模拟分配器、bpf 脚本、python 脚本等:https://github.com/frnsimoes/go-gc-swap-cost 。中间停顿时间约为 51 秒。对于 NVMe 上的元数据,最差的暂停为 40 毫秒。为了检查这 40 毫秒去了哪里,我编写了一个小的 bpf 脚本,用于在世界停止时计算页面错误。这是最糟糕的一次:39902我们,期间故障228,故障39013我们。这 40 毫秒中有 39 毫秒花费在 228 个页面错误上。这些错误发生在GC的簿记中: // addr2line output 0x42e5c8 runtime.(*spanSet).reset /usr/local/go/src/internal/runtime/atomic/types.go:194 0x4219de runtime.finishsweep_m /usr/local/go/src/runtime/mcentral.go:71 0x4629cf运行时.gcStart.func2 /usr/local/go/src/runtime/mgc.go:724 0x46dd8a 运行时.systemstack /usr/local/go/src/runtime/asm_amd64.s:518 0x4169dc 运行时.gcStart /usr/local/go/src/runtime/mgc.go:722 0x4276a4运行时.nextMarkBitArenaEpoch /usr/local/go/src/runtime/mheap.go:2481 0x421a65 运行时.finishsweep_m /usr/local/go/src/runtime/mgcsweep.go:268 0x4629cf 运行时.gcStart.func2 /usr/local/go/src/runtime/mgc.go:724 0x46dd8a runtime.systemstack /usr/local/go/src/runtime/asm_amd64.s:518 0x4169dc runtime.gcStart /usr/local/go/src/runtime/mgc.go:722 这是一种潜在的故障模式。 Go 的 GC 必须在两个点停止世界:当它执行清除终止时,以及当它执行标记终止时。 30 分钟内我们有 312 次这样的停顿。这就是发生这种情况的原因:运行时分配这些页面。它们没有被释放,而是被重用。这些页面在 GC 周期中被读取。因为内核按年龄逐出页面,所以它发送最近最少访问的页面进行交换。 GC 运行,停止世界,尝试读取这些页面,但现在我们遇到了严重的页面错误。内核需要读取 PTE,然后调用 do_swap_page,找到一个新帧,将其记入 cgroup,读取页面,提交 bio,等待磁盘,然后将它们放回内存中 - 只是为了保持简短。乍一看,这 40 毫秒似乎无害。但我们正在谈论的是世界停止的暂停。这 40 毫秒意味着一切都停止了——用 Go 的术语来说,每个 P 都停止了,因此,例如,如果一个 Goroutine 正在等待 I/O,那么在暂停期间 I/O 可能会返回,并且不会有人来处理它。 40ms 是中值暂停的 800 倍。在测试过程中,每次内存峰值都会发生两到三次。这是很多。然后我注意到另一件事:构建一条 511 KiB 的消息通常需要 3-5 毫秒,在 NVMe 上跃升至 105 毫秒,在 Hetzner 的网络卷上跃升至 903 毫秒。对于每条消息,这比元数据暂停的成本更高。但只有执行分配的 Goroutine 才会付出这个代价,所以至少它是本地化的,而不是像元数据那样是全局的。我还没有确认时间都去哪儿了,但即便如此我还是想在这里提一下,因为这是你必须支付的另一个成本。无论如何,我同意 Chris Down 的观点,交换并不是邪恶的。但是,是的,它在垃圾收集方面表现不佳,而且在生产中,我收集了很多垃圾。 9 月 14 日更新。有人问我 Go 1.26 的 Green Tea 垃圾收集器是否改变了 GC 读取元数据的方式。我测了一下,影响可以忽略不计。
这篇文章对您有帮助吗?
订阅66必读
每日精选科技资讯,直达你的邮箱