前几节我们介绍了接口的基本概念和用法,定义接口只需简单声明一个方法集合即可,定义新类型时不需要显式地声明要实现的接口,接口的使用也很简单。

那么接口的底层是如何实现的呢?如何实现动态调用的呢?接口的动态调用到底有多大的额外开销?本节我们就来深入讲解一下接口的底层实现。

阅读本节需要读者了解Go语言接口的基础知识和Go语言汇编基础和函数调用规约,以及对 ELF 可执行文件格式有基本了解。本节内容有点偏底层,有一定的难度,如果阅读起来有困难,可以先跳过去,有时间再慢慢读。

数据结构

从前面章节了解到,接口变量必须初始化才有意义,没有初始化的接口变量的默认值是 nil,没有任何意义。具体类型实例传递给接口称为接口的实例化。在接口的实例化的过程中,编译器通过特定的数据结构描述这个过程。

首先介绍非空接口的内部数据结构,空接口的底层更简单,放到最后介绍。非空接口的底层数据结构是 iface,代码位于Go语言安装目录的 src/runtime/runtime2.go 文件中。

iface 数据结构

非空接口初始化的过程就是初始化一个 iface 类型的结构,示例如下:



1. //src/runtime/runtime2.go
2. type iface struct {

3. tab *itab //itab 存放类型及方法指针信息
4. data unsafe.Pointer //数据信息
5. }

可以看到 iface 结构很简单,有两个指针类型字段。

  • itab:用来存放接口自身类型和绑定的实例类型及实例相关的函数指针,具体内容后面有详细介绍。
  • 数据指针 data:指向接口绑定的实例的副本,接口的初始化也是一种值拷贝。

data 指向具体的实例数据,如果传递给接口的是值类型,则 data 指向的是实例的副本;如果传递给接口的是指针类型,则 data 指向指针的副本。总而言之,无论接口的转换,还是函数调用,Go 遵循一样的规则——值传递。

接下来看一下 itab 数据结构,itab 是接口内部实现的核心和基础。示例如下:


1. //src/runtime/runtime2.go
2. type itab struct {

3. inter *interfacetype //接口自身的静态类型
4. _type *_type //_type 就是接口存放的具体实例的类型(动态类型)
5. //hash 存放具体类型的 Hash 值
6. hash uint32 // copy of _type.hash. Used for type switches.
7. _ [4]byte
8. fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
9. }

itab 有 5 个字段:

  • inner:是指向接口类型元信息的指针。
  • _type:是指向接口存放的具体类型元信息的指针,iface 里的 data 指针指向的是该类型的值。一个是类型信息,另一个是类型的值。
  • hash:是具体类型的 Hash 值,_type 里面也有 hash,这里冗余存放主要是为了接口断言或类型查询时快速访问。
  • fun:是一个函数指针,可以理解为 C++ 对象模型里面的虚拟函数指针,这里虽然只有一个元素,实际上指针数组的大小是可变的,编译器负责填充,运行时使用底层指针进行访问,不会受 struct 类型越界检查的约束,这些指针指向的是具体类型的方法。

itab 这个数据结构是非空接口实现动态调用的基础,itab 的信息被编译器和链接器保存了下来,存放在可执行文件的只读存储段(.rodata)中。itab 存放在静态分配的存储空间中,不受 GC 的限制,其内存不会被回收。

接下来介绍 _type 数据结构,Go语言是一种强类型的语言,编译器在编译时会做严格的类型校验。所以 Go 必然为每种类型维护一个类型的元信息,这个元信息在运行和反射时都会用到,Go语言的类型元信息的通用结构是 _type(代码位于 src/runtime/type.go), 其他类型都是以 _type 为内嵌宇段封装而成的结构体。


1. //src/runtime/type.go
2. type type struct {

3. size uintptr // 大小
4. ptrdata uintptr //size of memory prefix holding all pointers
5. hash uint32 //类型Hash
6. tflag tflag //类型的特征标记
7. align uint8 //_type 作为整体交量存放时的对齐字节数
8. fieldalign uint8 //当前结构字段的对齐字节数
9. kind uint8 //基础类型枚举值和反射中的 Kind 一致,kind 决定了如何解析该类型
10. alg *typeAlg //指向一个函数指针表,该表有两个函数,一个是计算类型 Hash 函
11. //数,另一个是比较两个类型是否相同的 equal 函数
12. //gcdata stores the GC type data for the garbage collector.
13. //If the KindGCProg bit is set in kind, gcdata is a GC program.
14. //Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
15. gcdata *byte //GC 相关信息
16. str nameOff //str 用来表示类型名称字符串在编译后二进制文件中某个 section
17. //的偏移量
18. //由链接器负责填充
19. ptrToThis typeOff //ptrToThis 用来表示类型元信息的指针在编译后二进制文件中某个
20. //section 的偏移量
21. //由链接器负责填充
22. }

