Go 中欺骗 Reaper:利用 GC 实现手动内存管理

Yeah, I drew this. Check out my art blog.

mcyoung

我是 Miguel,我写关于编译器、性能和有趣的计算机的东西。我还画宝可梦。

2025-04-21 • 3846 words • 32 minutes • #dark-arts#pointers#go

Go 中欺骗 Reaper

即使我本质上是一名 C++ 程序员,Go 仍然让我着迷,但原因并非你所想。Go 在设计上做出了几个有趣的决定:

  1. 它几乎没有 Undefined Behavior[1]。
  2. 它具有非常简单的 GC 语义,并且由于表面语言的设计决策,它们大多被限制于此。

这些意味着,尽管 Go 拥有 GC,但仍然可以在纯 Go 中,并与 GC 协同工作的情况下进行手动内存管理(尽管没有任何来自 runtime 包的帮助)。为了演示这一点,我们将构建一个非类型化的、由垃圾回收的 arena 抽象,它依赖于几个 GC 的实现细节。

我永远不会在 Rust 或 C++ 中玩这种游戏,因为 LLVM 非常智能,并且能够在频繁的编译器升级过程中找到各种方法来破坏你的代码。另一方面,尽管 Go 并没有承诺任何跨版本的代码兼容性(对于导入 unsafe 的代码),但实际上,有两种力量在阻止 Go 这样做:

  1. Go 不试图定义什么是允许的,什么是不允许的:unsafe 缺乏任何 operational semantics
  2. Go 优先考虑不破坏生态系统;这使得我们可以假定 Hyrum’s Law 将保护 runtime 的某些可观察行为,从中我们可以推断出什么可以轻易破坏,什么不能。

这与 LLVM 这样的高性能本地编译器形成对比,后者在所有 UB 周围都有精心定义的边界,允许它们任意破坏跨越边界的程序(主要是),而不用担心破坏生态系统。

因此,让我们深入探讨并欺骗死神。

我们要构建什么?

我们的目标是构建一个 arena ,这是一种用于高效分配具有相同生命周期的内存的数据结构。这通过仅以大块请求内存,然后一次性释放所有内存来减少通用分配器的压力。

为了在 Go 中进行比较,请考虑以下程序:

package main

import "fmt"

func main() {
	var s []int
	for i := range 1000 {
		prev := cap(s)
		s = append(s, i)
		if cap(s) != prev {
			fmt.Println(cap(s))
		}
	}
}

此程序将打印连续的 2 的幂:这是因为 append 的实现大致如下:

func append[S ~[]T, T any](a, b S) S {
	// If needed, grow the allocation.
	if cap(a)-len(a) < len(b) {
		// Either double the size, or allocate just enough if doubling is
		// too little.
		newCap := max(2*cap(a), len(a)+len(b))
		// Grow a.
		a2 := make([]T, len(a), newCap)
		copy(a2, a)
		a = a2
	}
	// Increase the length of a to fit b, then write b into the freshly
	// grown region.
	a = a[:len(a)+len(b)]
	copy(a[len(a)-len(b):], b)
	return a
}

对于追加小块,make 仅被调用 O(log⁡n)O(\log n)O(logn) 次,相比于每次调用 append 都调用它,这是一个很大的改进。几乎每种编程语言的动态数组抽象都进行了这种优化。

arena 概括了这个概念,但是它不是以指数方式调整大小,而是分配 新的 块并将指针传递给它们。我们想要符合的接口如下:

type Allocator interface {
	Alloc(size, align uintptr) unsafe.Pointer
}

在 Go 中,输入大小和对齐方式,就会输出具有该布局的新内存的指针。Go 没有用户可见的未初始化内存,因此我们还要求返回的区域被置零。我们还要求 align 是 2 的幂。

我们可以通过编写泛型 New 函数来为此提供类型安全的接口:

// New allocates a fresh zero value of type T on the given allocator, and
// returns a pointer to it.
func New[T any](a Allocator) *T {
	var t T
	p := a.Alloc(unsafe.Sizeof(t), unsafe.Alignof(t))
	return (*T)(p)
}

