观察 Go 的新垃圾回收器在堆中的移动

Lobsters Hottest 新闻

摘要

Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。

<p><a href="https://lobste.rs/s/u60zv9/watching_go_s_new_garbage_collector_move">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/24 21:06

# 观察 Go 新垃圾回收器在堆中的移动过程 来源:https://theconsensus.dev/p/2026/07/19/observing-gos-garbage-collector-old-and-new.html The Consensus 标志 (https://theconsensus.dev/) 关于软件基础设施。 ## 观察 Go 新垃圾回收器在堆中的移动过程 Go 1.26 将 Green Tea 设为默认垃圾回收器。我们通过 `perf` 观察其缓存友好性,可视化堆以了解 Go 如何分配内存,并探讨像 Go 这种非移动回收器难以处理的稀疏页问题。 作者:Phil Eaton 2026 年 7 月 19 日 聚焦 (https://theconsensus.dev/p/2026/07/19/observing-gos-garbage-collector-old-and-new.html#) 您作为订阅者正在提前阅读本文。您的支持使这类文章成为可能。感谢您。 去年发布的 Go 1.25 引入了一个新的垃圾回收器:Green Tea (https://go.dev/blog/greenteagc?from_theconsensus=1)。而在几个月前发布的 Go 1.26 中,Green Tea 成为默认回收器。那篇链接的文章非常出色。我们将简要回顾它,并研究几个受益最大的程序。我们还会看一些没有受益的程序,它们触发了 Go 残留的垃圾回收器 bugaboo:其非移动回收器无法回收稀疏页。 回顾一下,Go 通过将同一大小类(对象大小向上取整到最接近的大小类)的对象分配在一个或多个 8KiB *页* 的连续块(Go 术语中称为 *span*)中来管理内存。按大小隔离的分配在一些 malloc 实现中很常见(例如 tcmalloc,Go 的分配器源自它 (https://go.dev/doc/gc-guide?from_theconsensus=1))。让我们观察 Go 中发生的情况,然后与 C# 进行比较。我们将随机分配三种不同大小(小、中、大)的对象。然后检查它们的堆地址,遍历地址空间,并在遇到我们的对象时打印一个字符,每 32 字节打印一个字符。 首先安装 Go 和 C#。 ```bash sudo apt update -y sudo apt-get install -y dotnet-sdk-10.0 curl -fsSL https://go.dev/dl/go1.26.0.linux-amd64.tar.gz | sudo tar -C /usr/local -xz export PATH=$PATH:/usr/local/go/bin ``` 这是我们想要实现的伪代码。 ``` struct Small { a [32]byte } struct Medium { a [64]byte } struct Large { a [128]byte } constructors = [Small, Medium, Large] live = [] # 阻止对象被回收 for i in range(100): live.push(new constructors[rand() % len(constructors)]) for pass in [0, 1]: if pass == 1: runtime.gc() # 触发 GC records = [] for obj in live: records.push((runtime.addressof(obj), runtime.typeof(obj), runtime.sizeof(obj))) records.sort(key = r -> r.address) cell = 32 cursor = records[0].address for (addr, typ, size) in records: while cursor < addr: # 此处没有我们的对象 print("."); cursor += cell head = typ.name[0] print(upper(head) + "-" * (size/cell - 1)) # "S" / "M-" / "L---" cursor += size ``` 让我们用 Go 构建它。 ```go package main import ( "bytes" "cmp" "fmt" "math/rand" "reflect" "runtime" "slices" ) type ( Small struct{ _ [32]byte } // 32 字节 Medium struct{ _ [64]byte } // 64 字节 Large struct{ _ [128]byte } // 128 字节 ) type object struct { addr uintptr size int name byte // 'S' / 'M' / 'L' } func main() { allocs := []func() any{ func() any { return new(Small) }, func() any { return new(Medium) }, func() any { return new(Large) }, } live := make([]any, 100) // 保持引用,使 GC 无法回收,并且我们知道每个类型 for i := range live { live[i] = allocs[rand.Intn(len(allocs))]() } for pass := 0; pass < 2; pass++ { if pass == 1 { runtime.GC() // Go 从不移动对象:第 1 次与第 0 次完全相同 } objs := make([]object, len(live)) for i, o := range live { t := reflect.TypeOf(o).Elem() objs[i] = object{reflect.ValueOf(o).Pointer(), int(t.Size()), t.Name()[0]} } slices.SortFunc(objs, func(a, b object) int { return cmp.Compare(a.addr, b.addr) }) fmt.Printf("\n=== pass %d (base 0x%x) ===\n", pass, objs[0].addr) draw(objs) } } func draw(objs []object) { const cell, width = 32, 60 last := objs[len(objs)-1] base := objs[0].addr grid := make([]byte, int((last.addr+uintptr(last.size)-base)/cell)) for i := range grid { grid[i] = '.' } for _, o := range objs { c0 := int((o.addr - base) / cell) grid[c0] = o.name for k := 1; k < o.size/cell; k++ { grid[c0+k] = '-' } } prev := -1 for off := 0; off < len(grid); off += width { row := grid[off:min(off+width, len(grid))] if len(bytes.Trim(row, ".")) == 0 { // 该行不包含我们的任何对象 continue } if prev >= 0 && off != prev+width { fmt.Println(" ...") } fmt.Printf("0x%09x %s\n", base+uintptr(off)*cell, row) prev = off } } ``` *heapwalk.go* 运行它,你会看到类似这样的输出。 ``` $ go run heapwalk.go === pass 0 (base 0xba4841580c0) === 0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M- 0xba484158840 M-M-M-M-.................................................... ... 0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS ... 0xba4841add40 ..........................L---L---L---L---L---L---L---L---L- 0xba4841ae4c0 --L---L---L---L---L---L---L---L---L---L---L---L---L---L---L- 0xba4841aec40 --L---L---L---L---L---L---L---L---L---L--- === pass 1 (base 0xba4841580c0) === 0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M- 0xba484158840 M-M-M-M-.................................................... ... 0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS ... 0xba4841add40 ..........................L---L---L---L---L---L---L---L---L- 0xba4841ae4c0 --L---L---L---L---L---L---L---L---L---L---L---L---L---L---L- 0xba4841aec40 --L---L---L---L---L---L---L---L---L---L--- ``` 所以即使我们在不同大小的对象之间随机分配,我们也能观察到 Go 运行时将每个大小的对象彼此相邻放置。而且即使我们运行了垃圾回收器,它也没有移动任何东西。 现在让我们看看 C#。 ```csharp using System; using System.Collections.Generic; using System.Runtime.CompilerServices; var allocs = new Func[] { () => new Small(), () => new Medium(), () => new Large() }; var live = new object[100]; // 保持引用,使 GC 无法回收,并且我们知道每个类型 var rnd = new Random(); for (int i = 0; i < live.Length; i++) live[i] = allocs[rnd.Next(allocs.Length)](); // 在 64 位 .NET 上,引用是一个普通的 8 字节指针,因此使用 // Unsafe.As 重新解释它即可获得对象地址。对象大小通过堆测量: // 分配多个对象,连续地址之间的最小间隙就是(对齐的)对象大小,包括头部。 var size = new Dictionary(); foreach (var make in allocs) { var keep = new object[16]; var a = new nint[keep.Length]; for (int i = 0; i < keep.Length; i++) keep[i] = make(); for (int i = 0; i < keep.Length; i++) a[i] = Unsafe.As(ref keep[i]); Array.Sort(a); nint best = nint.MaxValue; for (int i = 1; i < a.Length; i++) if (a[i] - a[i - 1] > 0 && a[i] - a[i - 1] < best) best = a[i] - a[i - 1]; size[keep[0].GetType()] = (int)best; } for (int pass = 0; pass < 2; pass++) { if (pass == 1) GC.Collect(); // 地址仅在下次回收前有效,因此在获取地址时暂停 GC。 var addrs = new nint[live.Length]; GC.TryStartNoGCRegion(1 << 20); for (int i = 0; i < live.Length; i++) addrs[i] = Unsafe.As(ref live[i]); GC.EndNoGCRegion(); var objs = new (nint Addr, int Size, char Name)[live.Length]; for (int i = 0; i < live.Length; i++) objs[i] = (addrs[i], size[live[i].GetType()], live[i].GetType().Name[0]); Array.Sort(objs, (x, y) => x.Addr.CompareTo(y.Addr)); Console.WriteLine($"\n=== pass {pass} (base 0x{(long)objs[0].Addr:x}) ==="); Draw(objs); } static void Draw((nint Addr, int Size, char Name)[] objs) { const int cell = 32, width = 60; // cell = 最小对象的大小 var last = objs[^1]; nint b = objs[0].Addr; var grid = new char[(last.Addr + last.Size - b) / cell]; Array.Fill(grid, '.'); foreach (var o in objs) { int c0 = (int)((o.Addr - b) / cell); grid[c0] = o.Name; for (int k = 1; k < o.Size / cell; k++) grid[c0 + k] = '-'; } int prev = -1; for (int off = 0; off < grid.Length; off += width) { var row = new string(grid, off, Math.Min(width, grid.Length - off)); if (row.Trim('.').Length == 0) continue; // 该行不包含我们的任何对象 if (prev >= 0 && off != prev + width) Console.WriteLine(" ..."); Console.WriteLine($"0x{(long)b + (long)off * cell:x9} {row}"); prev = off; } } class Small { public long a, b; } class Medium { public long a, b, c, d, e, f; } class Large { public long a, b, c, d, e, f, g, h, i, j, k, l, m, n; } ``` *HeapWalk.cs* 构建并运行它。 ``` $ dotnet run HeapWalk.cs === pass 0 (base 0x7aea1080a1e0) === 0x7aea1080a1e0 L---L---L---M-M-SL---L---SM-M-M-M-SL---L---L---L---M-L---SSL 0x7aea1080a960 ---L---M-M-L---M-SL---L---SM-L---M-L---L---L---M-L---L---M-L 0x7aea1080b0e0 ---SL---SSL---L---M-L---L---M-L---L---L---SM-SL---L---SL---L 0x7aea1080b860 ---L---SSSL---L---M-SL---SM-L---SL---M-L---M-L---M-L---SSL-- 0x7aea1080bfe0 -L---SSSM-SM-L---M-M-L---M-L--- === pass 1 (base 0x7aea1080a1e0) === 0x7aea1080a1e0 L---L---L---M-M-SL---L---SM-M-M-M-SL---L---L---L---M-L---SSL 0x7aea1080a960 ---L---M-M-L---M-SL---L---SM-L---M-L---L---L---M-L---L---M-L 0x7aea1080b0e0 ---SL---SSL---L---M-L---L---M-L---L---L---SM-SL---L---SL---L 0x7aea1080b860 ---L---SSSL---L---M-SL---SM-L---SL---M-L---M-L---M-L---SSL-- 0x7aea1080bfe0 -L---SSSM-SM-L---M-M-L---M-L--- ``` 我们立即注意到,同一大小的对象并没有分组在一起。(稍后,在不同的工作负载中,我们还会注意到 C# 在内存中移动对象。)Go 和 C# 的文档都会告诉你这些行为,但我认为通过这种演示看到它也很不错。 既然我们已经看到了 Go 如何分配内存,现在来看看它是如何清理的。 ## 标记和清除 (https://theconsensus.dev/p/2026/07/19/observing-gos-garbage-collector-old-and-new.html#mark-and-sweep) 垃圾回收器从特定的根(例如全局变量和局部变量)开始,并在 Go 的历史实现中,跟踪每个指针,直到 GC 访问所有可达的对象。*标记*阶段。然后,在第二轮中,GC 释放任何已分配但未被访问的对象。由于这些现在被释放的对象在标记阶段无法从根树访问,因此它们被认定是死的。*清除*阶段。 当你有一个对象 A 指向不同大小的对象 B/C/D 时,就会出现挑战。在 Go 中,不同大小的对象被分配在不同内存区域。或者,即使你有对象 A 指向在非常不同时间创建的其他对象 A,它们也会存在于内存的非常不同的部分。在这两种情况下,GC 跟踪指针现在引入了随机内存访问,这在缓存友好性上明显较差。 在 Green Tea 中,Go 现在扫描一个内存 span 中的对象和指针,并根据找到的指针对未来的 span 进行扫描排队,而不是大致在遇到每个指针时就跟踪它。虽然我们无法展示这种随机访问行为,除非对 Go 本身应用补丁(这样我们才能观察标记路径访问每个对象的过程),但我们可以通过 `perf` 观察到它减少了缓存未命中(每千条指令)并加快了整体程序运行。 这是我们工作负载的伪代码。 ``` struct Node {a,b,c,d *Node} mode = packed | scattered nodes = new [2_000_000]*Node for i in 0..nodes.len: nodes[i] = Node{ a: nodes[(mode == packed ? i + 1 : rand()) % nodes.len], b: nodes[(mode == packed ? i + 2 : rand()) % nodes.len], c: nodes[(mode == packed ? i + 3 : rand()) % nodes.len], d: nodes[(mode == packed ? i + 4 : rand()) % nodes.len] } for i in 0..100: trigger_gc() keepalive(nodes) # 阻止 `nodes` 被垃圾回收 ``` 为了使程序测量更公平一些(分散版本需要做大量生成随机数的工作),我们将节点索引偏移量的生成分开: ```python import array import random import sys n = 2_000_000 order = sys.argv[1] if len(sys.argv) > 1 else "" if order == "packed": a = array.array("I", ((i + k) % n for i in range(n) for k in (1, 2, 3, 4))) elif order == "scattered": r = random.Random(1) a = array.array("I", (r.randrange(n) for _ in range(n * 4))) else: sys.exit("usage: gen.py packed|scattered") assert a.itemsize == 4 and sys.byteorder == "little" # 匹配 Go 的 uint32 转换 with open(order+".idx", "wb") as f: a.tofile(f) ``` *generate_indexes.py* 然后 Go 工作负载变成: ```go package main import ( "io" "os" "runtime" "unsafe" ) type Node struct { a, b, c, d *Node } func main() { n := 2_000_000 raw, err := io.ReadAll(os.Stdin) if err != nil { panic(err) } idx := unsafe.Slice((*uint32)(unsafe.Pointer(&raw[0])), n*4) nodes := make([]*Node, n) for i := range nodes { nodes[i] = &Node{} } for i, nd := range nodes { nd.a = nodes[idx[i*4]] nd.b = nodes[idx[i*4+1]] nd.c = nodes[idx[i*4+2]] nd.d = nodes[idx[i*4+3]] } for i := 0; i < 100; i++ { runtime.GC() } runtime.KeepAlive(nodes) // 避免 `nodes` 看起来超出作用域 } ``` *readorder.go* 现在从 Python 脚本生成索引文件。然后构建两个版本的 Go 工作负载:一个带 Green Tea,一个不带。 ```bash python3 generate_indexes.py scattered python3 generate_indexes.py packed go build -o readorder_greentea readorder.go GOEXPERIMENT=nogreenteagc go build -o readorder_oldgc readorder.go ``` 让我们用 `perf` 计时两个垃圾回收器和两个工作负载,同时收集缓存未命中信息。 ```bash $ for bin in readorder_oldgc readorder_greentea; do for input in packed.idx scattered.idx; do echo "=== $bin < $input ===" perf stat -e cache-references,cache-misses -r 5 \ sh -c "exec ./$bin < $input" > /dev/null done done === readorder_oldgc < packed.idx === Performance counter stats for 'sh -c exec ./readorder_oldgc < packed.idx' (5 runs): 1,130,709,755 cache-references ( +- 0.70% ) 290,434,782 cache-misses # 25.69% of all cache refs ( +- 1.02% ) 4.230 +- 0.145 seconds time elapsed ( +- 3.44% ) === readorder_oldgc < scattered.idx === Performance counter stats for 'sh -c exec ./readorder_oldgc < scattered.idx' (5 runs): 13,247,268,612 cache-references ( +- 0.38% ) 2,325,799,796 cache-misses # 17.56% of all cache refs ( +- 0.15% ) 11.052 +- 0.154 seconds time elapsed ( +- 1.39% ) === readorder_greentea < packed.idx === Performance counter stats for 'sh -c exec ./readorder_greentea < packed.idx' (5 runs): 481,414,281 cache-references ( +- 0.27% ) 257,894,055 cache-misses # 53.57% of all cache refs ( +- 0.04% ) 2.69560 +- 0.00385 seconds time elapsed ( +- 0.14% ) === readorder_greentea < scattered.idx === Performance counter stats for 'sh -c exec ./readorder_greentea < scattered.idx' (5 runs): 3,398,491,016 cache-references ( +- 1.02% ) 2,195,902,796 cache-misses # 64.61% of all cache refs ( +- 0.10% ) 6.9610 +- 0.0108 seconds time elapsed ( +- 0.16% ) ``` 我们看到新 GC 对每个工作负载都有非常明显的改进。但看起来新 GC 的缓存未命中也增加了?这出乎我们的意料。这里涉及两件事。首先,`cache-references` 和 `cache-misses` 在 perf 中通常对应于 L3 缓存。因此,虽然新 GC 中 L3 缓存未命中的百分比可能上升,但这并不能告诉我们 L1 或 L2 缓存级别的任何行为。而且,我们自己也看到了加速。所以我们遗漏了一些东西。 其次,程序运行时间在新旧 GC 之间发生了很大变化,我们还没有将其归一化。`perf` 有一个 `instructions` 指标,我们可以用它来计算标准归一化指标:每千条指令的缓存未命中数(MPKI)。所以让我们再次运行 `perf`,同时请求 `instructions`,然后自己计算 MPKI。 ```python import json, subprocess for binary in ["readorder_oldgc", "readorder_greentea"]: for inp in ["packed.idx", "scattered.idx"]: out = subprocess.run( ["perf", "stat", "-j", "-e", "instructions,cache-misses", "-r", "5", "sh", "-c", f"exec ./{binary} < {inp}"],

相似文章

垃圾回收的实际成本

Hacker News Top

一篇技术文章解释了垃圾回收的真实性能成本,对比了 Go、Java、Rust、Swift 和 Python 等语言中的跟踪式 GC、引用计数和编译期内存管理。

Go 泛型中的 GC shape stenciling

Hacker News Top

深入解释 Go 编译器如何使用 GC shape stenciling 实现泛型,并与 Rust 的 full monomorphization 和 Java 的 type erasure 进行比较。

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。

JDK 27 G1/Parallel/Serial GC 变更

Hacker News Top

JDK 27 引入了对 HotSpot 的 stop-the-world 垃圾收集器的显著更改,最重要的是 JEP 523 使 G1 在所有环境中成为默认 GC,同时还包含各种改进、重构和错误修复。

Go 1.27 交互式导览

Lobsters Hottest

Go 1.27 新功能的实践性交互式导览,重点介绍泛型方法、结构体字面量字段选择器等,并提供基于官方发布说明的可运行示例。