bigywater.tech
Go · 13

接口的内存布局:iface 与 eface 里到底存了什么

接口值看起来是一个对象,实际上只是两个字。这篇不靠源码推测,直接用 unsafe 把接口值拆开读:看类型字和数据字各装了什么、itab 里有什么、同一个类型装进不同接口时 itab 是不是同一个,再用汇编和基准测试量出调用的真实成本。

16 字节
任意接口值(any、io.Reader、自定义接口)的大小,两个机器字
同一 itab
两个 *T 装进 Summer 共用一个 itab;同一个 *bytes.Buffer 装进 io.Reader 和 io.ReadWriter 是两个 itab
2 ns → 7 ns
通过接口调用:4096 个同类型 1.99 ns/op,4 种类型伪随机 7.23 ns/op

接口值有多大

实测 · 本机执行输出
  unsafe.Sizeof:any = 16,io.Reader = 16,Summer = 16,*T = 8,T = 16,string = 16,[]int = 24
  零值 any(nil):两个字都是 nil?typ=true data=true

空接口和带方法的接口都是两个字,只是第一个字不同:any(eface)放类型指针,带方法的接口(iface)放 itab 指针。运行时源码里两者的定义在 internal/abi/iface.go:EmptyInterface{Type, Data} 与 ITab{Inter, Type, Hash, Fun},下面的实验用同样的布局把接口值读出来。

any 的两个字:类型 + 数据

实测 · 本机执行输出
  any(42) 与 any(1000):类型指针相同?true;any(1000) 与 any("hi"):false
  any(42):*(*int)(data) = 42;any(1000):*(*int)(data) = 1000;两个 any(1000) 的 data 指针相同?false(两个值各有自己的一份数据)
  any("hi"):data 指向字符串头(ptr,len):len = 2,内容 "hi"
  any(&T):data 指针 == 原指针 p?true(指针类型直接把指针放进 data 字,不再多一层)
  var av any = v(T 值);之后 v.A = 100:v.A = 100,接口里的拷贝 {A:1 B:2};data 指针 == &v?false;类型字等于 *T 的类型字?false

数据字有两种用法:指针类型(*T)直接把指针放进数据字,不再多一层;非指针类型(T、int、string)的数据字指向一份值的拷贝,所以装进接口之后再改原变量,接口里的值不变。字符串的数据字指向的是字符串头(指针 + 长度),不是字符内容本身。

带方法的接口:itab

实测 · 本机执行输出
  两个 Summer 都装着 *T:itab 指针相同?true;itab.typ 等于 any(&T) 的类型字?true
  itab.fun[0] 指向的函数名:main.(*T).Sum
  reflect 取到的 (*T).Sum 地址 == itab.fun[0]?true
  同一个动态类型 *bytes.Buffer,装进 io.Reader 与 io.ReadWriter:itab 指针相同?false;itab.typ 相同?true;itab.inter 相同?false
  RWC 的 itab.fun[0] → main.(*F).Close
  RWC 的 itab.fun[1] → main.(*F).Read
  RWC 的 itab.fun[2] → main.(*F).Write

itab 是“(接口类型, 动态类型)”这一对的方法表:相同的一对共用同一个 itab,所以两个 *T 装进 Summer 得到同一个指针;同一个 *bytes.Buffer 装进 io.Reader 与 io.ReadWriter 是两对,得到两个 itab。方法地址按方法名排序,而不是按接口里声明的顺序。注意最后一行:F 的方法是值接收者,itab 里放的却是 main.(*F).Close——数据字永远是个指针,所以 itab 里放的是接收指针的包装函数(名字来自实测输出,包装的具体实现我没有去读)。

itab 是什么时候造出来的

实测 · 本机执行输出
  main.static 的汇编里与接口有关的指令(go build -gcflags=-S):
    (main.go:15)	MOVD	$go:itab.*main.T,main.Summer(SB), R0
  main.dynamic 的汇编里与接口有关的指令(go build -gcflags=-S):
    (main.go:18)	CALL	runtime.typeAssert(SB)
    (main.go:18)	CALL	runtime.panicnildottype(SB)
  3 7
    dynamic(x) 稳态每次调用的分配次数:0

编译器能确定的转换(return t 里把 *T 变成 Summer)只有一条汇编:取 go:itab.*main.T,main.Summer 这个符号的地址,没有任何运行时查找。运行时才知道类型的断言(x.(Summer))要调用 runtime.typeAssert;稳态下它不分配内存。运行时源码 runtime/iface.go 里的 getitab 与 itabTable 就是这类 itab 的缓存,不过本文没有去量缓存命中与未命中的差别。

通过接口调用的成本

实测 · 本机执行输出
  直接调用 t.Sum()           中位 1.98 ns/op(最小 1.98 / 最大 2.00)
  经接口 s.Sum()            中位 1.97 ns/op(最小 1.97 / 最大 1.98)
  4096 个同类型,逐个调用         中位 1.99 ns/op(最小 1.99 / 最大 2.02)
  4096 个 4 类型随机,逐个调用     中位 7.23 ns/op(最小 7.21 / 最大 7.25)

在这个最小的例子里,经接口调用与直接调用没有可测的差别(1.97 与 1.98 ns/op),两者都被 //go:noinline 的调用本身占满了。真正拉开差距的是类型不固定:4096 个接口里随机放 4 种类型,每次间接跳转的目标都在变,耗时变成 7.23 ns/op。把它当成“接口调用本身便宜,类型多变会让它变贵”的一个数据点,不要套用到别的程序上。

分步演示

接口的内存布局
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。读过的运行时源码:internal/abi/iface.go(ITab 与 EmptyInterface 的布局)、runtime/iface.go(getitab 与 itabTable 的存在);用 unsafe 按同样布局读接口值,itab 地址只比较相等与否,不打印具体值;汇编用 go build -gcflags=-S 取得;基准测试每个变体 7 轮取中位数
    • 没有验证:32 位平台与其它架构上的接口调用成本、泛型(GC shape 字典)里的接口表示、reflect 构造的接口、type switch 的缓存实现与 itab 表扩容、PGO 去虚拟化(见后面的 PGO 一篇)、7.23 ns 变慢的原因是否确实是分支预测(没有用硬件计数器看)
    京ICP备2026067502号-1京公网安备11010502063487号