Appearance
全局单例的构造与析构顺序 —— 从编译到退出的完整链路
C++ 全局对象在
main之前构造、在main之后析构——这件事人尽皆知。但如果两个全局单例之间有依赖关系呢?如果退出时程序崩溃在某个析构函数里呢?本文从.init_array的生成、glibcexit()的执行流程、到跨编译单元的构造顺序陷阱、再到工程上常用的解决方案,把这一整条链路讲透。
更新时间:2026-08-07
本文要回答的问题
- 同一个
.cpp里多个全局对象的构造/析构顺序是怎样的? - 不同
.cpp/.so之间的全局对象构造顺序是怎样的?为什么不可控? - glibc 的
exit()到底做了什么?atexit和.fini_array的执行顺序是怎样的? - 退出时全局对象析构最常见的坑有哪些?
- 工程上如何绕过这些问题?
一、基础知识:构造与析构的"铁律"
1.1 编译单元内:顺序是确定的
在一个 .cpp 里,全局对象的初始化顺序 = 定义顺序;析构顺序 = 初始化顺序的逆序。这是 C++ 标准的保证。
cpp
// file: a.cpp
#include <cstdio>
struct X { int id; X(int i) : id(i) { printf("X%d ctor\n", id); }
~X() { printf("X%d dtor\n", id); } };
X x1(1);
X x2(2);
X x3(3);
// 输出(永远是这个顺序):
// X1 ctor
// X2 ctor
// X3 ctor
// X3 dtor
// X2 dtor
// X1 dtor1.2 编译单元之间:顺序是不确定的
不同 .cpp 的全局对象初始化顺序是未指定的(unspecified)。这是 C++ 的"静态初始化顺序溃败"(Static Initialization Order Fiasco)的根源。
cpp
// a.cpp
#include <string>
extern std::string g_name;
std::string g_msg = "Hello " + g_name; // g_name 可能还没构造!
// b.cpp
#include <string>
std::string g_name = "World"; // g_name 和 g_msg 谁先构造?| 场景 | 顺序 |
|---|---|
同一 .cpp 内 | 定义顺序(确定,标准保证) |
不同 .cpp,同一个 .o → 同 .so/可执行文件 | 链接顺序(不确定,取决于链接器命令行参数顺序) |
不同 .so | 动态链接器加载顺序(不确定,依赖拓扑排序但非严格保证) |
| 析构 | 各 TU 内部 = 构造的逆序;TU 之间 = 不确定(C++ 标准不保证) |
二、底层机制:全局对象怎么在 main 之前运行?
init-fini.md 已详细讲过
.init_array的结构和调用时序,本节从 C++ 全局对象的角度补充编译器生成的代码细节。
2.1 编译器生成了什么
对于一个全局对象:
cpp
MyClass global_obj; // 定义在全局作用域GCC 不会在 .text 里找一条 call MyClass::MyClass 指令。它在做的事:
- 生成构造代理函数(trampoline):一个
__static_initialization_and_destruction_0函数 - 将代理函数地址放入
.init_array:链接器收集所有.init_array入口 - 通过
__cxa_atexit注册析构函数:构造完成后立即调用__cxa_atexit(&MyClass::~MyClass, &global_obj, &__dso_handle),把析构函数压入 atexit 栈 - 生成
.fini_array条目:如果用了__attribute__((destructor))或编译器的 fini trampoline

