Go 泛型中的 GC shape stenciling
摘要
深入解释 Go 编译器如何使用 GC shape stenciling 实现泛型,并与 Rust 的 full monomorphization 和 Java 的 type erasure 进行比较。
暂无内容
查看缓存全文
缓存时间: 2026/07/16 16:52
# Go 泛型中的 GC 形状模板化
来源:https://rednafi.com/go/gc-shape-stenciling/
在阅读 [Go 泛型提案](https://github.com/golang/proposal/blob/master/design/43651-type-parameters.md#implementation) 时,我对编译器如何实现泛型产生了兴趣。编译器通常通过两种方式处理泛型:
- 通过[完全单态化](https://doc.rust-lang.org/book/ch10-01-syntax.html#performance-of-code-using-generics),编译器将泛型代码转换为具体的、类型特定的代码。它会为程序使用的每一组类型参数生成一个独立的版本。Rust 就是这样工作的,C++ 模板也是如此。
- 通过[类型擦除](https://docs.oracle.com/en/java/javase/26/docs/specs/jls/jls-4.html#jls-4.6),编译器保留泛型代码的一个共享版本,并将类型参数替换为公共类型。Java 将它们擦除为 `Object` 或它们声明的边界。
完全单态化让编译器能够精确知道每个生成函数的具体类型。它可以像普通代码一样优化每一个函数,泛型抽象没有运行时开销。缺点是每一组不同的类型参数都可能增加一个函数体,这会增加[编译时间和二进制文件体积](https://github.com/golang/proposal/blob/master/design/generics-implementation-stenciling.md#risks)。
擦除则处于另一个极端。只有一个函数体需要编译,但具体类型在运行时消失了。程序需要转型和装箱操作。
Go 介于两者之间,采用了一种称为 **GC 形状模板化** 的方法([提案文档](https://github.com/golang/proposal/blob/master/design/generics-implementation-gcshape.md))。它进行单态化,但只到类型的 GC 形状为止。具有相同形状的类型共享一个编译后的函数体。
## 完全单态化
https://rednafi.com/go/gc-shape-stenciling/#full-monomorphization
一个小的 Rust 程序展示了完全单态化如何生成所有具体的函数。它调用泛型 `identity` 函数两次,一次使用 `u32`,一次使用 `u64`:
```rust
#[inline(never)]
fn identity<T>(value: T) -> T {
value
}
fn main() {
println!("{} {}", identity(42_u32), identity(42_u64));
}
```
将其保存为 `mono.rs`。`rustc` 的 [`--emit` 选项](https://doc.rust-lang.org/rustc/command-line-arguments.html#--emit-specifies-the-types-of-output-files-to-generate) 将 LLVM 中间表示写入 `mono.ll`。`-C` 标志([文档](https://doc.rust-lang.org/rustc/codegen-options/index.html))选择零级优化、单一代码生成单元和 v0 符号名称修饰。然后使用 `rg` 只保留生成的 `identity` 函数:
```bash
rustc mono.rs --emit=llvm-ir=mono.ll \
-C opt-level=0 \
-C codegen-units=1 \
-C symbol-mangling-version=v0
rg -A5 '; mono::identity' mono.ll | rg -v 'Function Attrs|^--$'
```
在 Rust 1.96.1 上,相关输出为:
```
; mono::identity::<u32>
define internal i32 @_RINvCs15VVTAbh19D_4mono8identitymEB2_(i32 %value) unnamed_addr #0 {
start:
ret i32 %value
}
; mono::identity::<u64>
define internal i64 @_RINvCs15VVTAbh19D_4mono8identityyEB2_(i64 %value) unnamed_addr #0 {
start:
ret i64 %value
}
```
高亮部分的签名显示了发生了什么。一个函数接受并返回 `i32`,另一个使用 `i64`。一个泛型源函数变成了两个具体的函数体。
C++ 模板也是如此。在[模板实例化](https://eel.is/c++draft/temp.inst#5)过程中,编译器会为每一组具体的参数创建一个特化版本。当同一个特化出现在多个目标文件中时,[GCC 的实现](https://gcc.gnu.org/onlinedocs/gcc/Template-Instantiation.html) 会生成所有副本,并让链接器合并重复项。
## 类型擦除
https://rednafi.com/go/gc-shape-stenciling/#type-erasure
Java 只保留一个实现。`Box<String>` 和 `Box<Integer>` 都作为同一个 `Box` 类运行。无界类型参数 `T` 会擦除为 `Object`,因此声明为 `T` 的字段在字节码中存储为 `Object`。当代码从 `Box<String>` 中读取该字段时,`javac` 会插入一个[转换](https://docs.oracle.com/javase/tutorial/java/generics/erasure.html)回 `String`。像 `T extends Number` 这样的有界参数则会擦除为 `Number`。
擦除后的类在运行时**无法获得**具体类型参数([JLS 相关章节](https://docs.oracle.com/en/java/javase/26/docs/specs/jls/jls-4.html#jls-4.7))。
类型擦除还意味着类型参数只接受引用类型。如果将 `int` 传递给期望 `T` 的位置,Java 会将其[装箱](https://docs.oracle.com/javase/tutorial/java/generics/restrictions.html#instantiate)为 `Integer`。装箱意味着[堆分配和间接访问](https://openjdk.org/projects/valhalla/design-notes/state-of-valhalla/01-background#the-costs-of-boxing)。这些转型和装箱是特化版本永远不会做的运行时工作。
## GC 形状模板化
https://rednafi.com/go/gc-shape-stenciling/#gc-shape-stenciling
Go 的泛型提案没有规定具体的实现策略。最终发布的是 GC 形状模板化。下面是 Go 中相同的 `identity` 函数:
```go
package main
import "fmt"
type User struct{}
type Order struct{}
//go:noinline
func identity[T any](value T) T {
return value
}
func main() {
fmt.Println(identity(42))
fmt.Println(identity(3.14))
fmt.Println(identity(&User{}))
fmt.Println(identity(&Order{}))
}
```
这个程序用 `int`、`float64`、`*User` 和 `*Order` 实例化了 `identity`。为了决定哪些调用可以共享编译后的代码,编译器将每个类型参数映射到其 GC 形状。
**GC 形状** 是类型在分配器和垃圾回收器视角下的呈现方式:它的大小、对齐方式以及包含指针的部分。实际规则比这更严格。根据 [Go 1.18 实现说明](https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md#gcshapes),两个类型只有具有相同底层类型时才共享一个 GC 形状,但有一个例外:所有指针类型共享一个以 `*uint8` 命名的形状。因此 `*User` 和 `*Order` 最终属于同一组。
这个例外只适用于指针类型。例如 `map[string]int` 和 `chan int` 在运行时都是一个指针,但它们都不是指针类型,因此各自拥有自己的形状。
编译器将每个形状替换掉 `T`,并为每个形状编译一个函数版本。这种替换就是“模板化”部分。上面的四个调用只需要三个函数体:一个用于 `int`,一个用于 `float64`,一个由两个指针类型共享。
共享第三个函数体会丢失信息。编译后的代码知道它收到的是一个指针,但不知道调用使用的是 `*User` 还是 `*Order`。当函数体需要确切类型时,Go 通过一个隐藏的**字典**参数([文档](https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md#dictionary-format))传递,该参数与普通参数一起传递。尽管名字是“字典”,但它实际上是一个固定的表,编译器为每个具体实例化生成,并存储在二进制文件的只读数据段中。表内包含运行时类型描述符以及函数体可能需要的其他类型特定条目。
`identity` 不执行任何类型相关的操作,因此它的函数体会忽略字典。但编译器仍然会为每个实例化生成一个字典。所以这个程序应该产生三个函数体和四个字典。
`//go:noinline` 指令([文档](https://pkg.go.dev/cmd/compile#hdr-Function_Directives))阻止编译器内联调用,从而让 `identity` 符号保留在二进制文件中。将其保存为 `main.go`,然后构建并过滤符号表。`go tool nm` 列出符号,`rg` 保留 `identity` 函数体和字典,`awk` 丢弃地址:
```bash
go build -o /tmp/gcshape main.go
go tool nm /tmp/gcshape \
| rg 'main\.(identity|\.dict\.identity)\[' \
| awk '{print $2, $3}'
```
在 Go 1.26.5/`darwin/arm64` 上,我得到:
```
R main..dict.identity[*main.Order]
R main..dict.identity[*main.User]
R main..dict.identity[float64]
R main..dict.identity[int]
T main.identity[go.shape.*uint8]
T main.identity[go.shape.float64]
T main.identity[go.shape.int]
```
第一列区分了两种符号,`go tool nm` 的[文档](https://pkg.go.dev/cmd/nm) 解释了它们:
- `R` 表示只读数据。前四行分别是 `*Order`、`*User`、`float64` 和 `int` 的字典。
- `T` 表示文本段(代码段)。最后三行是编译后的函数体。
`int` 和 `float64` 各自拥有独立的函数体。高亮显示的 `go.shape.*uint8` 函数体由两个指针类型共享,这就是为什么四个实例化只产生了三个函数体。
> **注意**:分组依据是底层类型,因此命名类型不会增加新的函数体。添加 `type MyInt int` 并调用 `identity(MyInt(7))`,函数体数量仍然是三个:`MyInt` 重用 `go.shape.int`,只会额外增加一个自己的字典。
这种共享会有运行时开销。共享函数体中的某些操作需要确切类型:对类型参数值的方法调用、转换为接口类型、类型断言和类型开关。编译器会通过[字典传递](https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md#compiler-processing-for-calls-to-generic-functions-and-methods)重写这些操作,使它们在运行时从字典中读取所需信息。完全单态化的函数体则永远不需要这样做。
根据[提案的风险部分](https://github.com/golang/proposal/blob/master/design/generics-implementation-gcshape.md#risks),除了这些字典读取之外,共享函数体生成的汇编代码应该与完全单态化代码基本相同。主要例外是方法调用,它们无法在编译时完全解析。方法调用还可能阻止内联,并使逃逸分析更加保守,这可能导致额外的堆分配。
泛型最初确实降低了编译速度。[Go 1.18 发布说明](https://go.dev/doc/go1.18#compiler) 指出编译速度可能比 Go 1.17 慢大约 15%。[Go 1.20](https://go.dev/doc/go1.20#compiler) 将构建速度提升了多达 10%,并恢复到与 Go 1.17 相当的水平。这两个数字都是针对整个编译器而言,并非仅指模板化,但 1.20 的说明将之前的性能下降主要归因于泛型支持。
相似文章
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
@davidcrawshaw: 虽然行业正在向无GC(Rust)的程序投入大量资源,但我认为Jane Street的OCaml团队已经掌握了…
David Crawshaw认为,尽管行业投资于Rust的无GC特性,但Jane Street的OxCaml(OCaml变体)表明,GC对大多数代码路径是有益的,只有1%的代码需要性能优化。
Gossamer:一种具有真实goroutines和无暂停内存的Rust风格语言
Gossamer是一种受Rust启发的新编程语言,具有真实goroutines、基于引用计数和区域的无暂停确定性内存管理,以及配备LLVM编译的字节码虚拟机。它旨在提供富有表现力的语法,无需借用检查器或垃圾回收暂停。
无 Unsafe 代码的垃圾回收
safe-gc 是一个全新的 Rust 库,它完全不用 unsafe 代码就实现了垃圾回收器,通过“堆索引”而非直接解引用指针来保证内存安全。
安全 Rust 的边界
TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。