摘要:本文逐行解析 musl libc 中exit()函数的源码,揭示其如何用极简的原子操作解决并发退出和递归调用两大难题,以及.fini_array倒序调用的设计意图。
一、为什么要看 exit() 的实现?
exit()是每个 C 程序的终点,但很少有人关心它内部做了什么。标准只规定了行为,没规定实现。而 musl libc 的实现只有不到 60 行代码,却处理了:
- 多线程同时调用 exit 的竞态
- 递归调用 exit 的未定义行为
.fini_array中所有析构函数的倒序调用- 与 atexit / stdio 退出钩子的协作
看完这段代码,你会对"简单接口背后的复杂工程"有新的理解。
二、weak_alias:默认什么都不做
static void dummy() { } weak_alias(dummy, __funcs_on_exit); weak_alias(dummy, __stdio_exit); weak_alias(dummy, _fini);weak_alias是 musl 的弱符号别名机制。意思是:如果别的模块定义了同名强符号,就用那个;否则就用这个 dummy。
所以默认情况下:
__funcs_on_exit()→ 什么都不做__stdio_exit()→ 什么都不做_fini()→ 什么都不做
而atexit.c和__stdio_exit.c会分别覆盖这些弱符号,注入真正的退出钩子。这种设计让核心库保持零依赖,按需链接。
三、__libc_exit_fini:倒序遍历 .fini_array
extern weak hidden void (*const __fini_array_start)(void), (*const __fini_array_end)(void); static void libc_exit_fini(void) { uintptr_t a = (uintptr_t)&__fini_array_end; for (; a > (uintptr_t)&__fini_array_start; a -= sizeof(void(*)())) (*(void (**)())(a - sizeof(void(*)())))(); _fini(); }这是整段代码中最"反直觉"的部分。
.fini_array是什么?
编译时,每个有全局析构函数的目标文件会把函数指针放进.fini_array段。链接器把所有段拼在一起,形成从__fini_array_start到__fini_array_end的连续数组。
为什么要倒序遍历?
因为 C++ 保证:构造函数正序调用,析构函数倒序调用(栈的 LIFO 语义)。musl 严格遵守这个约定:
地址高 ← __fini_array_end [最后注册的析构函数] [ ... ] 地址低 ← __fini_array_start [最先注册的析构函数]循环从end往start走,等价于从后往前调用,完全符合语义。
最后单独调用_fini(),这是另一个弱符号,通常由 crt 文件实现,处理 C 层面的清理。
四、exit() 核心:用一个 int 解决并发和递归
_Noreturn void exit(int code) { static volatile int exit_lock[1]; int tid = __pthread_self()->tid; int prev = a_cas(exit_lock, 0, tid); if (prev == tid) a_crash(); // 递归调用 → 直接崩溃 else if (prev) for (;;) __sys_pause(); // 有别人在退 → 无限等待 __funcs_on_exit(); __libc_exit_fini(); __stdio_exit(); _Exit(code); }这 10 行是整段代码的精髓。
4.1 一个全局锁,不用 mutex
static volatile int exit_lock[1];不是 pthread_mutex_t,就是一个普通 int。为什么?
因为 exit 场景下:
- 不需要可重入
- 不需要等待超时
- 只需要"谁先抢到谁走,其他人等着"
用原子 CAS 足够了,而且零依赖,不用拖入整个锁实现。
4.2 CAS 抢锁
int prev = a_cas(exit_lock, 0, tid);prev == 0:锁空闲,当前线程抢到了(exit_lock现在存的是tid)prev == tid:当前线程之前已经抢过了 →递归调用→ 标准说这是未定义行为,musl 选择直接a_crash()prev != 0 && prev != tid:别的线程抢到了 → 当前线程进入等待
4.3 等待策略:不是 sleep,是 pause
else if (prev) for (;;) __sys_pause();__sys_pause()是sched_yield/pause系统调用,让出 CPU,避免忙等。
这个设计的潜台词是:只有第一个调用 exit 的线程会真正执行清理逻辑,其他线程只是"等着被杀"。
这完全合理——进程都要退出了,多个线程同时跑清理逻辑只会造成竞争和未定义行为。
五、退出顺序:一张图看清
exit(code) │ ├─ 抢锁(CAS)──→ 失败?pause 等待 │ ├─ __funcs_on_exit() ← atexit 注册的函数(由 atexit.c 覆盖 weak_alias) │ ├─ __libc_exit_fini() ← 倒序调用 .fini_array + _fini() │ ├─ __stdio_exit() ← 刷新/关闭 stdio(由 __stdio_exit.c 覆盖) │ └─ _Exit(code) ← 系统调用,真正结束进程注意:最后调用的是_Exit而不是exit。_Exit不会再触发任何钩子,这是终点。
六、几个值得思考的点
| 问题 | musl 的选择 | 对比 glibc |
|---|---|---|
| 退出锁 | 手动 CAS,无依赖 | 用内部锁机制 |
| 递归 exit | 直接 crash | 未定义,表现不一 |
| 多线程 exit | 只有一个线程执行清理,其余 pause | 类似,但实现更重 |
| .fini_array 遍历 | 指针运算,倒序 | 类似,但 glibc 代码更多 |
musl 的哲学很清晰:能用 10 行解决的,绝不写 100 行。
七、总结
这段exit()实现给我最大的启发是:
好的底层代码不是"什么都处理",而是"精确地只处理该处理的"。
- 并发退出 → 一个 CAS 够了
- 递归退出 → crash 比假装没事更诚实
- 析构顺序 → 倒序遍历指针数组,零抽象成本
- 模块协作 → weak_alias 让默认实现为空,按需覆盖
如果你在写需要高可靠退出的程序(比如服务端 daemon),这段代码值得反复读。
参考:musl libc 1.2.5 src/exit.c
原创不易,转载请注明出处。