2.2 DSO handle 的作用
注意 __cxa_atexit 的第三个参数 __dso_handle——它是动态共享对象的标识。它的用途:当 .so 被 dlclose 卸载时,glibc 会遍历 atexit 栈,只执行那些 __dso_handle 匹配的回调,清理该 .so 注册的析构函数。这防止了".so 已卸载、还存在 dangling 析构指针"的崩溃。
2.3 初始化状态保护
GCC 生成的代理函数里有一个 guard 变量:
cpp
// 编译器实际生成的逻辑等价于:
static int __guard = 0; // 64 字节 guard 变量,原子操作保护
if (__cxa_guard_acquire(&__guard)) {
new (&global_obj) MyClass(); // placement new 构造
__cxa_atexit(dtor, &global_obj, &__dso_handle);
__cxa_guard_release(&__guard);
}这保证了即使多个线程同时触发初始化,全局对象也只构造一次。但注意:guard 只保护单例构造,不保护跨 TU 的顺序依赖。
三、glibc exit() 的内部执行流程
3.1 三种触发路径
| 触发方式 | 入口 | 说明 |
|---|---|---|
main() return | exit() | 编译器在 main 返回后隐式插入 exit(return_code) 调用 |
显式调用 exit(n) | exit() | 执行完整清理流程 |
显式调用 _exit(n) | _exit() | 跳过所有清理,直接系统调用 exit_group |
显式调用 _Exit(n) | _Exit() | C 标准版,等同于 _exit() |
abort() | raise(SIGABRT) | 不执行任何清理,直接信号自杀 |
quick_exit(n) | quick_exit() | 只执行 at_quick_exit 注册的回调,跳过 atexit |
3.2 exit() 内部调用链

