← 文章 / 编程开发
Hacker News 5小时前 · 2026-07-28 08:45:01 · 2 阅读

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

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.
原始来源: Hacker News

评论 (0)