初始化函数
环境
OS : Ubuntu 20.04.2 LTS; x86_64
Go : go version go1.16.2 linux/amd64
包初始化
初始化函数与其他普通函数一样,都隶属于定义它的包(package),以下统称为当前包。
一般来讲,一个包初始化过程分三步:
- 初始化当前包依赖的所有包,包括依赖包的依赖包。
- 初始化当前包所有具有初始值的全局变量。
- 执行当前包的所有初始化函数。
关于这个过程,本文会一一详细介绍。
基本定义
在Golang中有一类特殊的初始化函数,其定义格式如下:
package pkg
func init() {
// to do sth
}
初始化函数一个特殊之处是:其在可执行程序的main入口函数执行之前自动执行。
所有的初始化函数都不可被直接调用!
重复声明
初始化函数第二个特殊之处是:在同一个包下,可以重复定义多次。
普通函数在同一个包下不可以重名,否则变异失败:xxx redeclared in this block。
编译重命名
初始化函数第三个特殊之处是:编译重命名规则与普通函数不同。
普通函数在编译过程中一般重命名规则为“包名.函数名”。
初始化函数在源码中虽然名称为init,但在编译过程中重命名规则为“包名.init.数字后缀”。
例如:
- 在上述的 func_init.0.go 源文件编译之后,
init函数被重命名为:main.init.0。 - 在上述的 func_init.1.go 源文件编译之后,两个
init函数分别被重命名为:main.init.0、main.init.1。
如上所示,如果同一个包下有多个init函数,重命名时后缀数字按顺序增加一。
为什么会这样呢?
那是因为Golang编译器对 init 函数进行了特殊处理,相关源码位于 cmd/compile/internal/gc/init.go 文件中。
全局变量 renameinitgen 用于记录当前包名下init函数的数量以及下一个init函数后缀的值。
每当Golang编译器遇到一个名称为 init 的函数,就会调用一次 renameinit() 函数,最终 init 函数变得不可被调用。
为什么重命名init函数?
如上述我们看到的,在同一个包下可以重复声明 init 函数,这可能是需要重命名的原因。
当我们继续探究时,可能更加接近真相。
有一点需要明确并始终坚信:除全局常量和全局变量的声明之外,所有的可执行代码都必须在函数内执行。
通常情况下,代码编译之后,
- 声明的全局常量可能被存储在可执行文件的
.rodatasection。 - 声明的全局变量可能被存储在可执行文件的
.data、.bss、.noptrdata等section。 - 声明的函数或方法被编译为机器指令存储在可执行文件的
.textsection。
那么,以下代码中,声明全局变量的同时进行初始化赋值,该如何编译呢?
func_init.go
以下代码属于变量声明。
var m
var name
而以下代码包含函数调用和初始化赋值,最终要被编译为机器指令,并且需要在main函数之前执行;这些指令最终必须占用一块存储空间并且能够加载到内存中。
var m = map[string]int{
"Jack": 18,
"Rose": 16,
}
var name = flag.String("name", "", "user name")
它们被存储在可执行文件的什么地方了呢?
通过逆向分析,发现Golang编译器合并了函数外的代码调用(全局变量的初始化赋值),自动生成了一个 init 函数;很明显,在func_init.go源文件中并没有定义初始化函数。
这可能也是编译器重命名自定义init函数的原因吧。
编译存储
所有的初始化函数都不可被直接调用! 所有它们会被存储起来并在程序启动时自动执行。
在代码编译过程中,当前包的初始化函数及其依赖的包的初始化,会被存储到一个特殊的结构体中,该结构体定义在runtime/proc.go源文件中,如下所示:
type initTask struct {
state uintptr // 当前包在程序运行时的初始化状态: 0 = uninitialized, 1 = in progress, 2 = done
ndeps uintptr // 当前包的依赖包的数量
nfns uintptr // 当前包的初始化函数数量
}
Golang是一个语法糖很重的编程语言,在源码中看到的往往不是真实的。
runtime.initTask结构体是一个编译时可修改的动态结构。其真实面貌如下所示:
type initTask struct {
state uintptr // 当前包在程序运行时的初始化状态: 0 = uninitialized, 1 = in progress, 2 = done
ndeps uintptr // 当前包的依赖包的数量
nfns uintptr // 当前包的初始化函数数量
deps [ndeps]*initTask // 当前包的依赖包的initTask指针数组(不是slice)
fns [nfns]func () // 当前包的初始化函数指针数组(不是slice)
}
每个包的依赖包数量可能不同(ndeps),每个包的初始化函数数量不同(nfns),所以最终生成的initTask对象大小可能不同。
具体编译过程参考cmd/compile/internal/gc/init.go源文件中的fninit函数,此处不再赘述。
Golang编译器为每个包生成一个runtime.initTask类型的全局变量,该变量的命名规则为“包名..inittask”,如下所示:
从上图第三列可以看出,每个包的initTask对象大小不同。具体计算方法如下:
size := (3 + ndeps + nfns) * 8
初始化过程
在可执行程序启动的初始化过程中,优先执行runtime包及其依赖包的初始化,然后执行main包及其依赖包的初始化。
一个包可能被多个包依赖,但是每个包的都只初始化一次,通过runtime.initTask.state字段进行控制。
具体的初始化逻辑请参考runtime/proc.go源文件中的main函数和doInit函数。
在初始化过程中,runtime.doInit函数会被调用很多次,其具体执行流程如本文开头的“包初始化”一节所述一致。
如前图所示的func_init.2.go源文件,编译之后包含两个初始化函数:一个是编译器自动生成的,另一个是编译器重命名的;自动生成的初始化函数优先执行。
如前图所示的func_init.2.go源文件,编译之后生成的main..inittask全局变量的内存地址是0x000000000054dc60。我们动态调试runtime.doInit函数,在其参数为main..inittask全局变量指针时暂停执行,观察参数的数据结构。
从动态调试时展示的内存数据我们反推出如下伪代码:
package main
var inittask = struct {
state uintptr // 当前包在程序运行时的初始化状态: 0 = uninitialized, 1 = in progress, 2 = done
ndeps uintptr // 当前包依赖的包的initTask数量
nfns uintptr // 当前包的初始化函数数量
deps [2]uintptr // 当前包依赖的包的initTask指针数组(不是slice)
fns [2]uintptr // 当前包的初始化函数指针数组(不是slice)
}{
state: 0,
ndeps: 2,
nfns: 2,
deps: [2]uintptr{0x54ef60, 0x54eca0}, // flag..inittask,fmt..inittask
fns: [2]uintptr{0x4a4ec0, 0x4a4d60}, // main.init,main.init.0
}
在func_init.2.go源文件中,引用了flag、fmt两个包,所以main包的初始化必须在这两个包的初始化完成之后执行。
import "flag"
import "fmt"
通常initTask.ndeps字段的值与import的数量相同。
编译器自动生成的init函数先于代码源文件中自定义的init函数执行。
结语
至此,本文完整地、详细地介绍了Golang中关于初始化函数相关的内容。
相信在认真刨析了初始化函数的所有细节之后,对Golang有了更近一步的了解。
希望有助于减少开发编码过程中的疑惑,更加得心应手,游刃有余。