并发下锁的初始化问题

作者:CherryYang 发布时间: 2026-06-16 阅读量:7 评论数:0

并发下锁的初始化:懒加载的鸡生蛋陷阱与 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 的是"赢家",去初始化锁;其余是"输家",跳过初始化、直接去抢锁。拆开看它的并发时序:

  1. 线程 A 拿到 1,准备执行 dpax_spinlock_init——但还没执行完。
  2. 线程 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 内部结构体整体清零的一个花括号聚合初值。能静态初始化靠两件事叠加:

  1. 它是编译期常量聚合初值。C 规定静态存储期变量只能用常量表达式初始化;这个全零花括号在编译期就能算出,编译器直接把字节写进 .data(全零则落到 .bss),loader 加载时放好,全程没有运行时代码。而 pthread_mutex_init() 是个函数调用,根本不能出现在静态初值里。
  2. 全零恰好是"已初始化、未上锁、默认类型"的状态。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_tPTHREAD_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 以应对持锁进程猝死),静态宏就用不了了。

总结

懒加载一把锁的写法:

  1. 想让初始化只跑一次,于是用原子计数器选一个"赢家"去初始化锁。
  2. 但输家不会等赢家初始化完就去用这把锁;弱内存模型下还缺发布屏障;错误路径还容易漏解锁。
  3. 结果:用到未初始化的锁、读到陈旧状态、或者死锁——而这一切都是因为你在试图"用锁保护锁自己的初始化",这是个解不开的循环。

本文范式:

  1. 让需要的锁在没有并发的时刻就绪:能静态初始化(PTHREAD_MUTEX_INITIALIZER)就静态初始化,或在单线程启动点 eager 初始化。
  2. 真要延迟初始化资源,就用一个本身静态可初始化的 lock-free 种子(pthread_once / 静态 guard mutex / ATOMIC64_INIT 原子)来引导,在它的保护下懒加载资源,绝不再叠一把锁。
  3. 资源若是单个字,直接 CAS、连锁都不要。
  4. 结果:没有鸡生蛋、没有 double-init、没有屏障漏配、没有死锁。

核心思想:不要懒加载锁本身——把锁的初始化推到 0 并发的时刻,或用一个不需要锁就能安全初始化的原语作为引导种子;一切正确方案最终都 bottom out 到这样一颗静态种子上。


(本文聚焦"锁的初始化"。文中"单个字直接用 CAS"那一点,在限频器一类场景里可以完全替代锁,值得单独成文展开。)

评论