_type 包含所有类型的共同元信息,编译器和运行时可以根据该元信息解析具体类型、类型名存放位置、类型的 Hash 值等基本信息。

这里需要说明一下:_type 里面的 nameOff 和 typeOff 最终是由链接器负责确定和填充的,它们都是一个偏移量(offset),类型的名称和类型元信息实际上存放在连接后可执行文件的某个段(section)里,这两个值是相对于段内的偏移量,运行时提供两个转换查找函数。例如:

//src/runtime/type.go
 //获取 _type 的 name
 func resolveNameOff(ptrInModule unsafe.Pointer , off nameOff) name {}
 //获取 _type 的副本
 func resolveTypeOff(ptrInModule unsafe.Pointer , off typeOff) *_type {}

注意:Go语言类型元信息最初由编译器负责构建,并以表的形式存放在编译后的对象文件中,再由链接器在链接时进行段合并、符号重定向(填充某些值)。这些类型信息在接口的动态调用和反射中被运行时引用。

接下来看一下接口的类型元信息的数据结构。示例如下:


1. //描述接口的类型
2. type interfacetype struct {

3. typ _type //类型通用部分
4. pkgpath name //接口所属包的名字信息, name 内存放的不仅有名称,还有描述信息
5. mhdr []imethod //接口的方法
6. }
7. //接口方法元信息
8. type imethod struct {

9. name nameOff //方法名在编译后的 section 里面的偏移量
10. ityp typeOff //方法类型在编译后的 section 里面的偏移量
11. }

接口调用过程分析

前面讨论了接口内部的基本数据结构,下面就来通过跟踪接口实例化和动态调用过程,使用 Go 源码和反汇编代码相结合的方式进行研究。下面是一段非常简单的接口调用代码。



1. //iface.go
2. package main
3. 
4. type Caler interface {

5. Add (a , b int) int
6. Sub (a , b int) int
7. }
8. 
9. type Adder struct {id int }
10. 
11. //go:noinline
12. func (adder Adder) Add(a, b int) int { return a + b }
13. 
14. //go:noinline
15. func (adder Adder) Sub(a , b int) int { return a - b }
16. 
17. func main () {

18. var m Caler=Adder{id: 1234}
19. m.Add(10, 32)
20. }

生成汇编代码:

go build -gcflags= "-S - N -l" iface.go >iface.s 2>&1

接下来分析 main 函数的汇编代码,非关键逻辑已经去掉:



1. "".main STEXT size=151 args=0x0 locals=0x40
2. ...
3. 0x000f 00015 (src/iface.go:16) SUBQ $64, SP
4. 0x0013 00019 (src/iface.go:16) MOVQ BP, 56(SP)
5. 0x0018 00024 (src/iface.go:16) LEAQ 56(SP), BP

为 main 函数堆战开辟空间并保存原来的 BP 指针,这是函数调用前编译器的固定动作。

var m Caler = Adder {id: 1234} 语句汇编代码分析:

0x00ld 00029 (src/iface.go:17) MOVQ    $0, ""..autotmp_1+32(SP)
0x0026 00038 (src/iface.go:17) MOVQ    $1234, ""..autotmp_1+32(SP)

在堆上初始化局部对象 Adder,先初始化为 0,后初始化为 1234。

0x002f 00047 (src/iface.go:17) LEAQ    go.itab."".Adder,"".Caler(SB),AX
0x0036 00054 (src/iface.go:17) MOVQ    AX, (SP)

这两条语句非常关键,首先 LEAQ 指令是一个获取地址的指令,go.itab."".Adder,"".Caler(SB) 是一个全局符号引用,通过该符号能够获取接口初始化时 itab 数据结构的地址。

注意:这个标号在链接器链接的过程中会替换为具体的地址。我们知道 (SP) 里面存放的是指向 itab(Caler,Adder) 的元信息的地址,这里 (SP) 是函数调用第一个参数的位置。示例如下:

0x003a 00058 (src/iface.go:17) LEAQ ""..autotmp_1+32(SP), AX
0x003f 00063 (src/iface.go:17) MOVQ AX, 8(SP)
0x0044 00068 (src/iface.go:17) PCDATA $0, $0

复制刚才的 Adder 类型对象的地址到 8(SP),8(SP) 是函数调用的第二个参数位置。示例如下:

0x0044 00068 (src/iface.go:17) CALL    runtime.convT2I64(SB)

runtime.convT2I64 函数是运行时接口动态调用的核心函数。runtime 中有一类这样的函数,看一下 runtime.convT2I64 的源码:



1. func convT2I64(tab *itab, elem unsafe.Pointer) (i iface) {

2. t := tab._type
3. if raceenabled {

4. raceReadObjectPC(t, elem, getcallerpc(unsafe.Pointer(&tab)), funcPC(convT2I64))
5. }
6. if msanenabled {

7. msanread (elem, t.size)
8. }
9. var x unsafe.Pointer
10. if *(uint64) (elem) == 0 {

11. x = unsafe.Pointer(&zeroVal[0])
12. } else {

13. x = mallocgc(8, t, false)
14. *(*uint64) (x) = *(*uint64) (elem)
15. }
16. i.tab = tab
17. i.data = x
18. return
  1. }

从上述源码可以清楚地看出,runtime.convT2I64 的两个参数分别是 *itab 和 unsafe.Pointer 类型,这两个参数正是上文传递进去的两个参数值:go.itab."".Adder, "".Caler(SB) 和指向 Adder 对象复制的指针。

runtime.convT2I64 的返回值是一个 iface 数据结构,其意义就是根据 itab 元信息和对象值复制的指针构建和初始化 iface 数据结构,iface 数据结构是实现接口动态调用的关键。至此己经完成了接口初始化的工作,即完成了 iface 数据结构的构建过程。下一步就是接口方法调用了。示例如下:

0x0049 00073 (src/iface.go:17) MOVQ 24(SP), AX
 0x004e 00078 (src/iface.go:17) MOVQ 16(SP), CX
 0x0053 00083 (src/iface.go:17 ) MOVQ CX, "".m+40(SP)
 0x0058 00088 (src/iface.go:17 ) MOVQ AX, "".m+48(SP)

16(SP) 和 24(SP) 存放的是函数 runtime.convT2I64 的返回值,分别是指向 itab 和 data 的指针,将指向 itab 的指针复制到 40(SP),将指向对象 data 的指针复制到 48(SP) 位置。

m.Add(10, 32) 对应的汇编代码如下:

0x00Sd 00093 (src/iface.go:18) MOVQ "".m+40(SP), AX
 0x0062 00098 (src/iface.go:18) MOVQ 32(AX), AX
 0x0066 00102 (src/iface.go:18) MOVQ "".m+48(SP), ex
 0x006b 00107 (src/iface.go:18) MOVQ $10, 8(SP)
 0x0074 00116 (src/iface.go:18) MOVQ $32, 16(SP)
 0x007d 00125 (src/iface.go:18) MOVQ CX, (SP)
 0x0081 00129 (src/iface.go:18) PCDATA $0, $0
 0x0081 00129 (src/iface.go:18) CALL AX

第 1 条指令是将 itab 的指针(位于 40(SP))复制到 AX 寄存器。第 2 条指令是 AX 将 itab 的偏移 32 字节的值复制到 AX。再来看一下 itab 的数据结构:



1. type itab struct {

2. inter *interfacetype
3. _type *type
4. link *itab
5. hash uint32 //copy of _type.hash.Used for type switches.
6. bad bool //type does not implement interface
7. inhash bool //has this itab been added to hash?
8. unused [2]byte
9. fun [1] uintptr //variable sized
10. }

32(AX) 正好是函数指针的位置, 即存放 Adder *Add() 方法指针的地址(注意:编译器将接收者为值类型的 Add 方法转换为指针的 Add 方法,编译器的这种行为是为了方便调用和优化)。

第 3 条指令和第 6 条指令是将对象指针作为接下来函数调用的第 1 个参数。

第 4 条和第 5 条指令是准备函数的第 2、第 3 个参数。

第 8 条指令是调用 Adder 类型的 Add 方法。

此函数调用时,对象的值的副本作为第 1 个参数,调用格式可以表述为func(reciver, param1, param2)