对于那些习惯于在 C++ 中用 mallocoperator new 伤害自己的人来说,这感觉非常好,但是有一个小问题。当我们把指针类型的内存分配到这个 allocator 中会发生什么?

// Allocate a pointer in our custom allocator, and then
// initialize it to a pointer on the Go heap.
p := New[*int](myAlloc)
*p = new(int)
runtime.GC()
**p = 42 // Use after free!

Allocator.Alloc 接受大小和对齐方式,这足以描述任何类型的 布局 。例如,在 64 位系统上,int*int 具有相同的布局:8 字节的大小和 8 字节的对齐方式。

但是,Go GC(以及所有垃圾收集器,通常)需要一个额外的信息,它介于值的布局(它在内存中的位置)和值的类型(关于其结构的丰富信息)之间。要理解这一点,我们需要简要概述 GC 的作用。

Mark and Sweep

有关如何构建简单 GC 的完整概述,请查看我前段时间设计的玩具 GC:The Alkyne GC

垃圾收集器的职责是维护内存分配器并记录:

  1. 哪些内存已被分配。
  2. 该内存是否仍在被使用。

未使用的内存可以被回收并标记为未分配,以便重新使用。

最流行的方式是通过 “mark and sweep” 架构。GC 将定期从某些预定的 遍历程序的整个对象图;它找到的任何东西都被 “标记” 为 alive。在标记完成后,所有其他内存都被 “sweep”,这意味着将其标记为未分配以便将来重用,或者在大量剩余的情况下将其返回给操作系统。

根通常是程序正在主动操作的实体。在 Go 的情况下,这是当前在某个 G[2]的堆栈上的任何内容,或全局变量中的任何内容(其中有一组编译时已知的)。

标记阶段从 堆栈扫描 开始,它查看每个 G 的堆栈并找到其中包含的任何指针。Go 编译器为每个函数生成元数据,指定函数帧中的哪些堆栈槽包含指针。所有这些指针在定义上都是 live 的。

这些指针被放入队列中,并且每个指针都被跟踪到它在堆上的分配。如果 GC 不知道关于特定地址的任何信息,则将其作为不需要标记的外部内存丢弃。如果它知道,则该分配中的每个指针都会被推送到队列中(如果它尚未被标记为 alive)。该过程一直持续到队列为空。

这里的关键步骤是获取某个分配的地址,并将其转换为其中的所有指针值。Go 具有精确的垃圾收集,这意味着它仅将表面语言中声明为指针的东西视为指针:恰好看起来像地址的整数不会导致 sweep。这导致了更有效的内存使用,但也增加了 GC 的复杂性。

例如,类型 *intmap[int]bytestringstruct {A int; B *int} 都包含至少一个指针,而 int[1000]bytestruct {X bool; F uintptr} 则没有。后者被称为 pointer-free 类型。

Go 通过添加一个位集来增强类型的布局,该位集指定类型的内存区域中的哪些指针对齐的、指针大小的字包含指针,从而将其增强为 shape 。这些被称为 pointer bits 。例如,以下是 64 位系统上的一些 Go 类型的 shape。

Type | Size/Align | Pointer Bits[3] ---|---|--- byte | 1/1 | 0 int | 8/8 | 0 rune | 4/4 | 0 *int | 8/8 | 1 unsafe.Pointer | 8/8 | 1 string | 16/8 | 10 []int | 24/8 | 100 [3]string | 48/8 | 101010 map[int]byte | 8/8 | 1 map[int]string | 8/8 | 1 any | 16/8 | 01[4] error | 16/8 | 01 func(int) int | 8/8 | 1 runtime.hchan[5] | 104/8 | 0010110011110

在 Go GC 中,每个分配都用它的 shape 进行标记(这在 GC 中以各种方式完成,可以通过分配上的显式 header 本身(“malloc header”),存储在分配的 runtime.mspan 中的 runtime 类型,或其他机制)。当扫描一个值时,它使用此信息来确定要扫描的指针在哪里。

我们的 Allocator.Alloc 类型最明显的问题是它不区分 shape,因此它无法分配包含指针的内存:GC 将无法找到指针,并将过早地释放它们!

