包依赖与初始化

非静态初始化

从语义上来讲,包级别变量的初始化工作应该在程序代码开始使用这些变量前完成。

假如有像这样的一段代码:

1
2
3
4
5
6
7
package main

var n = 123

func main() {
println(n)
}

这没什么特别的,n会被编译器分配到数据段,并且直接赋予初始值123。变量n的初始化工作在编译阶段就已经完成,运行阶段拿来即用。

再来看看这样的一段代码:

1
2
3
4
5
6
7
8
package main

var m = make(map[string]string)

func main() {
m["hello"] = "world"
println(m["hello"])
}

毫无疑问这段代码是合法的,可以正常通过编译并且正常运行。但是你应该注意到,make在这里是一个函数调用,并不是一个能够被编译器在编译阶段求值的表达式。

如果深入去思考的话,你应该会感到疑惑,这个make出现在任何函数的外部,它是如何被执行的呢?

如果对Go语言比较熟悉的话,你应该可以想到init函数。事实上,编译器在需要的时候会自动帮我们生成init函数,像上例中的make这种代码会被编译器放到init函数中去执行。这一点很容易通过反编译来验证。

init的顺序

Go语言允许同一个包中有多个init函数,并且对它们的执行顺序不做要求,实际上有一个init函数例外,就是编译器生成的那个。

如果有这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
package main

var m = make(map[string]string)

func init() {
m["hello"] = "world"
}

func main() {
println(m["hello"])
}

经过前面的研究,我们已经知道编译器会生成一个init函数来调用make。这里我们又自己实现了一个init函数,并且在init函数中使用了m。为了保证程序能够正常运行,编译器生成的init函数要比我们实现的init函数先得到执行。

事实上,Go语言的编译器会给用户代码实现的init函数进行编号,从序号0开始。如果用nm工具检查上例中代码生成的可执行文件,就会发现main.init.0和main.init两个函数,前者是我们实现的,后者没有编号的是编译器生成的,编译器生成的init的函数会首先得到执行。

初始化的实现

Go语言承诺被依赖的包会比当前包先初始化,实际上也确实是根据包依赖关系来组织初始化顺序的,因为有“依赖”的含义在,不允许出现循环是理所当然的。

这层包依赖关系就是在编译阶段,由编译器根据所有的import梳理出来的,所以要理解编译器不允许import没用到的包。包依赖关系,以及每个包的系列init函数,是整个初始化得以实现的基础。

编译阶段准备好了包依赖关系和init函数,实际的初始化动作就要靠runtime来完成了。包依赖关系像是一颗树,每个包对应树上的一个节点,节点的类型对应runtime包中的initTask结构,如下所示:

initTask

state字段有3个可能的值:0表示当前包尚未初始化,1表示初始化中,2表示初始化已经完成;

ndeps表示当前包所依赖的包的数量,nfns表示当前包有多少个init函数;

在go1.14中,这3个字段都是uintptr类型。紧随其后的是一个initTask指针数组,数组的大小等于ndeps,数组的元素分别指向依赖的包的initTask结构。在此之后是一个大小为nfns的func()数组,里面是按顺序排列的Function Value,依次指向当前包的系列init函数。

假设我们的代码中有3个包:main、p1和p2。其中,main依赖于p1和p2,并且有一个编译器生成的init函数和一个用户实现的init函数;p1依赖于p2,有一个用户实现的init函数;p2不依赖其他包,有一个编译器生成的init函数;则整体的initTask结构和init函数关系如下所示:

initTaskRelation

初始化过程调用init函数的顺序就是:p2.init、p1.init.0、main.init、main.init.0。

包依赖只是不允许出现循环,一个包可以依赖多个包,多个包也可以依赖同一个包,实质上构成了一个有向无环图。

运行阶段runtime.doInit函数会递归遍历整个依赖关系图,从最底层开始执行init函数。整个递归初始化过程是在单个线程中完成的,所以这里不会有并发问题。

initTask的state字段初始值为0,表示当前包尚未初始化;doInit函数拿到一个未初始化的initTask后,会先将state置为1,表示当前包初始化中,然后再去递归初始化依赖的包;当依赖的包都初始化完成后,doInit会调用当前包的系列init函数,然后把state置为2,表示当前包以及所有依赖包都已初始化完毕;最后doInit函数返回。

如果doInit函数拿到一个state为2的initTask,表明该节点已经被初始化过了,doInit会直接返回;如果doInit拿到一个state为1的initTask,表明依赖关系图中出现了环路,有可能编译链接工具链存在bug,这种情况下doInit通过调用throw函数来终止程序。