至此,整个接口的动态调用完成。从中可以清楚地看到,接口的动态调用分为两个阶段:

  • 第一阶段就是构建 iface 动态数据结构,这一阶段是在接口实例化的时候完成的,映射到 Go 语句就是var m Caler = Adder{id: 1234}
  • 第二阶段就是通过函数指针间接调用接口绑定的实例方法的过程,映射到 Go 语句就是 m.Add(10, 32)。

接下来看一下 go.itab. "".Adder, "".Caler(SB) 这个符号在哪里?我们使用 readelf 工具来静态地分析编译后的 ELF 格式的可执行程序。例如:



1. #编译
2. #go build -gcflag s= "-N -l" iface.go
3. #readelf -s -W iface legrep 'itab'
4. 60:000000000047b220 0 OBJECT LOCAL DEFAULT 5 runtime.itablink
5. 61:000000000047b230 0 OBJECT LOCAL DEFAULT 5 runtime.eitablink
6. 88:00000000004aa100 48 OBJECT GLOBAL DEFAULT 8 go.itab.main.Adder, main.Caler
7. 214:00000000004aa080 40 OBJECT GLOBAL DEFAULT 8 go.itab.runtime.errorString, error
8. 418:00000000004095e0 1129 FUNC GLOBAL DEFAULT 1 runtime.getitab
9. 419:0000000000409a50 1665 FUNC GLOBAL DEFAULT 1 runtime.additab
10. 420:000000000040a0e0 257 FUNC GLOBAL DEFAULT 1 runtime.itabsinit

可以看到符号表里面 go.itab.main.Adder, main.Caler 对应本程序里面 itab 的元信息,它被存放在第 8 个段中。我们来看一下第 8 个段是什么段?

#readelf -S -W iface |egrep '\[8\] | I Nr'
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[8]. noptrdata PROGBITS 00000000004aa000 OaaOOO 000a78 00 WA 0 0 32

可以看到这个接口动态转换的数据元信息存放在 .noptrdata 段中,它是由链接器负责初始化的。可以进一步使用 dd 工具读取井分析其内容,本书就不再继续深入这个细节,留给感兴趣的读者继续分析。

接口调用代价

前面讨论了接口动态调用过程,这个过程有两部分多余时耗,一个是接口实例化的过程,也就是 iface 结构建立的过程,一旦实例化后,这个接口和具体类型的 itab 数据结构是可以复用的;另一个是接口的方法调用,它是一个函数指针的间接调用。

同时我们应考虑到接口调用是一种动态的计算后的跳转调用,这对现代的计算机 CPU 的执行很不友好,会导致 CPU 缓存失效和分支预测失败,这也有一部分的性能损失。当然最直接的办法就是对比测试,看看接口动态调用的性能损失到底有多大。

测试用例

直接选用 GitHub 上的一个测试用例,稍作改写,代码如下。



