并发下锁的初始化:懒加载的鸡生蛋陷阱与 PTHREAD_MUTEX_INITIALIZER 范式
核心结论:在并发环境里"等到第一次用才去初始化一把锁"几乎总是错的——因为序列化这次初始化又需要另一把锁,于是陷入循环。正确做法是让锁在还没有并发的时刻(加载期 / 启动期)就准备好,或者用一个"本身不需要锁就能安全初始化"的原语来引导。
问题背景
先看一段真实的模块初始化代码(已简化),它想做的事很明确:让 jaguardb_lkv_init 在多线程并发调用时也只初始化一次。
int32_t jaguardb_lkv_init(void)
{
/* 第一个进来的线程负责初始化这把锁 */
if (atomic_inc_return(&g_lkvdb_init_status) == 1) {
dpax_spinlock_init(&g_lkvdb_init_status_lock);
}
while (!dpax_spinlock_trylock(&g_lkvdb_init_status_lock)) {
continue;
}
if (g_lkvdb_finit_status > 0) { /* 已初始化,幂等返回 */
dpax_spinlock_unlock(&g_lkvdb_init_status_lock);
return JAGUARDB_LKV_OK;
}
for (index = 0; index < moduleNum; index++) {
ret = g_lkvdb_module[index].intFunc();
if (ret != JAGUARDB_LKV_OK) {
do_lkvdb_module_exit(index);
atomic_dec(&g_lkvdb_init_status);
return ret; /* ← 持锁直接 return */
}
}
/* ... 共享内存恢复等 ... */
g_lkvdb_finit_status++;
dpax_spinlock_unlock(&g_lkvdb_init_status_lock);
return JAGUARDB_LKV_OK;
}
它的思路是:用一个原子计数器 g_lkvdb_init_status 选出"第一个"线程去 dpax_spinlock_init 初始化那把 g_lkvdb_init_status_lock,之后所有线程再用这把锁互斥。听上去合理——锁还没初始化,那就第一个来的人负责初始化它。
问题是,这把"用来保护初始化的锁"自己也需要先被初始化,而初始化它的过程同样暴露在并发之下。这段代码踩中的,正是并发初始化里最经典的陷阱。
鸡生蛋:锁不能保护自己的初始化
把上面那段"选举初始化者"的逻辑单独拎出来:
if (atomic_inc_return(&g_lkvdb_init_status) == 1) {
dpax_spinlock_init(&g_lkvdb_init_status_lock);
}
while (!dpax_spinlock_trylock(&g_lkvdb_init_status_lock)) {
continue;
}
这段选举是如何工作的? atomic_inc_return 给每个进来的线程发一个递增的号,拿到 1 的是"赢家",去初始化锁;其余是"输家",跳过初始化、直接去抢锁。拆开看它的并发时序:
- 线程 A 拿到
1,准备执行dpax_spinlock_init——但还没执行完。 - 线程 B 拿到
2,跳过初始化,立刻dpax_spinlock_trylock去抢一把还没初始化好的锁。
到这里就出事了:B 在 A 的 dpax_spinlock_init 完成之前就在操作这把锁的内存,读到的是未初始化的垃圾状态(owner / magic 字段),或者 A 的 init 又把 B 刚改的状态覆盖掉。atomic_inc_return 只保证了"唯一一个赢家去 init",但完全没保证输家会等到 init 完成。这是第一个、也是最根本的 bug。
第二个问题藏在内存序里。即便补上"输家等待 init 完成"的逻辑,在 ARM 这种弱内存模型(weak memory model,多核之间的读写可见顺序不保证和程序顺序一致)上还需要配对的内存屏障:赢家 spinlock_init 的那些写,必须 happens-before 它发布"已就绪"的信号(release 语义);输家看到"已就绪"后,对锁字段的读必须排在该信号之后(acquire 语义)。缺了这对 release/acquire,输家可能看到"已就绪"却读到锁的陈旧字段。
⚠️ 一个常见误区:
cmpxchg在 Linux 内核语义下确实自带 full barrier,容易让人以为内存序就齐了。但那个屏障挂在"抢初始化权"那一步、且在 init 之前,它管不到"init 的写"与"发布就绪"之间的顺序。屏障要加在 publish / consume 这对操作上才有用。
第三个问题是错误处理:拿到锁之后,模块初始化失败的分支直接 return ret,没有 dpax_spinlock_unlock。锁就此永久被占,下一次进来 while (!trylock) continue; 直接死循环。
退一步看,这三个 bug 的根源是同一个:你试图用一把锁来保护这把锁自己的初始化。可"保护初始化"需要一个已经就绪的同步原语,而这恰恰是你正要初始化的东西。这个循环依赖无法用"再加一把锁"打破——那把锁又由谁来初始化?
真正的分界线:eager vs lazy,而不是函数 vs 宏
很容易得出一个错误结论:"用函数(如 dpax_mtx_init)初始化就需要加锁互斥,用静态初始化宏就不用。"这个划分是错的。
真正决定"要不要 guard"的,不是"函数还是宏",而是初始化发生的时机:eager(提前)还是 lazy(延迟到首次使用)。
| 初始化方式 | 发生时机 | 有无并发 | 需要 guard 吗 |
|---|---|---|---|
| 静态初始化宏 | 加载时,由 loader 放好 | 单线程,0 并发 | 不需要 |
| 运行时函数,在启动期单线程调用 | 启动阶段 | 单线程,0 并发 | 同样不需要 |
| 运行时函数,首次使用时 lazy 调用 | 运行中,可能多线程命中 | 有并发 | 需要,但绝不是另一把锁 |
中间那行是关键:用函数初始化,只要也放在没有并发的时刻,就和静态初始化一样不需要任何 guard。静态初始化之所以"天生免疫",不是它有魔法,而是它被迫 eager——loader 在任何线程跑起来之前、单线程地把它放好,竞争窗口根本不存在。麻烦只在你坚持 lazy 时才出现。
那 PTHREAD_MUTEX_INITIALIZER 为什么能静态初始化? 看 glibc 里它的定义:
# define PTHREAD_MUTEX_INITIALIZER \
{ { 0, 0, 0, 0, 0, 0, { 0, 0 } } }
它就是把 pthread_mutex_t 内部结构体整体清零的一个花括号聚合初值。能静态初始化靠两件事叠加:
- 它是编译期常量聚合初值。C 规定静态存储期变量只能用常量表达式初始化;这个全零花括号在编译期就能算出,编译器直接把字节写进
.data(全零则落到.bss),loader 加载时放好,全程没有运行时代码。而pthread_mutex_init()是个函数调用,根本不能出现在静态初值里。 - 全零恰好是"已初始化、未上锁、默认类型"的状态。glibc 故意把内部布局设计成这样:
__lock = 0表示未上锁,__kind = 0表示默认普通锁,__owner / __count为 0,末尾的{ 0, 0 }是 robust mutex 链表指针为 NULL。因为这个状态正好是全零位模式,才能用纯零的常量来表达。
所以"换成静态初始化宏就没有 double-init 竞争"——根本原因是它把初始化挪到了 0 并发的加载时刻。
⚠️ 这串花括号的字段个数随架构 / glibc 版本变化,它和结构体定义在同一套头文件里同步维护。所以应该
#include <pthread.h>用PTHREAD_MUTEX_INITIALIZER这个宏名,而不是自己手抄那串零,否则一旦和实际布局错位就是未初始化 / 越界。
正确范式工具箱
按"能不能 eager"和"临界资源大小"分场景。
首选:能 eager 就 eager。 在保证单线程的启动点初始化——模块 init、子系统 boot,或用户态的 constructor(__attribute__((constructor)),在 main() 之前单线程运行,自包含且不需要显式 boot 钩子)。没有并发就没有竞争,最省心。静态初始化宏本身就属于这一类。
必须 lazy 时:用一个"自带静态初始化、本身不需要锁"的种子来引导。 这是打破鸡生蛋的唯一办法——不是再加一把锁,而是借一个 lock-free 且静态可初始化的原语:
-
pthread_once:种子是静态初始化的pthread_once_t(PTHREAD_ONCE_INIT),把真正的初始化放进回调,互斥 / 等待 / 内存序全由它内部处理。代价是回调返回void,带错误码和重试的初始化用着别扭。 -
静态 guard mutex + 锁内 sentinel 懒加载:用
PTHREAD_MUTEX_INITIALIZER静态初始化一把 guard 锁(它 eager,所以没有鸡生蛋),再用它在锁内懒加载真正的资源:static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; /* guard:静态初始化 */ static bool g_ready = false; /* sentinel */ void use_resource(void) { pthread_mutex_lock(&g_lock); if (!g_ready) { /* 判断必须在锁内 */ init_real_resource(); /* 只跑一次;失败保持 false 可重试 */ g_ready = true; } pthread_mutex_unlock(&g_lock); /* ... 使用资源 ... */ }关键认知:被懒加载的是资源,不是那把 guard 锁;guard 锁是 eager 的。等待者在锁上阻塞而非自旋,所以这种写法特别适合"初始化较长、可能睡眠"的场景。
-
手搓三态 CAS:当没有 once 原语、又必须全自包含时,用
ATOMIC64_INIT的原子状态机当 guard,配对smp_wmb / smp_rmb:#define UNINIT 0 #define INITING 1 #define DONE 2 static dpax_atomic64_t state = ATOMIC64_INIT(UNINIT); /* 静态种子 */ if (dpax_atomic64_cmpxchg(&state, UNINIT, INITING) == UNINIT) { /* cmpxchg 返回旧值 */ init_resource(); dpax_smp_wmb(); /* init 写 → 发布,配对屏障 */ dpax_atomic64_set(&state, DONE); } else { while (dpax_atomic64_read(&state) != DONE) /* 输家只等,绝不 reset */ dpax_cpu_relax(); dpax_smp_rmb(); }这里有两个原始 jaguardb 没做对的点被补上了:三态里的
INITING让后来者知道"有人正在跑、需要等待",而不是误判成"没初始化、我也来一遍"(这正是 double-init 的来源);以及smp_wmb / smp_rmb把初始化结果正确发布给后来者。⚠️ 千万别给输家加"自旋超时就把状态 reset 回 UNINIT"的逻辑。一旦赢家只是慢(没死),reset 会让第三个线程并发地再初始化一次同一个对象,直接 double-init。
init本身是微秒级操作,正常永远不会超时;这个 reset 防的是一个进程内并不存在的问题。
资源是单个字:根本不要锁。 如果临界资源就是一个能原子操作的字(比如限频器里的"上次执行时间戳"),直接用静态初始化的 dpax_atomic64_t + cmpxchg 把"读—判断—写"做成一次 CAS,连锁带初始化问题一起消失。这种情况下前面所有纠结都不存在。
应用:重写 jaguardb_lkv_init
jaguardb_lkv_init 的临界区是一整段多步初始化(遍历模块 + 共享内存恢复),不是单个字,没法用无锁 CAS。它需要"恰好一次 + 并发互斥 + 失败可重试",正好套用上面"静态 guard mutex + 锁内 sentinel"的范式。
因为可以直接用原生 pthread_mutex_t,guard 用 PTHREAD_MUTEX_INITIALIZER 静态初始化,绕开 dpax 没有静态宏的限制;又因为初始化可能长、会睡眠,用 mutex 而非 spinlock,让并发的后来者阻塞而不是空转。
static pthread_mutex_t g_lkvdb_init_lock = PTHREAD_MUTEX_INITIALIZER; /* eager guard */
static bool g_lkvdb_inited = false; /* sentinel */
/* 真正的多步初始化:不含任何锁逻辑,单一出口 */
static int32_t jaguardb_lkv_do_init(void)
{
int32_t ret;
uint32_t index, moduleNum = ARRAY_LEN(g_lkvdb_module);
dpax_pid_set_name(MY_PID, "JAGUARDB_LKV");
jaguardb_init_crc();
for (index = 0; index < moduleNum; index++) {
ret = g_lkvdb_module[index].intFunc();
if (ret != JAGUARDB_LKV_OK) {
do_lkvdb_module_exit(index);
jaguardb_err("Init module(%s) failed, ret(%d).",
g_lkvdb_module[index].moduleName, ret);
return ret;
}
}
LVOS_TP_NOPARAM_START(LKVDB_RECOVER_BEFORE)
LVOS_TP_END
ret = jaguardb_lkv_init_data();
if (ret != JAGUARDB_LKV_OK) {
return ret;
}
LVOS_TP_NOPARAM_START(LKVDB_RECOVER_AFTER)
LVOS_TP_END
return JAGUARDB_LKV_OK;
}
int32_t jaguardb_lkv_init(void)
{
int32_t ret;
pthread_mutex_lock(&g_lkvdb_init_lock); /* 锁恒已初始化,lock 永远安全 */
if (g_lkvdb_inited) { /* 幂等 */
ret = JAGUARDB_LKV_OK;
goto out_unlock;
}
ret = jaguardb_lkv_do_init();
if (ret == JAGUARDB_LKV_OK) {
g_lkvdb_inited = true; /* 仅成功置位;失败保持 false → 可重试 */
}
out_unlock:
pthread_mutex_unlock(&g_lkvdb_init_lock); /* 任何路径统一解锁 */
return ret;
}
对照原版,这一版消掉的问题:
atomic_inc_return + dpax_spinlock_init那套懒初始化锁全删,guard 改为静态初始化——“输家没等 init 完成”"缺屏障"的竞争从根上消失。goto out_unlock单一出口,原版 error 路径持锁 return 导致的死锁没了。g_lkvdb_init_status那个身兼"选举 + 计数"的双重计数器删掉,换成纯粹的g_lkvdb_inited:只在成功时置位,既幂等又允许失败重试。- spinlock 换 mutex,可安全地跨越可能睡眠的长初始化,后来者阻塞而非空转。
⚠️ 一个前提要确认:
PTHREAD_MUTEX_INITIALIZER初始化出来的是进程私有锁(PROCESS_PRIVATE)。如果多个进程真的在竞争初始化同一份共享内存里的状态,这把私有锁拦不住它们,得改用共享内存里PTHREAD_PROCESS_SHARED属性的运行时 init(甚至 robust mutex 以应对持锁进程猝死),静态宏就用不了了。
总结
懒加载一把锁的写法:
- 想让初始化只跑一次,于是用原子计数器选一个"赢家"去初始化锁。
- 但输家不会等赢家初始化完就去用这把锁;弱内存模型下还缺发布屏障;错误路径还容易漏解锁。
- 结果:用到未初始化的锁、读到陈旧状态、或者死锁——而这一切都是因为你在试图"用锁保护锁自己的初始化",这是个解不开的循环。
本文范式:
- 让需要的锁在没有并发的时刻就绪:能静态初始化(
PTHREAD_MUTEX_INITIALIZER)就静态初始化,或在单线程启动点 eager 初始化。 - 真要延迟初始化资源,就用一个本身静态可初始化的 lock-free 种子(
pthread_once/ 静态 guard mutex /ATOMIC64_INIT原子)来引导,在它的保护下懒加载资源,绝不再叠一把锁。 - 资源若是单个字,直接 CAS、连锁都不要。
- 结果:没有鸡生蛋、没有 double-init、没有屏障漏配、没有死锁。
核心思想:不要懒加载锁本身——把锁的初始化推到 0 并发的时刻,或用一个不需要锁就能安全初始化的原语作为引导种子;一切正确方案最终都 bottom out 到这样一颗静态种子上。
(本文聚焦"锁的初始化"。文中"单个字直接用 CAS"那一点,在限频器一类场景里可以完全替代锁,值得单独成文展开。)