在我们的示例中,我们在自定义 allocator 中分配了一个 *int,最终在堆栈上得到了一个 **int。你可能会认为 Go 会简单地跟踪第一个 * 以找到一个 *int 并将其标记为 alive,但事实并非如此!Go 而是找到一个指向自定义 allocator 从堆中获取的某个块的指针,该块缺少其 shape 的 pointer bits!

为什么 Go 不查看它所遍历的指针的类型?原因有两个。

  1. 从 runtime 的角度来看,Go 中的所有指针都是非类型化的;每个 *T 都会被擦除为一个 unsafe.Pointer。这使得很多 Go runtime 可以在不使用实际泛型的情况下 “通用”。
  2. Pointee 元数据可以被聚合,因此指向对象的每个指针不必在 runtime 记住它的类型。

对我们来说,最终结果是我们不能把指针放在 arena 上。这使得我们的 New API 不安全,特别是由于 Go 没有提供用于将泛型参数标记为 pointer-free 的标准约束:毫不奇怪,他们不希望大多数用户关心这样的细节。

可以使用反射来推断类型的 pointer bits,但是这非常慢,并且使用 arenas 的全部意义在于要快。但是,当我们设计我们的 arena 时,将会清楚地看到有一种安全的方法可以在其上放置指针。

设计 Arena

现在我们对 Go GC 在做什么有了很好的理解,我们可以开始设计一个快速的 arena 结构。

理想的情况是调用 Alloc 非常快:在常见情况下只是偏移一个指针。我们可以立即做出的一个假设是,可以强制所有内存具有最大对齐方式:大多数对象是指针或更大,并且 Go 确实对普通用户类型具有最大对齐方式,因此我们可以忽略 align 参数,并始终对齐到例如 8 字节。这意味着指向下一个未分配块的指针将始终是对齐的。因此,我们可能会提出像这样的结构:

type Arena struct {
	next   unsafe.Pointer
	left, cap uintptr
}

const (
	// Power of two size of the minimum allocation granularity.
	wordBytes = 8 // Depends on target, this is for 64-bit.
	minWords  = 8
)

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
	// First, round the size up to the alignment of every object in
	// the arena.
	mask := wordBytes - 1
	size = (size + mask) &^ mask
	// Then, replace the size with the size in pointer-sized words.
	// This does not result in any loss of size, since size is now
	// a multiple of the uintptr size.
	words := size / wordBytes
	// Next, check if we have enough space left for this chunk. If
	// there isn't, we need to grow.
	if a.left < words {
		// Pick whichever is largest: the minimum allocation size,
		// twice the last allocation, or the next power of two
		// after words.
		a.cap = max(minWords, a.cap*2, nextPow2(words))
		a.next = unsafe.Pointer(unsafe.SliceData(make([]uintptr, a.cap)))
		a.left = a.cap
	}
	// Allocate the chunk by incrementing the pointer.
	p := a.next
	a.left -= words
	if a.left > 0 {
		a.next = unsafe.Add(a.next, size)
	} else {
		// Beware, offsetting to one-past-the-end is one of the few
		// things explicitly not allowed by Go.
		a.next = nil
	}
	return p
}

// nextPow2 returns the smallest power of two greater than n.
func nextPow2(n uintptr) uintptr {
	return uintptr(1) << bits.Len(uint(n))
}

这到底有多快?这是一个简单的 benchmark。

func BenchmarkArena(b *testing.B) {
	bench[int](b)
	bench[[2]int](b)
	bench[[64]int](b)
	bench[[1024]int](b)
}

const runs = 100000

var sink any

func bench[T any](b *testing.B) {
	var z T
	n := int64(runs * unsafe.Sizeof(z))
	name := fmt.Sprintf("%v", reflect.TypeFor[T]())
	b.Run(name, func(b *testing.B) {
		b.Run("arena", func(b *testing.B) {
			b.SetBytes(n)
			for b.Loop() {
				a := new(arena.Arena)
				for range runs {
					sink = arena.New[T](a)
				}
			}
		})
		b.Run("new", func(b *testing.B) {
			b.SetBytes(n)
			for b.Loop() {
				for range runs {
					sink = new(T)
				}
			}
		})
	})
}