3.3 atexit 栈的详细结构
c
// glibc stdlib/exit.c 简化版注解
void exit(int status) {
__run_exit_handlers(status, &__exit_funcs, true, true);
}
void __run_exit_handlers(int status, struct exit_function_list **listp,
bool run_list_atexit, bool run_dtors) {
// __exit_funcs 是全局 atexit 链表头
// 结构:exit_function_list → next → next → ...
// 每个节点包含多个 struct exit_function:
// - flavor == ef_cxa → __cxa_atexit 注册的
// - flavor == ef_on → atexit() 注册的
// - flavor == ef_at → at_quick_exit() 注册的
while (*listp != NULL) {
// 从链表末尾开始遍历(逆序)
for (cur = 链表最后一个节点; cur >= 第一个节点; cur--) {
switch (cur->flavor) {
case ef_cxa:
// 全局对象的析构函数
cur->func.cxa.fn(cur->func.cxa.arg, status);
break;
case ef_on:
// 用户 atexit() 注册的普通函数
(*cur->func.at)(arg);
break;
}
}
listp = &(*listp)->next; // 继续下一段链表
}
// 然后执行 .fini_array
if (run_dtors) {
__libc_atexit(); // 内部遍历 .fini_array
_IO_cleanup(); // flush stdio 缓冲区
}
_exit(status); // 最终系统调用
}3.4 执行顺序总结
注册(main 之前) 注销(main 之后)
─────────────────────────────────────────────────────────
① .preinit_array ① 用户 atexit() 函数(逆序)
② 共享库 .init / .init_array ② __cxa_atexit 注册的析构(逆序)
③ 可执行文件 .init_array ③ .fini_array 函数指针数组
(全局对象构造 → __cxa_atexit) ④ _fini()
⑤ fflush(stdout/stderr)
④ main() ⑥ _exit() → syscall关键点:析构顺序不保证是构造顺序的严格逆序。.fini_array 的遍历顺序由链接器决定,.so 卸载时的析构顺序还受 dlclose 影响。跨 .so 尤其不可靠。
四、跨编译单元的典型陷阱
4.1 静态初始化顺序溃败(最经典)
cpp
// logger.cpp
#include <string>
std::string g_log_dir = "/var/log/myapp"; // TU1 的全局对象
// config.cpp
extern std::string g_log_dir;
// config.cpp 编译单元里的全局对象在构造时访问 g_log_dir
Config g_config; // 构造函数里 fopen(g_log_dir + "/config") → 可能读到空字符串!Config 的构造函数用到了 g_log_dir,但如果链接器把 config.o 排在 logger.o 前面(取决于 Makefile 里的 .o 顺序),g_log_dir 就是零初始化状态——一个空 std::string。
症状:运行时表现不稳定——换台机器、加个文件、改个 Makefile,行为就变了。调试时极其痛苦。
4.2 析构顺序溃败(退出时 SIGSEGV)
cpp
// thread_pool.cpp
ThreadPool g_pool(8); // 构造时创建 8 个工作线程
// logger.cpp
Logger g_logger; // 析构时会调用 g_pool.submit() 写最后一条日志退出时可能发生:
g_pool先析构 → 工作线程被 join/杀死g_logger后析构 → 析构函数里调用g_pool.submit()→ 空指针/use-after-free → SIGSEGV
这比构造时崩溃更阴险——因为它在 exit() 的深层调用栈里崩,core dump 的回溯可能不完整,日志已经关了,stderr 也被 _IO_cleanup 刷掉了。
4.3 DSO 卸载 + 悬挂析构
cpp
// plugin.so
std::map<std::string, Plugin> g_registry; // 全局注册表
void register_plugin(Plugin p) {
g_registry[p.name] = p; // p 持有 plugin.so 里的函数指针
}
// 某刻 dlclose(plugin.so)
// → g_registry 还在(在主程序的数据段里)
// → 但 g_registry 里的 Plugin 对象的函数指针指向已卸载的 .so → 调用即崩溃4.4 fork + 多线程 + 全局对象
cpp
// fork() 只复制调用线程,其他线程被杀死
// 如果全局 ThreadPool 的析构函数里 join 了工作线程
// → fork 后子进程的 ThreadPool 析构时,join 一个不存在的线程 → 死锁4.5 atexit 与析构函数交互
cpp
static std::ofstream g_log("/tmp/app.log");
void cleanup() {
g_log << "goodbye" << std::endl; // atexit 注册的清理函数
}
int main() {
atexit(cleanup);
return 0;
}
// 退出时执行顺序:
// ① cleanup() → g_log << "goodbye" ← g_log 还活着,正常
// ② g_log 析构 → 关闭文件 → 正常但如果反过来——g_log 先析构、cleanup 后执行——就崩了。幸好 atexit 注册的函数先于 .fini_array 析构执行,所以用户 atexit 的回调总是在全局对象析构之前运行。
五、工程解决方案
5.1 Meyer's Singleton(函数内静态变量)—— 推荐首选
cpp
// 替代全局对象
Logger& getLogger() {
static Logger instance; // C++11 保证线程安全、只初始化一次
return instance;
}
// 使用
getLogger().log("hello");| 特性 | 全局对象 | Meyer's Singleton |
|---|---|---|
| 初始化时机 | main 之前 | 首次调用时(lazy) |
| 线程安全 | 依赖 guard 变量 | C++11 保证 |
| 析构顺序 | 不确定(跨 TU) | 不确定(但配合 nifty counter 可控) |
| 代码改动 | 无 | 所有 g_logger → getLogger() |
| 适用场景 | 不需要顺序保证的简单场景 | 大多数情况的首选 |
但有代价:每次调用都要走一次 __cxa_guard_acquire 检查(虽然很快,大约 1-2 个原子 load),热路径上要注意。
5.2 Nifty Counter(静态初始化引用计数)
让每个使用到全局对象的 .cpp 在 main 之前自动"声明依赖",确保全局对象在所有使用者之前构造。
cpp
// logger.h
#include <new>
struct Logger { /* ... */ };
extern Logger& getLogger();
// 每个 #include "logger.h" 的 .cpp 都生成一个 static initializer
static struct LoggerInitializer {
LoggerInitializer() { if (refCount++ == 0) new(&loggerStorage) Logger(); }
~LoggerInitializer() { if (--refCount == 0) ((Logger*)&loggerStorage)->~Logger(); }
static int refCount;
static char loggerStorage[sizeof(Logger)];
} _logger_init;
// logger.cpp
Logger& getLogger() { return *(Logger*)LoggerInitializer::loggerStorage; }原理:每个包含了 logger.h 的编译单元都会生成一个 _logger_init 对象(名字不同,因为有匿名命名空间或 static)。这些对象在各自 TU 的 .init_array 里,第一个碰到的 _logger_init 构造时构造真正的 Logger,最后一个析构时销毁它。
优点:完全自动化,使用者无需改代码。
缺点:每个 TU 都加一次 guard 检查;.so 场景下引用计数可能不准(静态链接 vs 动态链接各有问题)。
实际使用者:libstdc++ 用这个保护 std::cin / std::cout / std::cerr。<iostream> 里就是 nifty counter。
5.3 __attribute__((init_priority)) —— 编译单元级控制
GCC/Clang 支持给全局对象的构造器指定优先级:
cpp
// a.cpp
MyClass __attribute__((init_priority(101))) early_obj; // 数字小 → 先构造
// b.cpp
MyClass __attribute__((init_priority(65535))) late_obj; // 数字大 → 后构造| 优先级范围 | 用途 |
|---|---|
| 0 – 100 | 保留给系统/语言运行时(不要用) |
| 101 – 65535 | 用户可用,数字越小越先初始化 |
局限:
- 只影响同一个可执行文件/共享库内部的顺序
- 跨
.so边界不保证(各自有自己的优先级空间) - 不能解决"B 的析构依赖于 A"的问题——只有构造顺序可控,析构是构造的逆序,但跨 TU 仍不可靠
5.4 使用 atexit 显式注册析构顺序
如果必须用全局对象,可以用 atexit / std::atexit 显式控制清理顺序:
cpp
Logger g_logger;
ThreadPool g_pool;
// main() 开头注册清理顺序
int main() {
std::atexit([] { g_pool.shutdown(); }); // 先注册 → 后执行
std::atexit([] { g_logger.flush(); }); // 后注册 → 先执行
// 退出时:先 flush 日志,再关线程池
}atexit 是以 LIFO 顺序执行的——最后注册的最先调用,自然地形成依赖栈。
5.5 主动避免析构(允许泄漏)
对于进程生命周期内始终存在的对象,可以不析构、直接让 OS 在 _exit() 时回收内存:
cpp
Logger& getLogger() {
static Logger* instance = new Logger(); // 永远不 delete
return *instance;
}优点:绝对没有析构顺序问题,退出时速度最快。
缺点:
- Valgrind / ASan 报内存泄漏(需要 suppression 文件压制)
- 如果 Logger 构造时申请了文件句柄、信号量、共享内存等系统资源,OS 未必能自动回收
- 不适用于 RAII 管理非内存资源的场景(文件、锁、socket 等)
5.6 方案选型速查表

