我想搞清楚的是互斥锁到底靠什么保证互斥?竞争时如何睡眠?为什么不会丢唤醒?多等待者如何接力?
我准备了三套源码:libstdc++ / glibc(NPTL) / Linux 内核。因为链路跨三层:C++ 标准库只是入口,真正的用户态协议在 glibc,阻塞/唤醒队列机制在内核 futex。
1)C++ 层:std::mutex 只是薄封装,把活交给 gthread/pthread
我先从 C++ 的 std::mutex::lock() 看起。它没有“锁算法”,就是直接转调:
// libstdc++: bits/std_mutex.h(节选)
class mutex : private __mutex_base
{
public:
void lock()
{
int __e = __gthread_mutex_lock(&_M_mutex);
if (__e)
__throw_system_error(__e);
}
void unlock()
{
__gthread_mutex_unlock(&_M_mutex);
}
};
所以第一层结论:
std::mutex本质把调用交给__gthread_mutex_lock,也就是平台线程库(POSIX 下是 pthread)。
2)glibc 关键:用户态“状态字 + 原子操作协议”才是互斥锁本体
真正让我理解互斥锁的,是 glibc 的 low-level lock(lll_lock)注释和实现。
2.1 三态模型:0/1/>1(2)
glibc 把锁压缩成一个 int 状态字(futex word),并定义了三态语义:
/* glibc: sysdeps/nptl/lowlevellock.h(节选)
A lock can be in one of three states:
0: not acquired,
1: acquired with no waiters;
>1: acquired, possibly with waiters;
*/
我对这三态的理解:
- 0:空闲
- 1:占用但认为“没人等”(无竞争快路径)
- 2(>1):占用且“可能有人等”(竞争模式标记)
注意:2 不是计数器,它表达的是“可能存在等待者/需要保守唤醒”。
2.2 快路径:CAS(0→1) 成功直接拿锁,不进内核
快路径就在 lowlevellock 的宏里,核心是一次 CAS:
// glibc: sysdeps/nptl/lowlevellock.h(节选)
#define __lll_lock(futex, private) \
((void) \
({ \
int *__futex = (futex); \
if (__glibc_unlikely \
(atomic_compare_and_exchange_bool_acq (__futex, 1, 0))) \
{ \
/* CAS 失败才会走慢路径 */ \
if (__builtin_constant_p (private) && (private) == LLL_PRIVATE)\
__lll_lock_wait_private (__futex); \
else \
__lll_lock_wait (__futex, private); \
} \
}))
这里我只盯住一个事实:
- CAS 0→1 成功:直接返回(纯用户态,不 syscall)
- CAS 失败:说明锁被占,进入慢路径(可能会 futex_wait)
2.3 慢路径:把状态推到 2,然后 futex_wait(expected=2) 睡眠
慢路径真正的“等待循环”在 lowlevellock.c,我认为这是理解“被唤醒后到底干啥”的关键:
// glibc: nptl/lowlevellock.c(节选)
void __lll_lock_wait (int *futex, int private)
{
if (atomic_load_relaxed (futex) == 2)
goto futex;
while (atomic_exchange_acquire (futex, 2) != 0)
{
futex:
futex_wait ((unsigned int *) futex, 2, private); /* Wait if *futex == 2. */
}
}
这段我当时卡了很久,最后我用推演把它想通:
atomic_exchange_acquire(futex, 2)的意思是:我每次都试图把锁状态写成 2。- 只有当 exchange 读到旧值是 0(也就是把 0 换成了 2),才算抢到锁并退出循环。
- 如果旧值不是 0(1 或 2),说明别人持有锁,我就去
futex_wait(expected=2)睡眠。
被唤醒不等于拿锁。唤醒只是让线程从 futex_wait 返回,然后继续跑 while,再靠 exchange 去真正抢锁。
3)解锁:先置 0,再根据旧值是否 >1 决定唤醒
我问过“释放锁是不是先把状态改成 0,再唤醒?”——答案是对的,且顺序很重要。
// glibc: sysdeps/nptl/lowlevellock.h(节选)
#define __lll_unlock(futex, private) \
((void) \
({ \
int *__futex = (futex); \
int __private = (private); \
int __oldval = atomic_exchange_release (__futex, 0); \
if (__glibc_unlikely (__oldval > 1)) \
{ \
if (__builtin_constant_p (private) && (private) == LLL_PRIVATE) \
__lll_lock_wake_private (__futex); \
else \
__lll_lock_wake (__futex, __private); \
} \
}))
这里我只记住逻辑:
exchange(...,0):真正释放锁oldval > 1才 wake:说明进入过竞争模式,可能有人睡着,需要唤醒一个- oldval == 1:纯无竞争,解锁不进内核
唤醒函数最终也是 futex_wake:
// glibc: nptl/lowlevellock.c(节选)
void __lll_lock_wake (int *futex, int private)
{
lll_futex_wake (futex, 1, private);
}
4)我最关键的疑问:队列里不止一个线程怎么办?谁来保证状态一直是 2?
我当时的疑问是:
如果等待队列里不止一个线程,被唤醒的线程抢到锁后应该把状态设为多少?
它怎么知道队列里还有没有线程在睡?
我最后想通的点是:
- 被唤醒这一刻不会修改状态字(wake 不改用户态状态)
- 真正改状态发生在它醒来后执行 while 条件:
atomic_exchange_acquire(futex, 2) - 它抢到锁时会把 0 改成 2,并保持 2(竞争模式),这让后续释放者继续
oldval>1 → wake(1),形成接力
所以答案是:
线程不需要知道队列还有几个人;只要锁保持在 2(可能有等待者),释放者就会保守地 wake(1),剩余等待者就不会永远睡死。就算已经没人等了,多 wake 一次也不会出错(最多唤醒 0 个)。
5)我自己的最终总结
我现在对 mutex 的理解是一条完整链路:
- C++ 的
std::mutex::lock()并不实现锁算法,只是转调到__gthread_mutex_lock,POSIX 下最终是 pthread。 - glibc 用一个 int 状态字(futex word)实现 mutex 的用户态协议:0 空闲、1 无竞争占用、2(>1) 竞争模式占用。
- 快路径:CAS(0→1) 成功直接拿锁,不进内核;解锁 1→0 也不 wake。
- 慢路径:抢不到锁就把状态推到 2,然后
futex_wait(expected=2)睡眠;醒来后继续循环,用exchange(0→2)真正抢锁。 - 解锁时先
exchange(...,0)释放锁,再根据旧值是否 >1 来futex_wake(1)唤醒一个等待者,形成接力。 - 快路径/慢路径的本质差别:是否需要走 futex 进入内核进行阻塞/唤醒;核心正确性来自“用户态状态机 + futex 的 expected 等待语义”。