从 `std::mutex` 到 futex:我如何用源码真正理解互斥锁的底层逻辑

9 阅读5分钟

我想搞清楚的是互斥锁到底靠什么保证互斥?竞争时如何睡眠?为什么不会丢唤醒?多等待者如何接力?

我准备了三套源码: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 的理解是一条完整链路:

  1. C++ 的 std::mutex::lock() 并不实现锁算法,只是转调到 __gthread_mutex_lock,POSIX 下最终是 pthread。
  2. glibc 用一个 int 状态字(futex word)实现 mutex 的用户态协议:0 空闲、1 无竞争占用、2(>1) 竞争模式占用。
  3. 快路径:CAS(0→1) 成功直接拿锁,不进内核;解锁 1→0 也不 wake。
  4. 慢路径:抢不到锁就把状态推到 2,然后 futex_wait(expected=2) 睡眠;醒来后继续循环,用 exchange(0→2) 真正抢锁。
  5. 解锁时先 exchange(...,0) 释放锁,再根据旧值是否 >1 来 futex_wake(1) 唤醒一个等待者,形成接力。
  6. 快路径/慢路径的本质差别:是否需要走 futex 进入内核进行阻塞/唤醒;核心正确性来自“用户态状态机 + futex 的 expected 等待语义”。