从 `std::condition_variable` 到 futex:我如何用源码真正理解条件变量(condvar)

3 阅读7分钟

我并不是要背 API,我要通过源码把“条件变量到底是怎么保证语义”的底层逻辑捋清楚。学习路径仍然是三层链路:libstdc++ → glibc(pthread) → futex syscall(Linux 内核)

核心问题:

  • 为什么 wait 必须配合 mutex?
  • 为什么一定要写 while (predicate)
  • futex 不 FIFO,condvar 是怎么保证“不丢信号”的?
  • wait 到底在等什么地址?signal/broadcast 到底投放的是什么?
  • 我写的那把 mutex 在 wait 内部是什么时候解锁、什么时候重新加锁?

1)C++ 层:condition_variable 的语义(while 谓词)与委托

1.1 谓词版本为啥是 while?

libstdc++ 的头文件实现里,谓词版本就是 while 循环。醒来不代表条件成立,所以必须循环检查共享状态(在 mutex 保护下)。

// libstdc++: include/std/condition_variable(节选)
template<typename _Predicate>
void wait(unique_lock<mutex>& __lock, _Predicate __p)
{
  while (!__p())
    wait(__lock);
}

1.2 condition_variable::wait/notify_* 本质是委托

真正执行的工作交给 _M_cond(下一层):

// libstdc++: src/c++11/condition_variable.cc(节选)
void condition_variable::wait(unique_lock<mutex>& __lock)
{
  _M_cond.wait(*__lock.mutex());
}

void condition_variable::notify_one() noexcept
{
  _M_cond.notify_one();
}

void condition_variable::notify_all() noexcept
{
  _M_cond.notify_all();
}

1.3 _M_cond → gthread → pthread_cond_*

libstdc++ 的内部 __condvar 封装,POSIX 下映射到 pthread:

// libstdc++: include/bits/std_mutex.h(节选)
class __condvar
{
public:
  void wait(mutex& __m)
  {
    int __e = __gthread_cond_wait(&_M_cond, __m.native_handle());
    __glibcxx_assert(__e == 0);
  }

  void notify_one() noexcept
  {
    int __e = __gthread_cond_signal(&_M_cond);
    __glibcxx_assert(__e == 0);
  }

  void notify_all() noexcept
  {
    int __e = __gthread_cond_broadcast(&_M_cond);
    __glibcxx_assert(__e == 0);
  }
};

结论:C++ 层只提供语义(尤其是 while 谓词)和调用路径;“如何等待/唤醒”的算法在 glibc。


2)condvar 的职责(它不是“条件”,它是“等待/通知通道”)

  • 条件本身由共享状态(predicate)表达;检查必须在同一把 mutex 保护下完成;
  • condvar 的职责是:让线程在条件不满足时能释放 mutex 并阻塞;条件可能满足时由其他线程通知唤醒,再去重新持有 mutex并检查 predicate。

因此典型模式才是:

std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return predicate; }); // while-predicate 语义

3)glibc 的关键思想:不是“一把 futex”,而是“序列号 + 两组(G1/G2) + 每组一个 futex word”

futex 唤醒不保证 FIFO,只有 32bit 字,容易 ABA。condvar 的语义要求 signal/broadcast 在并发下仍然正确(不丢进展),所以 glibc 采用了更强的用户态协议:

  • 64bit waiter 序列号 __wseq(每个 waiter 发号)
  • 两组槽位 G1/G2(每组一个 futex word:__g_signals[0/1]
  • g1_start(当前 G1 覆盖区间的起点边界,切换时推进)

这套协议保证:谁有资格消费 signal(而不是靠唤醒顺序),从而规避 futex 非 FIFO 的问题。


4)pthread_cond_wait:先登记 waiter,再解锁用户 mutex,再决定睡不睡

这是避免丢信号的关键顺序。节选如下(删去无关错误处理):

// glibc: nptl/pthread_cond_wait.c(节选)

/* 获取 waiter 序列号 wseq(每次 +2);最低位是当前 G2 槽位 */
uint64_t wseq = __condvar_fetch_add_wseq_acquire (cond, 2);
unsigned int g = wseq & 1;    // 当前绑定到的 G2 槽位(0/1)
uint64_t seq = wseq >> 1;     // 我在 waiter 序列中的序号

/* waiter 引用计数 +1(用于销毁/生命周期等) */
unsigned int flags = atomic_fetch_add_relaxed (&cond->__data.__wrefs, 8);
int private = __condvar_get_private (flags);

/* 关键点:先登记,再解锁这把 mutex(避免丢信号) */
err = __pthread_mutex_unlock_usercnt (mutex, 0);
if (err != 0) { /* 取消等待等清理路径 */ }

这正是你关心的那点:

  • std::unique_lock<std::mutex> lk(m); 开始一直是同一把 m
  • 只有在完成“登记为 waiter”(发号/计数)之后,才解锁这把用户 mutex;中间没有换锁也没有提前解锁

5)waiter 在等什么?不是等“cond 对象”,而是等 __g_signals[g] 这个地址

进入等待循环后,waiter 的策略是:先尝试消费信号(纯用户态 CAS),消费不到才 futex_wait

// glibc: nptl/pthread_cond_wait.c(节选)