1. package main
2. import (
3. "testing"
4. )
5. type identifier interface {

6. idInline() int32
7. idNoInline() int32
8. }
9. type id32 struct{ id int32 }
10. func (id *id32) idinline() int32 { return id.id }
11. 
12. //go:noinline
13. func (id *id32) idNoinline() int32 { return id.id }
14. 
15. var escapeMePlease *id32
16. 
17. //主要作用是强制变量内存在 heap 上分配
18. //go:noinline
19. func escapeToHeap(id *id32) identifier {

20. escapeMePlease = id
21. return escapeMePlease
22. }
23. 
24. //直接调用
25. func BenchmarkMethodCall_direct(b *testing.B) { //
26. var myID int32
27. 
28. b.Run("single/noinline", func(b *testing.B) {

29. m := escapeToHeap(&id32{id: 6754}).(*id32)
30. b.ResetTimer ()
31. for i := 0; i < b.N; i++ {

32. //CALL "".(*id32).idNoinline(SB)
33. //MOVL 8(SP), AX
34. //MOVQ "".&myID+40(SP), CX
35. //MOVL AX, (CX)
36. myID = m.idNoInline()
37. }
38. }
39. b.Run ("single/inline", func(b *testing.B) {

40. m := escapeToHeap(&id32{id: 6754}).(*id32)
41. b.ResetTimer()
42. for i: = 0; i < b.N; i++ {

43. //MOVL (DX), SI
44. //MOVL SI, (CX)
45. myID = m.idinline()
46. }
47. })
48. }
49. //接口调用
50. func BenchmarkMethodCall_interface(b *testing.B) { //
51. var myID int32
52. b.Run("single/noinline", func(b *testing.B) {

53. m := escapeToHeap(&id32{id: 6754})
54. b.ResetTimer()
55. for i := 0; i < b.N ; i++ {

56. // MOVQ 32(AX), CX
57. // MOVQ "".m.data+40(SP), DX
58. // MOVQ DX, (SP)
59. // CALL CX
60. // MOVL 8(SP), AX
61. // MOVQ "".&myID+48(SP), CX
62. // MOVL AX, (CX)
63. myID = m.idNoInline()
64. }
65. })
66. b.Run("single/inline", func(b *testing.B) {

67. m := escapeToHeap(&id32{id: 6754})
68. b.ResetTimer()
69. for i := 0; i < b.N; i++ {

70. //MOVQ 24(AX), CX
71. //MOVQ "".m.data+40(SP), DX
72. //MOVQ DX, (SP)
73. //CALL CX
74. //MOVL 8(SP), AX
75. //MOVQ "". &myID+48(SP), ex
76. //MOVL AX, (CX)
77. myID = m.idinline()
78. }
79. })
80. } //
81. func main() {}

测试过程和结果


1. //直接调用
2. #go test -bench= 'BenchmarkMethodCall_direct/single/noinline' -cpu=1 -count=5 iface_bench_test.go
3. goos:linux
4. goarch:amd64
5. BenchmarkMethodCall_direct/single/noinline 2000000000 2.00 ns/op
6. BenchmarkMethodCall_direct/single/noinline 2000000000 1.97 ns/op
7. BenchmarkMethodCall_direct/single/noinline 2000000000 1.97 ns/op
8. BenchmarkMethodCall_direct/single/noinline 2000000000 1.94 ns/op
9. BenchmarkMethodCall_direct/single/noinline 2000000000 1.97 ns/op
10. PASS
11. ok command-line-arguments 20.682s
12. 
13. //接口调用
14. #go test -bench='BenchmarkMethodCall_interface/single/noinline' -cpu=1 -count=5 iface_bench_test.go
15. goos:linux
16. goarch:amd64
17. BenchmarkMethodCall_interface/single/noinline 1000000000 2.18 ns/op
18. BenchmarkMethodCall_interface/single/noinline 1000000000 2.16 ns/op
19. BenchmarkMethodCall_interface/single/noinline 1000000000 2.17 ns/op
20. BenchmarkMethodCall_interface/single/noinline 1000000000 2.15 ns/op
21. BenchmarkMethodCall_interface/single/noinline 1000000000 2.16 ns/op
22. PASS
23. ok command-line-arguments 11.930s

结果分析

直接调用平均时耗为 1.97ns/op,接口调用的平均时耗为 2.16ns/op, (2.16-1.97)/1.97 约等于 9.64%。可以看到测试结果符合预期,每次迭代接口要慢 0.19ns,大约有 9% 的性能损失。

但是要清楚这个百分比并不能真实地反映接口的效率问题,首先调用的方法是一个很简单的方法,方法的耗时占比很小,无形中放大了接口调用的耗时。如果方法里面有复杂的逻辑,则真实的性能损失远远小于9%。

从绝对值的角度来看更合理,那就是每次接口调用大约比直接调用慢 0.2ns ,从这个角度看,动态调用的性能损失几乎可以忽略不计。

空接口数据结构

前面我们了解到空接口 interface{} 是没有任何方法集的接口,所以空接口内部不需要维护和动态内存分配相关的数据结构 itab 。空接口只关心存放的具体类型是什么,具体类型的值是什么,所以空接口的底层数据结构也很简单,具体如下:



1. //go/src/runtime/runtime2.go
2. //空接口
3. type eface struct {

4. _type *_type
5. data unsafe.Pointer
6. }

从 eface 的数据结构可以看出,空接口不是真的为空,其保留了具体实例的类型和值拷贝,即便存放的具体类型是空的,空接口也不是空的。

由于空接口自身没有方法集,所以空接口变量实例化后的真正用途不是接口方法的动态调用。空接口在Go语言中真正的意义是支持多态,有如下几种方式使用了空接口(将空接口类型还原):

  • 通过接口类型断言
  • 通过接口类型查询
  • 通过反射

至此,接口内部实现原理全部讲完,大家在了解和学习接口内部实现的知识的同时,更应该学习和思考分析过程中的方法和技巧,使用该方法可以继续分析接口断言、接口查询和接口赋值的内部实现机制。