当你通过接口引用使用一个变量时,你知道 Go 运行时到底做了哪些工作吗?这个问题并不容易回答。这是因为在 Go 中,一个类型实现了一个接口,但是这个类型并没有包含任何对这个接口的引用。与上一篇博客《Go语言内幕(1):主要概念与项目结构》一样,你可以用 Go 编译器的知识来回答这个问题。关于 Go 编译器的内容我们已经在上一篇中已经讨论过一部分了。
在这里,让我们更加深入地探索 Go 编译器:创建一个简单的 Go 程序来看一下 Go 内部在类型转换时到底做了哪些工作。通过这个例子,我会解释结点树是如何生成并被使用的。同样地,你也可以将这篇博客的知识应用到其它 Go 编译器特征的研究中。
前言
要完成这个实验,我们需要直接使用 Go 编译器(而不是使用 Go 工具)。你可以通过下面的命令来使用:
Shell
1 | go tool 6g test.go |
这个命令会编译 test.go 源文件并生成目标文件。这里, 6g 是 AMD64 架构上编译器的名称。请注意,如果你在不同的架构上,请使用不同的编译器。
在直接使用编译器的时候,我们可能会用到一些命令行参数(详细内容请参考这里)。在这个实验中,我们会用到 -W 参数,这个参数会输出结点树的布局结构。
创建一个简单的 Go 程序
首先,我们需要先编写一个简单的 Go 程序。 我编写的程序如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | package main
type I interface { DoSomeWork() }
type T struct { a int }
func (t *T) DoSomeWork() { }
func main() { t := &T{} i := I(t) print(i) } |
这段代码非常简单,不是吗?其中第 17 输出了变量 i 的值,这一行代码看上去多此一举。但是,如果没有这一行代码,程序中就没有使用到变量 i,那么整个程序就不会被编译。接下来,我们将使用 -W 参数来编译我们的程序:
Shell
1 | go tool 6g -W test.go |
完成编译后,你会看到输出中包含了程序中定义的每个方法的结点树。在我们这个例子中有 main 和 init 方法。init 方法是隐式生成的,所有的程序都会有这个方法。此处,我们暂将该方法搁置在一边。
对于每个方法,编译器都会输出两个版本的结点树。第一个是刚解析完源文件生成的原始结点树。另外一个则是完成类型检查以及一些必须的修改后的结点树。
分析 main 方法的结点树
让我们仔细看一下 main 方法的最初版本结点树,尽量搞清楚 Go 编译器到底做了哪些工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | DCL l(15) . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T
AS l(15) colas(1) tc(1) . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T . PTRLIT l(15) esc(no) ld(1) tc(1) PTR64-*main.T . . STRUCTLIT l(15) tc(1) main.T . . . TYPE <S> l(15) tc(1) implicit(1) type=PTR64-*main.T PTR64-*main.T
DCL l(16) . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I
AS l(16) tc(1) . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T
AS l(16) colas(1) tc(1) . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I . CONVIFACE l(16) tc(1) main.I . . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T
VARKILL l(16) tc(1) . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T
PRINT l(17) tc(1) PRINT-list . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I |
下面的分析过程中,我会删除结点树中一些不必要的信息。
第一个结点非常的简单:
1 2 | DCL l(15) . NAME-main.t l(15) PTR64-*main.T |
第一个结点是一个声明结点。 l(15) 说明这个结点的定义在源码的第 15 行。这个声明结点引用了表示 main.t 变量的名称结点。这个变量是定义在 main 包中指向 main.T 类型的一个 64 位指针。你去看一下源代码中的第 15 行就很容易就明白这个声明代表着什么了。
接下来这个结点又是一个声明结点。这一次,这个声明结点声明了一个属于 main.T 类型的变量 main.i。
1 2 | DCL l(16) . NAME-main.i l(16) main.I |
然后,编译器创建了另外一个变量 autotmp_0000, 并将变量 main.t 赋值给该变量。
1 2 3 | AS l(16) tc(1) . NAME-main.autotmp_0000 l(16) PTR64-*main.T . NAME-main.t l(15) PTR64-*main.T |
最后,我们终于看到我们真正感兴趣的结点。
C++
1 2 3 4 | AS l(16) . NAME-main.i l(16)main.I . CONVIFACE l(16) main.I . . NAME-main.autotmp_0000 PTR64-*main.T |
我们可以看到编译器将一个特殊的结点 CONVIFACE 赋值给了变量 main.i。但是,这并没有告诉我们在这个赋值背后到底发生了什么。为了搞清楚幕后真相,我们需要去分析一下修改完成后的 main 方法结点树(你可以在输出信息的 “after walk main” 这一小节中看到相关的信息)。
编译器怎么翻译赋值结点
下面,你将看到编译器到底是如何翻译赋值结点的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | AS-init . AS l(16) . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8
. IF l(16) . IF-test . . EQ l(16) bool . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . . LITERAL-nil I(16) PTR64-*uint8 . IF-body . . AS l(16) . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . . CALLFUNC l(16) PTR64-*byte . . . . NAME-runtime.typ2Itab l(2) FUNC-funcSTRUCT-(FIELD- . . . . . NAME-runtime.typ·2 l(2) PTR64-*byte, FIELD- . . . . . NAME-runtime.typ2·3 l(2) PTR64-*byte PTR64-*byte, FIELD- . . . . . NAME-runtime.cache·4 l(2) PTR64-*PTR64-*byte PTR64-*PTR64-*byte) PTR64-*byte . . . CALLFUNC-list . . . . AS l(16) . . . . . INDREG-SP l(16) runtime.typ·2 G0 PTR64-*byte . . . . . ADDR l(16) PTR64-*uint8 . . . . . . NAME-type.*"".T l(11) uint8
. . . . AS l(16) . . . . . INDREG-SP l(16) runtime.typ2·3 G0 PTR64-*byte . . . . . ADDR l(16) PTR64-*uint8 . . . . . . NAME-type."".I l(16) uint8
. . . . AS l(16) . . . . . INDREG-SP l(16) runtime.cache·4 G0 PTR64-*PTR64-*byte . . . . . ADDR l(16) PTR64-*PTR64-*uint8 . . . . . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8 AS l(16) . NAME-main.i l(16) main.I . EFACE l(16) main.I . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-main.autotmp_0000 l(16) PTR64-*main.T |
正如在输入中看到的那样,编译器首先给赋值结点增加了一个初始化结点列表(AS-init)用以分配节点,在 AS-init 结点中,它创建一个新的变量 main.autotmp_0003,并将 go.itab.*””.T.””.I 变量的值赋给新生成的变量。随后检查这个变量是否为 nil。如果变量为 nil,编译器使用如下参数调用 runtime.type2Itab 函数:
1 2 3 | a pointer to the main.T type , a pointer to the main.I interface type, and a pointer to the go.itab.*””.T.””.I variable. |
从这部分代码很容易看出,这个变量是用于缓存从 main.T 转换到 main.I 的中间结果。
getitab 方法内部
逻辑上来说,下一步就是找到 runtime.typ2Itab 方法。下面就是这个方法:
1 2 3 4 5 | func typ2Itab(t *_type, inter *interfacetype, cache **itab) *itab { tab := getitab(inter, t, false) atomicstorep(unsafe.Pointer(cache), unsafe.Pointer(tab)) return tab } |
很明显,runtime.typ2Itab 方法中第二行只是简单地创建了一个 tab 变量,所以真正的工作都是在 getitab 方法中完成的。因此,我们再去探索 getitab 方法。因为这个方法的代码量非常巨大,所以我只拷贝了其中最重要的一部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys)) m.inter = interm._type = typ
ni := len(inter.mhdr) nt := len(x.mhdr) j := 0 for k := 0; k < ni; k++ { i := &inter.mhdr[k] iname := i.name ipkgpath := i.pkgpath itype := i._type for ; j < nt; j++ { t := &x.mhdr[j] if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath { if m != nil { *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn } } } } |
首先,我们为结果分配了一段内存空间:
1 | (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys)) |
为什么我们要分配内存空间而且还是以这样奇怪的方式呢?要回答这个问题,我们需要看一下 itab 结构体的定义。
1 2 3 4 5 6 7 8 | type itab struct { inter *interfacetype _type *_type link *itab bad int32 unused int32 fun [1]uintptr // variable sized } |
最后一个属性 fun 定义为一个只有一个元素的数组,但是这个数组的长度实际上是可变的。随后,我们会看到这个可变数组中存储了指向在类型中定义的方法的指针。这些方法对应于接口类型的方法。 Go 语言作者使用动态内存分配的方法为这个属性分配空间(是的,如果你使用 unsafe 包时,这么做是可行的)。分配内存的大小为接口中方法的数量乘以指针的大小再加上结构体本身的大小之和。
1 | unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize |
接下来,你会看到一个嵌套循环。首先,我们遍历所有接口的方法。对于接口中的每一个方法,我们都会尽力在类型中找到一个对应的方法(这些方法存储于 mhdr 集合中)。检查两个方法是否相同的方法是相当明了的。
1 | *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn |
这里做了一点性能上的改进:这些接口以及预置类型的方法都是以字母顺序排列的,这个嵌套循环只需要 O(n + m) 而不是O(n * m),其中 n 和 m 分别对应于方法的数量。
你还记得赋值的最后一部分吗?
1 2 3 4 5 | AS l(16) . NAME-main.i l(16) main.I . EFACE l(16) main.I . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-main.autotmp_0000 l(16) PTR64-*main.T |
这里,我们将 EFACE 结点赋值给 main.i 变量。这个结点(EFACE)包含了对变量 main.autotmp_0003 的引用–指向由 runtime.typ2Itab 方法返回的 itab 结构的指针,还包含对 autotmp_0000 变量的引用 , autotmp_0000 变量中包含了与 main.t 变量相同的值。以上就是我们通过接口引用调用方法所需的全部信息了。
因此,main.i 变量存储了定义在运行时包中 iface 结构体的一个实例:
1 2 3 4 | type iface struct { tab *itab data unsafe.Pointer } |
下一篇讲什么?
到目前为止,我们也只分析了 Go 编译器与 Go 运行时的一小部分代码。还有大量的有意思的内容等待我们去探索,比如目标文件、链接器、重定位等。在接下来的博客中我会来依次分析这些内容。