环境
- 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 函数,这可能是需要重命名的原因。
当我们继续探究时,可能更加接近真相。
有一点需要明确并始终坚信:除全局常量和全局变量的声明之外,所有的可执行代码都必须在函数内执行。
通常情况下,代码编译之后,
- 声明的全局常量可能被存储在可执行文件的.rodata section。
- 声明的全局变量可能被存储在可执行文件的.data、.bss、.noptrdata等section。
- 声明的函数或方法被编译为机器指令存储在可执行文件的.text section。
那么,以下代码中(func_init.go),声明全局变量的同时进行初始化赋值,该如何编译呢?
以下代码属于变量声明。
- var m
- var name
而以下代码包含函数调用和初始化赋值,最终要被编译为机器指令,并且需要在main函数之前执行;这些指令最终必须占用一块存储空间并且能够加载到内存中。
- var m = map[string]int{
- "Jack": 18,
- "Rose": 16,
- }
- var name = flag.String("name", "", "user name")
它们被存储在可执行文件的什么地方了呢?
通过逆向分析,发现Go编译器合并了函数外的代码调用(全局变量的初始化赋值),自动生成了一个 init 函数;很明显,在func_init.go源文件中并没有定义初始化函数。
这可能也是编译器重命名自定义init函数的原因吧。
编译存储
所有的初始化函数都不可被直接调用!所有它们会被存储起来并在程序启动时自动执行。
在代码编译过程中,当前包的初始化函数及其依赖的包的初始化,会被存储到一个特殊的结构体中,该结构体定义在runtime/proc.go源文件中,如下所示:
- type initTask struct {
- state uintptr // 当前包在程序运行时的初始化状态:0 = uninitialized, 1 = in progress, 2 = done
- ndeps uintptr // 当前包的依赖包的数量
- nfns uintptr // 当前包的初始化函数数量
- }
Go语言是一个语法糖很重的编程语言,在源码中看到的往往不是真实的。
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函数,此处不再赘述。
Go编译器为每个包生成一个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函数执行。
结语
至此,本文完整地、详细地介绍了Go中关于初始化函数相关的内容。
相信在认真刨析了初始化函数的所有细节之后,对Go有了更近一步的了解。
希望有助于减少开发编码过程中的疑惑,更加得心应手,游刃有余。
本文转载自微信公众号「Golang In Memory」