该 benchmark 的重点是衡量分配许多相同大小的对象的成本。for b.Loop() 循环将执行的次数是未知的,并且由 benchmark 框架确定,以尝试减少统计异常。这意味着,如果我们改为仅 benchmark 单个分配,则结果将对运行次数 非常 敏感。

我们还使用 b.SetBytes 来获取 benchmark 的吞吐量测量。这比 benchmark 产生的原始 ns/op 更容易解释。它告诉我们每个 allocator 每单位时间可以分配多少内存。

我们想与 new 进行比较,但是仅编写 _ = new(T) 将被优化掉,因为生成的指针不会 escape。将其写入全局变量足以使 Go 确信它 escape。

这是结果,缩写为仅显示每秒的字节数。所有 benchmark 都是在我的 AMD Ryzen Threadripper 3960X 上执行的。越大越好。

BenchmarkArena/int/arena-48     794.84 MB/s
BenchmarkArena/int/new-48      390.59 MB/s
BenchmarkArena/[2]int/arena-48   1263.58 MB/s
BenchmarkArena/[2]int/new-48    528.06 MB/s
BenchmarkArena/[64]int/arena-48   7370.08 MB/s
BenchmarkArena/[64]int/new-48    2865.24 MB/s
BenchmarkArena/[1024]int/arena-48  9889.20 MB/s
BenchmarkArena/[1024]int/new-48   2875.75 MB/s

这非常好,当然值得追求!性能提升似乎随着分配的内存量的增加而扩大,在不同情况下提高了 2 倍到 4 倍。

现在我们需要应对这样一个事实,即如果我们要在其上放置指针,我们的实现将完全损坏。

不把内存丢在地上

(*Arena).Alloc 中,当我们分配一个新分配的 chunk 时,我们会覆盖 a.next,这意味着 GC 可以回收它。但这很好:只要指向该 arena chunk 的指针是 alive 的,GC 就不会释放它,而与 arena 无关。因此,我们似乎不必担心它?

但是,arena 的全部意义在于分配许多具有相同生命周期的内存。这对于图数据结构(例如 AST 或编译器 IR)很常见,它们执行大量工作来分配大量内存,然后丢弃结果。

我们不允许将指针放入 arena 中,因为它们将从 GC 的视野中消失并过早地被释放。但是,如果一个指针想要进入 arena,它必然会 outlive 整个 arena,因为它 outlive 了 arena 的一部分,并且 arena 旨在具有相同的生命周期。

特别是,如果我们可以这样做,即持有 Alloc 返回的任何指针都可以防止 整个 arena 被 GC sweep,则 arena 可以安全地包含指向自身的指针!考虑一下:

  1. 我们有一个指针 p **int。它被分配在某个 arena a 上。
  2. GC 看到我们的指针(作为一个类型擦除的 unsafe.Pointer),并将其分配标记为 alive。
  3. 不知何故,GC 也因此将 a 标记为 alive。
  4. 不知何故,GC 然后将 a 已分配的每个 chunk 标记为 alive。
  5. 因此,*p 指向的 chunk 也是 alive 的,因此 *p 不需要直接标记,并且不会过早释放。

步骤(3)至关重要。通过强制标记整个 arena,arena 中存储的任何指向自身的指针都将自动保持 alive,而无需 GC 知道如何扫描它们。

因此,即使 *New[*int](a) = new(int) 仍然会导致 use-after-free,*New[*int](a) = New[int](a) 也不会!这种小小的改进并不能使 arenas 本身安全,但是具有内部 arena 的数据结构可以完全安全,只要进入 arena 的唯一指针来自 arena 本身。

我们如何才能使它工作?简单部分是(4),我们可以通过向 arena 添加一个 []unsafe.Pointer,并将我们分配的每个指针放入其中来实现。

type Arena struct {
	next   unsafe.Pointer
	left, cap uintptr
	chunks []unsafe.Pointer // New field.
}

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
	// ... snip ...
	if a.left < words {
		// Pick whichever is largest: the minimum allocation size,
		// twice the last allocation, or the next power of two
		// after words.
		a.cap = max(minWords, a.cap*2, nextPow2(words))
		a.next = unsafe.Pointer(unsafe.SliceData(make([]uintptr, a.cap)))
		a.left = a.cap
		a.chunks = append(a.chunks, a.next)
	}
	// ... snip ...
}

