Cheating the Reaper in Go
Go 中欺骗 Reaper:利用 GC 实现手动内存管理
mcyoung
我是 Miguel,我写关于编译器、性能和有趣的计算机的东西。我还画宝可梦。
2025-04-21 • 3846 words • 32 minutes • #dark-arts • #pointers • #go
Go 中欺骗 Reaper
即使我本质上是一名 C++ 程序员,Go 仍然让我着迷,但原因并非你所想。Go 在设计上做出了几个有趣的决定:
- 它几乎没有 Undefined Behavior[1]。
- 它具有非常简单的 GC 语义,并且由于表面语言的设计决策,它们大多被限制于此。
这些意味着,尽管 Go 拥有 GC,但仍然可以在纯 Go 中,并与 GC 协同工作的情况下进行手动内存管理(尽管没有任何来自 runtime
包的帮助)。为了演示这一点,我们将构建一个非类型化的、由垃圾回收的 arena 抽象,它依赖于几个 GC 的实现细节。
我永远不会在 Rust 或 C++ 中玩这种游戏,因为 LLVM 非常智能,并且能够在频繁的编译器升级过程中找到各种方法来破坏你的代码。另一方面,尽管 Go 并没有承诺任何跨版本的代码兼容性(对于导入 unsafe
的代码),但实际上,有两种力量在阻止 Go 这样做:
- Go 不试图定义什么是允许的,什么是不允许的:
unsafe
缺乏任何 operational semantics。 - 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(logn)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++ 中用 malloc
或 operator 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。
垃圾收集器的职责是维护内存分配器并记录:
- 哪些内存已被分配。
- 该内存是否仍在被使用。
未使用的内存可以被回收并标记为未分配,以便重新使用。
最流行的方式是通过 “mark and sweep” 架构。GC 将定期从某些预定的 根 遍历程序的整个对象图;它找到的任何东西都被 “标记” 为 alive。在标记完成后,所有其他内存都被 “sweep”,这意味着将其标记为未分配以便将来重用,或者在大量剩余的情况下将其返回给操作系统。
根通常是程序正在主动操作的实体。在 Go 的情况下,这是当前在某个 G[2]的堆栈上的任何内容,或全局变量中的任何内容(其中有一组编译时已知的)。
标记阶段从 堆栈扫描 开始,它查看每个 G 的堆栈并找到其中包含的任何指针。Go 编译器为每个函数生成元数据,指定函数帧中的哪些堆栈槽包含指针。所有这些指针在定义上都是 live 的。
这些指针被放入队列中,并且每个指针都被跟踪到它在堆上的分配。如果 GC 不知道关于特定地址的任何信息,则将其作为不需要标记的外部内存丢弃。如果它知道,则该分配中的每个指针都会被推送到队列中(如果它尚未被标记为 alive)。该过程一直持续到队列为空。
这里的关键步骤是获取某个分配的地址,并将其转换为其中的所有指针值。Go 具有精确的垃圾收集,这意味着它仅将表面语言中声明为指针的东西视为指针:恰好看起来像地址的整数不会导致 sweep。这导致了更有效的内存使用,但也增加了 GC 的复杂性。
例如,类型 *int
、map[int]byte
、string
、struct {A int; B *int}
都包含至少一个指针,而 int
、[1000]byte
、struct {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 不查看它所遍历的指针的类型?原因有两个。
- 从 runtime 的角度来看,Go 中的所有指针都是非类型化的;每个
*T
都会被擦除为一个unsafe.Pointer
。这使得很多 Go runtime 可以在不使用实际泛型的情况下 “通用”。 - 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 可以安全地包含指向自身的指针!考虑一下:
- 我们有一个指针
p **int
。它被分配在某个 arenaa
上。 - GC 看到我们的指针(作为一个类型擦除的
unsafe.Pointer
),并将其分配标记为 alive。 - 不知何故,GC 也因此将
a
标记为 alive。 - 不知何故,GC 然后将
a
已分配的每个 chunk 标记为 alive。 - 因此,
*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(loglogn)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 是 []uintptr
s,它们不会被扫描。如果 以某种方式 该 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
需要写屏障。
写屏障的实现如下:
- 检查
runtime.writeBarrier
,它确定是否需要写屏障,这仅在 GC 处于标记阶段时才需要。否则,将采用分支跳过写屏障。 - 调用其中一个
runtime.gcWriteBarrierN
函数。N
是 GC 需要通知的指针数。 - 此函数调用
runtime.gcWriteBarrier
,它返回一个缓冲区,GC 现在需要追踪的指针应该写入该缓冲区。 - 发生实际的存储。
在以下情况下需要写屏障。考虑以下代码。
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