六、问题回答
Q1:同一个 .cpp 里多个全局对象的构造/析构顺序是怎样的?
→ 构造顺序 = 定义顺序;析构顺序 = 构造的逆序。C++ 标准保证。
Q2:不同 .cpp / .so 之间的构造顺序是怎样的?
→ 不确定(unspecified)。依赖链接器排列和动态链接器加载顺序,不可靠。
Q3:glibc 的 exit() 做了什么?
→ 先逆序遍历 atexit 栈执行用户回调和 __cxa_atexit 注册的析构函数,再遍历 .fini_array 执行 destructor 函数,然后 _IO_cleanup 刷 stdio 缓冲区,最后 _exit() 系统调用。
Q4:退出时最常见的坑有哪些?
→ 析构顺序溃败(A 的析构用到 B,但 B 先析构了);DSO 卸载后悬挂析构指针;fork+多线程+全局对象死锁。
Q5:工程上如何解决?
→ Meyer's Singleton 是首选;nifty counter 适用于 iostream 式场景;init_priority 只能管构造不管析构;允许泄漏是最激进的简单方案。
一句话总结:C++ 全局对象的构造顺序在 TU 内确定、TU 间随机;退出时 glibc 的 atexit → __cxa_finalize → .fini_array 链不能保证跨模块的有序清理,工程上要依赖 Meyer's Singleton 或显式生命周期管理来规避。