append 的成本是摊销的:要分配 n 字节,我们最终会额外分配 O(log⁡log⁡n)O(\log \log n)O(loglogn) 次。但这会对我们的 benchmark 产生什么影响?

BenchmarkArena/int/arena-48     800.08 MB/s
BenchmarkArena/int/new-48      386.81 MB/s
BenchmarkArena/[2]int/arena-48   1236.00 MB/s
BenchmarkArena/[2]int/new-48    520.84 MB/s
BenchmarkArena/[64]int/arena-48   7999.71 MB/s
BenchmarkArena/[64]int/new-48    2706.68 MB/s
BenchmarkArena/[1024]int/arena-48  9998.00 MB/s
BenchmarkArena/[1024]int/new-48   2816.28 MB/s

看起来几乎相同,这是一个好兆头。

Back Pointers

现在 arena 不会丢弃任何分配的内存,我们可以专注于条件(3):使 Alloc 返回的任何指针保持 alive,那么整个 arena 也保持 alive。

在这里,我们可以利用 Go GC 如何工作的一个重要属性:指向分配的任何指针都会使其保持 alive,以及 从该指针可达的任何东西 。但是我们分配的 chunks 是 []uintptrs,它们不会被扫描。如果 以某种方式 该 slice 中有一个被扫描的指针,我们将能够将指针 a *Arena 粘贴在那里,因此当扫描 Alloc 返回的任何内容时,它将导致 a 被标记为 alive。

到目前为止,我们一直使用 make([]T) 分配 [N]uintptr,但是我们实际上想分配 struct { A [N]uintptr; P unsafe.Pointer },其中 N 是一些动态值。

以其无限的智慧,Go 标准库实际上为我们提供了一种专门的机制来做到这一点:reflect.StructOf。这可以用于在 runtime 构造任意的匿名 struct 类型,然后我们可以在堆上分配这些类型。

因此,我们可能调用以下函数而不是调用 make

func (a *Arena) allocChunk(words uintptr) unsafe.Pointer {
	chunk := reflect.New(reflect.StructOf([]reflect.StructField{
		{
			Name: "X0",
			Type: reflect.ArrayOf(int(words), reflect.TypeFor[uintptr]()),
		},
		{Name: "X1", Type: reflect.TypeFor[unsafe.Pointer]()},
	})).UnsafePointer()
	// Offset to the end of the chunk, and write a to it.
	end := unsafe.Add(chunk, words*unsafe.Sizeof(uintptr(0)))
	*(**Arena)(end) = a
	return chunk
}

这似乎对性能[6]产生了一点但很明显的影响。

BenchmarkArena/int/arena-48     763.91 MB/s
BenchmarkArena/int/new-48      385.49 MB/s
BenchmarkArena/[2]int/arena-48   1174.00 MB/s
BenchmarkArena/[2]int/new-48    524.32 MB/s
BenchmarkArena/[64]int/arena-48   7563.54 MB/s
BenchmarkArena/[64]int/new-48    2649.63 MB/s
BenchmarkArena/[1024]int/arena-48  8668.02 MB/s
BenchmarkArena/[1024]int/new-48   2648.10 MB/s

更多优化

回顾 Arena.Alloc,此函数的结尾有一个分支:

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
	// ... snip...
	// Allocate the chunk by incrementing the pointer.
	p := a.next
	a.left -= words
	if a.left > 0 {
		a.next = unsafe.Add(a.next, size)
	} else {
		// Beware, offsetting to one-past-the-end is one of the few
		// things explicitly not allowed by Go.
		a.next = nil
	}
	return p
}

这是分配最热的部分,因为它每次调用此函数时都会执行。该分支有点不幸,但正如注释所指出的,这是必要的。

在 C++ 中,如果我们有一个包含 n 个元素的 int 数组,并且 int* p 是指向数组开始的指针,则 p + n 是一个有效的指针,即使它不能被取消引用;它指向数组的 “one past the end”。这是一个有用的构造,例如,你可以使用它来消除循环归纳变量:

// Naive for loop, has an induction variable i.
for (int i = 0; i < n; i++) {
	do_something(p[i]);
}

