一、SIOF
Static Initialization Order Fiasco,简称 SIOF,也就是静态初始化顺序问题。它指的是跨编译单元的全局变量初始化顺序不固定,一旦彼此之间存在依赖,就可能产生难以预料的错误。之前虽然分析过几次,但重点往往放在“怎么解决”上。这篇文章想从实际执行过程出发,看看全局变量到底按什么顺序加载,帮大家弄明白 SIOF 为什么会出现。
有一个前置认知很容易被忽略:全局变量加载了,不代表它真正完成了初始化。很多开发者恰恰在这里踩坑,最终表现为程序的随机性故障。比如前面提到的一个动态库全局变量随机异常的问题,排查时甚至到现场蹲了一个月才定位到根因。
二、常见场景
出现这类问题的场景,通常集中在下面三类:
-
主程序和动态库之间的全局变量依赖
这是最典型、也最容易碰到的情况。尤其对刚接触 C++ 的新手来说,往往很难第一时间发现。
-
同一模块内不同编译单元之间的全局变量依赖
这种也比较常见,好在这类问题相对容易定位和处理。
-
动态库与动态库之间的全局变量依赖
这种情况稍显少见。通常一个库会依赖另一个库,本身就说明库的结构设计和职责划分可能出了问题;而新手一般又遇不到这种多库联动场景。
无论哪一种,本质上都是加载顺序不同导致依赖的全局变量没能正确初始化,最终破坏程序目标,也就是典型的 SIOF 问题。
三、加载顺序
前面说到加载和初始化是两回事。以 Linux 平台为例,一个程序依赖了动态库时,加载大致按以下步骤展开:
-
创建加载进程
通过 fork() 等方式创建进程,内核映射 ELF 主程序和解释器。
-
调用链接器
进入动态加载器 ld-linux.so。
-
映射主程序
-
处理动态库
通过 DT_NEEDED(.dynamic 区)构建依赖树,并遍历加载依赖动态库。假设程序依赖 a.so,a.so 又依赖 b.so,就会先加载 b.so,直到没有更深的依赖后,再逐级回退完成全部加载。
-
建立 .data、.bss、TLS 等存储
-
解析符号并处理重定位
从第 4 步开始,后面都是初始化之前的准备工作。动态库加载本质上是一个遍历过程,例如常见的 BFS,因此整体呈现出自上而下的加载顺序。
四、初始化顺序
加载和准备完成后,才开始执行初始化。初始化顺序正好和加载相反,是自下而上的。一般来说分三步:
-
执行动态库初始化函数
也就是沿着依赖树,从最远端依赖开始构造。例如上面的 b.so 会先执行,调用相关的 init_array段(构造函数地址,可通过 __attribute__((constructor)) 设置)、全局构造函数表等;随后回退到上一层依赖 a.so 中的 _init(每个库只有一个,目前已废弃),执行相同操作,直到所有库完成初始化。
-
执行主程序初始化函数
主程序同样会调用 init_array 段里的构造函数和全局对象构造逻辑,完成相关变量的初始化。不过凡事有例外:主程序还可能存在 .preinit_array,它的回调可以早于普通共享库的初始化入口执行。所以“自下而上”只是一般模型,不能绝对化理解。
-
回到 main()
初始化其实就两大块:主程序自身的初始化,以及所依赖动态库的初始化。动态库初始化会沿着依赖链不断向下延伸,直到最后一个被依赖的库才真正开始构造,再沿依赖路径逆序逐层返回。
这里要特别注意零初始化和调用构造函数的初始化的区别。不少开发者以为全局对象默认就是零初始化,或者编译器会自动补上零初始化,但实际业务有时需要构造函数去完成动态初始化。当这种情况叠加依赖顺序不确定时,就很容易触发未定义行为甚至崩溃。
看一个简单例子:
// 主程序
int g_v = 11; // 一般来说,动态库构造函数调用前已经可用,即 11 可见
std::string g_s = getStr(); // 这种情况相对复杂:在初始化函数和构造函数完成前,g_s 的值不可见
// 库
extern std::string g_s;
__attribute__((constructor))
static void myinit()
{
g_s.size(); // 未定义行为
}
这意味着,只要对象需要动态初始化或者存在严格数据依赖,就不能把尚处于零初始化状态的对象当成可用状态,这一点必须考虑清楚。
全局变量的初始化可以分成三类:
-
零初始化
未显式赋初值的静态存储期对象会被置零,通常指针为 NULL,整数为 0。这也是全局整型变量默认是 0 的原因。
-
常量初始化
编译器在程序运行前就完成变量初始化,比如 constexpr。这类方式能在一定程度上规避依赖顺序问题。
-
动态初始化
必须在程序运行时才能完成初始化,例如通过函数读取配置文件、分配资源等场景。
五、动态加载
动态库还存在另一种情况:运行时通过 dlopen() 动态加载。它的加载和初始化顺序如下:
- 进入
main()
- 准备基础服务
- 调用
dlopen()
- 映射
libfoo.so 及其新增依赖
- 符号解析与重定位
- 执行依赖库和
libfoo.so 的初始化入口
dlopen() 返回
- 调用显式的
plugin_initialize(services)
dlopen() 返回只表示装载和初始化动作完成,并不代表上下文逻辑可以安全匹配。如果此时还有全局变量依赖,依旧可能出现各种问题。
另外还要关注 dlopen() 常用的两个参数:RTLD_LAZY 和 RTLD_NOW。RTLD_LAZY 允许函数的 PLT 绑定推迟到第一次调用,RTLD_NOW 则要求在装载阶段尽早解析相关符号。但两者都不能推迟已经需要在构造函数中访问的数据对象所必需的重定位,也都不能改变 C++ 全局对象依赖先后顺序这一事实。如果希望 dlopen() 返回前尽早暴露缺失符号,使用 RTLD_NOW 更容易定位问题,但它并不是对象初始化顺序管理机制。
六、处理方法
理解了全局变量的加载和初始化顺序之后,处理 SIOF 就有了清晰方向。常见机制可以归纳成下面三类:
-
设计控制
同一模块内部,尤其是跨 .cpp 单元的全局变量,可以集中到指定头文件中统一处理。跨模块场景,比如多个动态库,则需要显式地进行分阶段加载和初始化控制。在设计阶段就明确指定相关依赖的装载与初始化顺序,避免循环依赖;如果能把模块拆成互不依赖的结构当然更好。同时还要显式控制生命周期的同步,比如销毁和卸载顺序。
-
技术控制
可选手段很多。例如使用常量初始化,这在以前讨论的“使用 constinit 规避 SIOF”里分析过;利用局部静态变量“第一次使用时才动态构造”的特点,也是 C++ 编程思想里推荐的做法;显式使用依赖注入控制依赖关系;或者提供 initialize() 之类的接口,把风险集中管理。
-
动态加载控制
要清楚 dlopen() 工作在运行期,尽量避免在构造函数等特殊函数里调用它。程序退出时,析构顺序必须与初始化时的调用严格逆序执行。
虽然不同书籍、不同平台给出的推荐方式不尽相同,但整体原则逃不出以上几点。
七、总结
回到底层,理解这种全局变量的加载和初始化过程就不再困难。C/C++ 都是强类型语言,变量符号被发现和解析只是前提,并不代表变量对象已经构造完成,特别是非 POD 或非平凡类型。变量加载成功,也未必意味着初始化完成。开发者只有显式控制变量的加载与初始化过程,才能让程序更安全,这恰恰是避免 SIOF 的根本方式。这类话题在云栈社区也有过不少讨论。