while (1)
{
  unsigned int signals
    = atomic_load_acquire (cond->__data.__g_signals + g);
  uint64_t g1_start = __condvar_load_g1_start_relaxed (cond);

  /* A) “关闭组”判断:若 seq < g1_start,则不应继续睡在旧地址 */
  if (seq < g1_start)
    break;

  /* B) 若存在可消费信号,CAS 消费一个并返回(消费失败就重试) */
  if ((int)(signals - (unsigned int)g1_start) > 0)
  {
    if (atomic_compare_exchange_weak_acquire (
          cond->__data.__g_signals + g,
          &signals, signals - 1))
      break;
    else
      continue;
  }

  /* C) 没信号才真正阻塞:在 __g_signals[g] 这个地址上 futex 等待 */
  err = __futex_abstimed_wait_cancelable64(
          cond->__data.__g_signals + g, // uaddr
          signals,                      // expected
          clockid, abstime, private);
}

要点:

  • __g_signals[g] 即是 futex word 地址,也是 signal 投放信号的目标;
  • seq < g1_start 表示我属于“已关闭的旧批次”,不应再睡在旧地址,应该结束 wait 阶段并回去抢 mutex(符合 condvar 虚假唤醒语义)。

6)pthread_cond_signal:投放信号 + 唤醒睡在“当前 G1 槽位地址”的一个 waiter

signal 的核心逻辑(删去无关部分):

// glibc: nptl/pthread_cond_signal.c(节选)

unsigned int wrefs = atomic_load_relaxed (&cond->__data.__wrefs);
if (wrefs >> 3 == 0) return 0;

int private = __condvar_get_private (wrefs);
__condvar_acquire_lock (cond, private);

unsigned long long int wseq = __condvar_load_wseq_relaxed (cond);
unsigned int g1 = (wseq & 1) ^ 1; // 当前 G1 槽位(与 G2 槽位相反)
wseq >>= 1;

bool do_futex_wake = false;

if ((cond->__data.__g_size[g1] != 0)
    || __condvar_switch_g1 (cond, wseq, &g1, private))
{
  atomic_fetch_add_relaxed (cond->__data.__g_signals + g1, 1); // 投放信号
  cond->__data.__g_size[g1]--;
  do_futex_wake = true;
}

__condvar_release_lock (cond, private);

if (do_futex_wake)
  futex_wake (cond->__data.__g_signals + g1, 1, private); // 唤醒一个

要点:

  • signal 不保证 FIFO,它只保证“让某个 waiter 有机会醒来”;真正能否返回由 waiter 的 CAS(消费信号)决定;
  • condvar 的资格判定由 G1/G2 + g1_start 维持,不依赖唤醒顺序。

7)pthread_cond_broadcast:对两组都投足够信号并 wake 全部

逻辑是“对旧 G1 全发 → 切换 → 对新 G1 全发”,然后 wake(INT_MAX) 一次性唤醒所有在对应地址上睡着的 waiter。此处略代码(原理与上面一致)。


8)g1_start 怎么更新?只在“组切换 __condvar_switch_g1”时推进

这就是“关闭组”的落地:推进边界,发布新 G2 槽位,初始化新 G1 的 __g_signals

// glibc: nptl/pthread_cond_common.c(节选)

/* 关闭当前 G1:__g1_start += old_orig_size */
__condvar_add_g1_start_relaxed (cond, old_orig_size);

/* 发布组切换:翻转 __wseq 的最低位,宣告新的 G2 槽位 */
wseq = __condvar_fetch_xor_wseq_release (cond, 1) >> 1;
g1 ^= 1;
*g1index ^= 1;

/* 初始化新 G1 的 g_signals 到 new_g1_start(有效可消费信号为 0) */
atomic_store_release (cond->__data.__g_signals + g1, (unsigned)new_g1_start);

这确保:

  • 被切走的旧批次 waiter 不再阻塞在旧地址(seq < g1_start break);
  • 新批次 waiter 在新槽位作为 G2 等待,必要时切 G1 继续投信号。

9)futex 系统调用:只要真的需要阻塞,就一定进入内核

wait 真阻塞那一步就是 futex syscall,常用 FUTEX_WAIT_BITSET(支持绝对超时):

// glibc: nptl/futex-internal.c(节选)
int __futex_abstimed_wait_cancelable64 (unsigned int* futex_word,
                                        unsigned int expected,
                                        clockid_t clockid,
                                        const struct __timespec64* abstime,
                                        int private)
{
  // 构造 op(包含 FUTEX_WAIT_BITSET 与 private/clock 位)
  // 最终发起 INTERNAL_SYSCALL_CANCEL(futex_time64, ...)
  ...
}

结论:不一定“每次都 syscall”(如果能消费到信号就不用阻塞);但只要真的需要等待阻塞,就一定会进入内核 futex


10)我最后的总结

  • std::condition_variable 本身不实现等待算法;谓词版本使用 while (pred),醒来后必须在同一把 mutex 保护下再次检查 predicate。
  • glibc 的 condvar 用“序列号 wseq + 两组(G1/G2) + 边界 g1_start + 两个 futex word __g_signals[2]”的用户态协议,在 futex 非 FIFO 的现实下仍能保证 signal/broadcast 的语义正确、不丢进展。
  • waiter 的关键顺序是:先登记为 waiter(拿号/计数)→ 再解锁这把用户 mutex;随后循环“消费信号(CAS)/futex 等待”。这恰恰是为避免丢信号而设计的。
  • signal 优先给当前 G1 投放信号;必要时切换(推进 g1_start 关闭旧批次、翻转 wseq 发布新 G2槽位);然后对对应 __g_signals[g1]futex_wake
  • 不保证 FIFO;“可控”的关键是:返回前重新持有同一把 mutex,由上层 while (!pred) wait() 决定是否继续等待。