// Faster: avoids the extra variable increment in the loop
// body for doing p[i].
for (auto end = p + n; p < end; p++) {
	do_something(*p);
}

但是,Go 如果你这样做会非常不高兴,因为它会混淆垃圾收集器。GC 无法区分分配 A 的 one-past-the-end 指针和紧随其后的分配 B 的开始。充其量,这会导致内存保持 alive 更长时间,最坏的情况下会触发 GC 中的安全互锁。如果 GC 碰巧扫描了一个它知道已被释放的地址的指针,它将 panic。

但是在我们上面的代码中,每个 chunk 现在都在最后都有一个额外的元素,该元素不用于分配,因此我们可以拥有一个指针,该指针 我们从中出售内存的 [N]uintptr 的 one-past-the-end。

更新后的分配函数如下所示:

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
	// ... snip ...
	// Allocate the chunk by incrementing the pointer.
	p := a.next
	a.next = unsafe.Add(a.next, size)
	a.left -= words
	return p
}

值得注意的是,我们不会用结束指针替换 a.left,因为有 if a.left < words 比较。我们实际上无法避免减法 a.left -= words,因为如果我们摆脱了 a.left,我们将不得不这样做才能使此比较起作用。

那么这有多好?

BenchmarkArena/int/arena-48     780.07 MB/s
BenchmarkArena/int/new-48      383.16 MB/s
BenchmarkArena/[2]int/arena-48   1245.73 MB/s
BenchmarkArena/[2]int/new-48    530.39 MB/s
BenchmarkArena/[64]int/arena-48   7684.39 MB/s
BenchmarkArena/[64]int/new-48    2679.94 MB/s
BenchmarkArena/[1024]int/arena-48  8859.99 MB/s
BenchmarkArena/[1024]int/new-48   2611.33 MB/s

非常出色,不是很棒!这是一个百分之一或二的数量级的改进。这是因为我们删除的分支非常可预测。

事实证明,我们可以做出更大的改进。

写屏障 (Write Barriers)

这是 Go 为此函数生成的汇编代码,经过大量缩减,并用相应的 Go 源代码进行了注释。

TEXT (*Arena).Alloc(SB)
	CMPQ  SP, 0x10(R14)
	JBE   moreStack ; Stack growth prologue.
	PUSHQ  BP
	MOVQ  SP, BP
	SUBQ  $0x58, SP
	; size = (size + mask) &^ mask
	LEAQ  0x7(BX), DX
	ANDQ  $-0x8, DX
	; words := size / wordBytes
	MOVQ  DX, SI
	SHRQ  $0x3, DX
	; if a.left < words
	CMPQ  0x8(AX), DX
	JAE   alloc
	MOVQ  AX, 0x68(SP)
	MOVQ  SI, 0x48(SP)
	MOVQ  DX, 0x40(SP)
	; nextPow2(words)
	MOVZX  runtime.x86HasPOPCNT(SB), DI
	TESTL  DI, DI
	JE   1f
	XORL  DI, DI
	POPCNTQ DX, DI
	JMP   2f
1:
	MOVQ  DX, AX
	CALL  math/bits.OnesCount(SB)
	MOVQ  0x40(SP), DX
	MOVQ  0x48(SP), SI
	MOVQ  AX, DI
	MOVQ  0x68(SP), AX
2:
	CMPQ  DI, $0x1
	JE   1f
	BSRQ  DX, CX
	MOVQ  $-0x1, DI
	CMOVE  DI, CX
	INCQ  CX
	MOVL  $0x1, DI
	SHLQ  CL, DI
	CMPQ  CX, $0x40
	SBBQ  R8, R8
	ANDQ  R8, DI
	MOVQ  DI, DX
1:
	MOVQ  0x10(AX), CX
	SHLQ  $0x1, CX
	; a.cap = max(minWords, a.cap*2, nextPow2(words))
	CMPQ  CX, $0x8
	MOVL  $0x8, BX
	CMOVA  CX, BX
	CMPQ  DX, BX
	CMOVA  DX, BX
	MOVQ  BX, 0x10(AX)
	; a.next = a.allocChunk(a.cap)
	CALL  github.com/mcy/go-arena.(*Arena).allocChunk(SB)
	CMPL  runtime.writeBarrier(SB), $0x0
	JNE   1f
	MOVQ  0x68(SP), DX
	JMP   2f
