观察 Go 新垃圾回收器在堆中的移动
观察 Go 新垃圾回收器在堆中的移动
Go 1.26 将 Green Tea 设为默认 GC。我们用 perf 观察其缓存友好性,可视化堆来了解 Go 的分配方式,并探究 Go 这样的非移动式回收器所面临的一个稀疏页面问题。作者:Phil Eaton,2026 年 7 月 19 日 Focus
您作为订阅者提前看到了这篇文章。您的支持让这类文章成为可能。谢谢。
去年发布的 Go 1.25 引入了一个新的垃圾回收器:Green Tea。而在几个月前发布的 Go 1.26 中,Green Tea 已成为默认选项。那篇链接文章非常出色。我们将回顾一下,并看看哪些程序受益最大,哪些程序没有受益,反而触发了 Go 垃圾回收器遗留的顽疾:它的非移动式回收器无法回收稀疏页面。
回过头来看,Go 管理内存的方式是:将相同大小类(对象大小向上取整到最近的大小类)的对象分配在由一或多个 8KiB 页面组成的连续块(Go 术语中称为 span)中。按大小隔离分配在一些 malloc 实现(如 tcmalloc,Go 的分配器 源自于它)中很常见。
我们来看看 Go 中这个过程的实际情况,然后与 C# 做个对比。我们会随机分配三种不同大小(小、中、大)的对象,然后检查它们的堆地址,遍历地址空间,每遇到一个我们的对象就打印出来,每遍历 32 字节打印一个字符。
首先安装 Go 和 C#。
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 来实现它。
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# 的例子。
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
var allocs = new Func<object>[] { () => 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<Type, int>();
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<object, nint>(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<object, nint>(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} 轮 (基址 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 的内存分配方式,接下来看看它是如何清理内存的。
Mark and sweep#
垃圾回收器从特定的根对象(例如全局变量和局部变量)开始,在Go语言的传统做法中,它会沿着每个指针一路追踪,直到访问完所有可达对象——这就是标记阶段。然后,在第二轮扫描中,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) # prevent `nodes` from being garbage collected
为了让程序测量更公平一点(散乱版本需要大量生成随机数),我们将节点索引偏移量的生成单独抽出来:
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" # matches Go's uint32 cast
with open(order+".idx", "wb") as f:
a.tofile(f)
generate_indexes.py
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) // keep `nodes` from seeming to fall out of scope
}
readorder.go
现在用 Python 脚本生成索引文件。然后构建两个版本的 Go 程序:一个启用 Green Tea,一个不启用。
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 对两个垃圾收集器和两个工作负载进行计时,同时收集缓存未命中信息。$ 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.