Go Empty Interface Explained & The Hidden Heap Allocation

Lobsters Hottest 新闻

摘要

深入解析 Go 空接口 any 的底层实现:eface 与 iface 的两指针结构、类型字与数据字的含义,以及编译器如何通过常量、zeroVal、staticuint64s 等手段避免不必要的堆分配。

<p><a href="https://lobste.rs/s/1hbwvl/go_empty_interface_explained_hidden_heap">Comments</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/29 09:42

**TL;DR:Go 中的 `any` 本质是两个指针——类型字指向类型描述符,数据字指向值的拷贝;拷贝落在哪里(二进制段、栈、堆)取决于值的类型、编译器是否知道其位置以及它需要存活多久,其中还藏着不少可以省掉堆分配的优化。** ## 空接口可能是 Go 中最棘手的类型 `any`(空接口的别名)可能是 Go 中最有趣的类型,同时也是最棘手的类型之一。我们经常使用 `any`,但当我们把一个值放入其中时,究竟发生了什么?值会被拷贝吗?会产生堆分配吗?为什么 `any` 持有一个 nil 指针却不等于 `nil`? 这一切都归结于 Go 如何存储 `any`。查看 Go 规范:所有接口,无论是空接口还是非空接口,在本质上都是一样的东西——接口只是一组方法列表。`any` 是一个方法列表为空的接口,而 `io.Reader` 是一个方法列表中包含一个方法的接口。这就是它们唯一的区别。 但运行时并不是这么看待它们的。运行时用一个问题把接口分成两类:**这个接口有方法吗?** 在规范中列出类型约束的接口,例如 `~int` 或 `~float64`,仍然是接口,但 Go 只允许我们将它们用作泛型的约束。我们无法声明这种类型的变量,因此运行时永远不会存储它们的值,它们根本不会出现在这里。 ## eface 与 iface:16 字节里的两个指针 在 64 位机器上,`any` 和 `io.Reader` 的大小都是 16 字节。所以从开发者的角度看,这两种接口没有任何区别,都只是接口而已。但在内部,运行时以不同的方式存储它们: - 空接口是一个叫 `eface` 的结构体(empty interface 的缩写)。 - 带有方法的接口(如 `io.Reader`)是一个叫 `iface` 的结构体。 它们都存储两个指针,只有第二个指针 `data` 在两者中是相同的。 ### 类型字:类型描述符的地址 当我们把一个值赋给空接口时,运行时需要知道这个值的类型和值本身。第一个指针用于类型,它指向类型描述符。 当 Go 构建二进制文件时,程序运行时需要的每一种类型都会获得一个类型描述符——包括自定义类型如 `Rect`,以及内置类型如 `int`、`bool` 和 `string`。链接器把它们全部放在一个专门存储类型的段中:在 Linux 上是 `go.type`,在 macOS 上是双下划线加 `go.type`。 我们可以用 `objdump -h` 查看自己二进制文件中的段列表。名称不同,但布局是一样的:`.text` 段保存代码,`go.type` 段保存所有类型描述符。程序运行时,操作系统会把这些段加载到内存中,所以类型字保存的就是 `Rect` 描述符在 `go.type` 段加载区域中的地址。 ### 数据字:值的拷贝在哪 第二个指针 `data` 是空接口用来访问其所持值的方式。 先看一个简单场景:把一个 `Rect` 存入空接口变量 `a`,然后把初始 `rect` 的 `width` 改为 9。运行后,空接口指向的 `Rect` 的 `width` 仍然是 2,而不是 9。所以 `a` 的数据字并没有指向变量 `r`——在内部,Go 拷贝了这个值,数据字指向的是那个拷贝。 当我们复制一个接口,比如 `a2 := a`,变量 `a` 只是两个指针,所以它会把这两个指针复制到 `a2` 中,仅此而已。`Rect` 不会被再次拷贝,两个数据字都指向同一个拷贝。这种共享是安全的,因为拷贝是不可变的——我们无法通过一个接口修改 `Rect` 并影响其他接口(除非用 `unsafe`,但那可能会导致程序崩溃)。 将值取出来是相反的过程:对 `Rect` 的类型断言会从数据字指向的位置读取字段,并把它们拷贝到一个新的 `Rect` 中。所以我们得到的又是一份拷贝。 ## 用 println 看见这两个字 我们可以用 Go 内置的一个非常简单的工具 `println` 看到这两个指针。`println` 对接口不会打印两个值,而是打印这两个字本身:第一个是类型字,即 `Rect` 类型描述符的地址;第二个是数据字,即 `Rect` 存储的地址。 ## 为什么持有 nil 指针的 any 不等于 nil 声明一个 `int` 指针并设为 `nil`,把它存入空接口 `a`,然后检查 `a == nil`。直觉上 `int` 指针等于 `nil`,`a` 等于这个 `int` 指针,所以 `a` 也等于 `nil`。但程序走了另一个分支,打印出 `a is not nil`。 用 `fmt.Println` 打印 `a` 和另一个刚声明未赋值的空接口 `b`,两者打印的完全一样,都是 `<nil>`。这让人困惑,因为 `fmt.Println` 实际打印的不是接口本身,而是它内部存储的值。 改用 `println`: - 对于 `b`,两个字都是 0。 - 对于 `a`,数据字是 0,但类型字是一个地址——`*int` 的描述符。 所以接口只有当类型字和数据字都为 nil 时才等于 nil。`a` 持有的是一个 nil 指针,但它仍然有一个类型,所以它不是 nil。 在幕后,这个检查完全依赖于类型字:当类型字是 nil 时,数据字也一定是 nil;而如果类型字存在,无论数据字保存什么,`any` 都不等于 nil。 ## Go 如何决定数据字指向哪里 数据字并不总是指向一个新拷贝。Go 怎么做取决于三件事:值的类型、编译器是否已经知道值所在的位置、以及值需要存活多久。可以归纳为四种场景。 ### 场景一:值本身就是一个指针 变量 `p` 是一个指向 `Rect` 的指针,把它存入空接口 `b`。因为 `p` 本身已经是一个指针,和数据字一样,Go 不会拷贝 `p` 再指向那个拷贝,而是直接把 `p` 的值放入数据字——这意味着它直接指向 `Rect`。 所以如果我们存储一个指针然后修改它指向的值,接口看到的就是修改后的值。那为什么这没有违反"接口中的值不可变"?因为不可变的含义是:当我们把它断言取出来时,得到的是我们存入的那个值。这里存入的值是那个指针,而不是 `Rect`。即使我们把 `p` 设为 nil,断言 `b` 仍然给我们指向 `Rect` 的那个指针。 存储指针的方式对 map、channel 或函数值来说是一样的——在底层它们都只是一个指针,所以它们直接存入数据字。一个存入 `any` 的 map,其数据字就是 map 的指针。 **只包含一个指针的结构体也是同样的方式存储。** 一个 `int` 指针和一个包含一个 `int` 指针的包装结构体在内存里看起来完全一样:包装结构体的地址和它的字段 `p` 的地址相同,大小也一样(64 位机器上 8 字节)。所以数据字直接使用指针值,直接指向 `int`;只有类型字不同,指向 `Wrapper` 的描述符。 编译器并不关心这个类型是结构体还是数组——单元素指针数组、只包含一个 map 的结构体都一样。但再多加一个字段,结构体就不再只是一个指针了,于是 Go 就像对待其他结构体一样拷贝它。 ### 场景二:编译器已经知道值的位置 **常量。** 把 `100` 存入空接口。这个数字直接写在代码中,编译器在编译时就已经知道,于是它把 `100` 的一个只读拷贝放入二进制文件中,名字类似 `STN.0`。程序运行时,这个拷贝和其他二进制内容一样被映射到内存中,数据字保存的就是它的地址。这条赋值编译后只是一条加载地址的指令——不需要运行任何代码来生成拷贝,也不需要任何分配。 对于每个字段都是常量的结构体也是一样,比如 `Rect{2, 3}`。如果每个字段都是零值(比如空的 `Rect`),编译器甚至不会写入一个拷贝:`runtime` 包中已经有一个充满零值的地方——`zeroVal`,一个 1024 字节的全局数组,全部为零。所以零值结构体或最大 1024 字节的数组就直接指向 `zeroVal`。 **零大小的值。** 有些类型占零字节:空结构体、长度为零的数组、空结构体的数组,或字段全部是上述类型的结构体。这些值天生不可变,因为它们没有任何可供修改的数据。Go 每次都使用相同的地址——`zeroBase`,`runtime` 包中的一个全局变量,仅 8 字节。而且不仅仅是这些值,每一个逃逸的零字节分配也获取 `zeroBase` 的地址,所以它们都指向同一块 8 字节。 **`bool` 或单字节整数类型。** `int8`、`uint8`、`byte` 只能保存 256 种不同的值(0 到 255)。`runtime` 包中有一个全局数组保存了所有这些值——`staticuint64s`,包含 256 个数字,第 n 个元素存放的就是数字 n。由于每个值都有对应元素,编译器仅凭类型就知道可以指向那里。查看汇编代码,每一行实际上只有两条指令:一条加载类型,另一条加载对应元素的地址,比如 `staticuint64s + 8` 表示 `true`。 这之所以可行,是因为接口中的拷贝是不可变的——如果我们尝试通过数据字用 `unsafe` 写入,程序会以 fatal error 崩溃。 用调试标志 `-gcflags=-m` 可以询问编译器选择了哪个位置。它会为存入接口的每个值打印一行信息,每一行都说 `using global`——每个值都指向二进制文件中已经存在的东西。 ### 场景三:Go 事先不知道值 比如 `show` 函数的 `int` 参数 `n`。这个值可能是 1、100 或一百万,编译器无法预测。所以当我们把这个变量赋给空接口时,值必须被拷贝,而且在这个场景中,拷贝存活在函数的栈帧中。 逃逸分析报告确认了这一点:`using stack temporary for interface value n`——`n` 的拷贝放在 `show` 函数的栈上一个临时变量中。 汇编代码里,Go 使用寄存器 `R0` 存放类型字,`R1` 存放数据字;然后编译器添加一个隐藏的局部变量,命名为 `autotemp.2`。`println` 调用变成了对 `runtime.print` 的调用,接收的正好是 `a` 的两个字:类型 `int` 和 `autotemp.2` 的地址。 在以上三种场景中,都是**编译器**决定数据字是直接指向内存、还是指向二进制文件中的拷贝、还是指向函数栈帧中的拷贝。 ### 场景四:由运行时决定,因为拷贝逃逸了 把 `show` 函数改成把 `n` 存入一个全局变量。这是让值逃逸到堆上的简短方式。 逃逸分析报告说 `n escapes to heap`,但这有点奇怪:通常当变量逃逸时,Go 报告的是 `move to heap`。区别在于,Go 在变量本身需要存活在堆上时才报告 `move to heap`,那条消息指向变量声明的那一行,而不是使其逃逸的那一行。 这是因为编译器将 `global = n` 读取为 `global = any(n)`。逃逸的不是 `n`,而是这次转换操作——转换需要一个地方来保存 `n` 的拷贝,而那个地方才是真正逃逸到堆上的东西。 在这个场景下,编译器不会在栈上预留槽位,它需要 Go 运行时的帮助。赋值变成了对 `runtime` 包中一个函数的调用:`runtime.convT64`,名字表示它转换一个 64 位值。针对不同类型的值有各自的转换函数(`int`、`string`、`slice`、无指针类型等)。 运行时在这里可以按自己的方式优化。例如,如果我们赋值一个小于 256 的数字,它可以使用 `staticuint64s` 表来避免分配;否则 `convT64` 会在堆上分配 8 字节并把值拷贝进去。所以 `show(7)` 不花费任何分配,而 `show(1000)` 花费一次。空字符串或 nil 切片指向 `zeroVal`,其他任何字符串或切片都会在堆上获得拷贝。 **结论:逃逸消息并不总是意味着堆分配,只有堆上的拷贝才会花费一次分配。** 把一个空 `Rect` 存入全局变量时,逃逸分析说它逃逸到堆,但下一行说数据字指向 `zeroVal`——没有任何分配。 ## 大值存指针:跳过拷贝 一个逃逸的结构体值每次存储时都会在堆上获得一个新的拷贝,无论它的大小。128 字节的结构体 `big` 存入全局 `any`,每次存储都是一次分配;但如果我们存入它的指针,就是零次分配。 对于经常存储的大值,存储指针可以跳过拷贝,而且每个持有该指针的接口都共享同一个值。这在 `sync.Pool` 中经常发生——我们不断把同一个对象传入接口,如果使用指针就不会有任何拷贝。 ## Go 如何比较两个 any 值 比较分两步:先比较两个类型字,如果相等,再比较数据字指向的值。 `== b` 在背后被编译器转换为两个检查:类型字是否相等,以及 `runtime.efaceequal` 检查值是否相等。所以如果我们比较 `int(7) == 7` 和 `uint64(7)`,两个类型字不相等,它立即返回 false,尽管值看起来一样。 `runtime.efaceequal` 就是对数据字指向的值进行相等性检查,但有一个优化:当我们把指针赋给空接口时,数据字保存的就是指针本身,所以它直接比较指针。 除了检查数据字和数据字指向的值之外,当类型不可比较时它还会 panic。例如两个切片的比较——切片在 Go 中被视为不可比较类型,map 和 func 也是一样,它们在类型描述符中的 `equal` 字段是 nil。 Source: [Go Empty Interface Explained & The Hidden Heap Allocation — func25](https://www.youtube.com/watch?v=_BxdvkLLnDc)

相似文章

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

Lobsters Hottest

Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。

Go 泛型中的 GC shape stenciling

Hacker News Top

深入解释 Go 编译器如何使用 GC shape stenciling 实现泛型,并与 Rust 的 full monomorphization 和 Java 的 type erasure 进行比较。