1:
	CALL  runtime.gcWriteBarrier2(SB)
	MOVQ  AX, 0(R11)
	MOVQ  0x68(SP), DX
	MOVQ  0(DX), R8
	MOVQ  R8, 0x8(R11)
2:
	MOVQ  AX, 0(DX)
	; a.left = a.cap
	MOVQ  0x10(DX), R8
	MOVQ  R8, 0x8(DX)
	MOVQ  0x28(DX), CX
	MOVQ  0x20(DX), BX
	INCQ  BX
	MOVQ  0x18(DX), R8
	CMPQ  CX, BX
	JAE   2f
	; a.chunks = append(a.chunks, a.next)
	MOVQ  AX, 0x50(SP)
	MOVQ  R8, AX
	MOVL  $0x1, DI
	LEAQ  0x28f70(IP), SI
	CALL  runtime.growslice(SB)
	MOVQ  0x68(SP), DX
	MOVQ  CX, 0x28(DX)
	CMPL  runtime.writeBarrier(SB), $0x0
	JE   1f
	CALL  runtime.gcWriteBarrier2(SB)
	MOVQ  AX, 0(R11)
	MOVQ  0x18(DX), CX
	MOVQ  CX, 0x8(R11)
1:
	MOVQ  AX, 0x18(DX)
	MOVQ  AX, R8
	MOVQ  0x50(SP), AX
2:
	MOVQ  BX, 0x20(DX)
	CMPL  runtime.writeBarrier(SB), $0x0
	JE   1f
	CALL  runtime.gcWriteBarrier2(SB)
	MOVQ  AX, 0(R11)
	MOVQ  -0x8(R8)(BX*8), CX
	MOVQ  CX, 0x8(R11)
1:
	MOVQ  AX, -0x8(R8)(BX*8)
	MOVQ  DX, AX
	MOVQ  0x40(SP), DX
	MOVQ  0x48(SP), SI
alloc:
	; p := a.next
	MOVQ  0(AX), CX
	; a.next = unsafe.Add(a.next, size)
	LEAQ  0(CX)(SI*1), BX
	CMPL  runtime.writeBarrier(SB), $0x0
	JE   1f
	CALL  runtime.gcWriteBarrier2(SB)
	MOVQ  BX, 0(R11)
	MOVQ  0(AX), SI
	MOVQ  SI, 0x8(R11)
1:
	MOVQ  BX, 0(AX)
	; a.left -= words
	LEAQ  0(CX)(SI*1), BX
	SUBQ  DX, 0x8(AX)
	; return p
	MOVQ  CX, AX
	ADDQ  $0x58, SP
	POPQ  BP
	RET

x86 Assembly (Go Syntax)

此函数中有很多事情在发生,但其中大多数是 Go 不擅长寄存器分配以及大量 写屏障 的混合。

写屏障是一种将普通用户代码与 GC 同步的机制。每当存储非 pointer-free 类型时,Go 都会生成代码。例如,写入 **int*string*[]int 需要写屏障。

写屏障的实现如下:

  1. 检查 runtime.writeBarrier,它确定是否需要写屏障,这仅在 GC 处于标记阶段时才需要。否则,将采用分支跳过写屏障。
  2. 调用其中一个 runtime.gcWriteBarrierN 函数。N 是 GC 需要通知的指针数。
  3. 此函数调用 runtime.gcWriteBarrier,它返回一个缓冲区,GC 现在需要追踪的指针应该写入该缓冲区。
  4. 发生实际的存储。

在以下情况下需要写屏障。考虑以下代码。

func alloc(n **int) {
	*n = new(int)
}

此函数将调用 runtime.newobject 来分配 8 字节的内存。生成的指针将在 rax 中返回。然后,此函数将 rax 存储到 n 中并返回。如果我们 Godbolt 此函数,我们将发现它实际上生成了一个写屏障:

TEXT x.alloc
	CMPQ  SP, 16(R14)
	JLS   growStack
	PUSHQ  BP