我并不是要背 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_startbreak); - 新批次 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()决定是否继续等待。