本文完整地, 详细地介绍了 Go 中关于初始化函数相关的内容. 相信在认真刨析了初始化函数的所有细节之后, 对 Go 有了更近一步的了解.
环境
- 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」
来源: http://developer.51cto.com/art/202109/683674.htm