- 今天跑测试用例无意中发现的,懒得自己写,所以AI味儿很浓,勿怪
- 环境:WSL + Ubuntu 24.04 + gcc 13.3.0 + C++17(不确定是否跟WSL环境有关系)
// WaitGroup.hpp
void done()
{
--mCounter; // ① 原子递减(无锁)
mCond.notify_all(); // ② 通知(无锁)
}
void wait()
{
std::unique_lock<std::mutex> l(mMutex); // ③ 加锁
mCond.wait(l, [&] { // ④ 等待谓词
return mCounter <= 0;
});
}
具体逻辑路径(竞态时序)
时间线 主线程 (wait) 工作线程 (done)
────── ───────────── ──────────────
T1 wg->add(2); // mCounter=2
T2 启动 t1, t2
T3 wg->wait()
T4 l(mMutex) ← 获取 mMutex
T5 谓词检查: mCounter=2 > 0, 不满足
T6 --mCounter; // mCounter=1
T7 mCond.notify_all(); ← 关键!
T8 --mCounter; // mCounter=0
T9 mCond.notify_all();
T10 mCond.wait() ← 进入等待...
关键问题在 T7/T9 时刻:notify_all() 被调用时,主线程尚未真正进入 mCond.wait() 的内核等待队列。condition_variable::wait() 的内部执行分为三步:
- 释放 mMutex(unlock)
- 将自己加入等待队列(原子操作)
- 进入阻塞
只有步骤 2 完成后,notify_all() 才能唤醒等待线程。如果 notify_all() 在步骤 1 和步骤 2 之间发生(即主线程已释放 mMutex 但还未进入等待队列),这个通知就会丢失。
为什么在 Linux 上更容易出现
- pthread_cond_wait 的实现:glibc 的
pthread_cond_wait 在释放 mutex 后到进入内核等待队列之间有一个窗口期,在此期间 signal 会丢失。
- 线程调度差异:Linux 的调度器可能让工作线程在主线程释放 mutex 后、进入等待前迅速执行
done()。
- Windows 上更难触发:Windows 的
CONDITION_VARIABLE 实现中,SleepConditionVariableCS 在释放临界区前就已将线程加入等待队列,缩小了竞态窗口。
次要问题:mCounter 是 atomic<int> 但未与 mMutex 同步
std::mutex mMutex;
std::atomic<int> mCounter; // ← 用了 atomic,但 wait() 中的谓词检查在 mutex 保护下
std::condition_variable mCond;
add() 完全无锁,done() 也无锁。这意味着:
wait() 持有 mMutex 检查 mCounter,但 done() 不持有 mMutex 就修改 mCounter 并调用 notify_all()
- 条件变量的标准用法要求:修改共享状态和 notify 必须在同一个 mutex 保护下进行,或者至少保证 notify 不会在 wait 释放锁和进入等待之间丢失
修复方案
done() 应当在持锁状态下修改计数器并通知:
void done()
{
std::lock_guard<std::mutex> l(mMutex);
--mCounter;
mCond.notify_all();
}
add() 同样需要持锁(若 add 在 wait 之后调用,计数器会从 0 变为正数,但 wait 已经返回了):
void add(int i = 1)
{
std::lock_guard<std::mutex> l(mMutex);
mCounter += i;
}
总结
| 项目 |
说明 |
| 阻塞类型 |
信号丢失导致的永久阻塞 |
| 根本原因 |
done() 无锁修改 mCounter 并 notify_all(),而 wait() 在释放 mMutex 和进入条件变量等待之间存在竞态窗口,notify_all() 在此窗口内被调用会丢失 |
| 关键时序 |
主线程 wait() 释放 mMutex → 工作线程 done() 递减 + notify → 主线程进入等待(信号已丢失) |
| Linux 更易触发 |
glibc pthread_cond_wait 的 unlock→enqueue 非原子窗口较 Windows 更大 |
| 修复 |
done() 和 add() 必须在 mMutex 保护下操作,确保 notify 与 wait 的谓词检查互斥 |
具体逻辑路径(竞态时序)
关键问题在 T7/T9 时刻:
notify_all()被调用时,主线程尚未真正进入mCond.wait()的内核等待队列。condition_variable::wait()的内部执行分为三步:只有步骤 2 完成后,
notify_all()才能唤醒等待线程。如果notify_all()在步骤 1 和步骤 2 之间发生(即主线程已释放 mMutex 但还未进入等待队列),这个通知就会丢失。为什么在 Linux 上更容易出现
pthread_cond_wait在释放 mutex 后到进入内核等待队列之间有一个窗口期,在此期间 signal 会丢失。done()。CONDITION_VARIABLE实现中,SleepConditionVariableCS在释放临界区前就已将线程加入等待队列,缩小了竞态窗口。次要问题:
mCounter是atomic<int>但未与mMutex同步add()完全无锁,done()也无锁。这意味着:wait()持有mMutex检查mCounter,但done()不持有mMutex就修改mCounter并调用notify_all()修复方案
done()应当在持锁状态下修改计数器并通知:add()同样需要持锁(若 add 在 wait 之后调用,计数器会从 0 变为正数,但 wait 已经返回了):总结
done()无锁修改mCounter并notify_all(),而wait()在释放mMutex和进入条件变量等待之间存在竞态窗口,notify_all()在此窗口内被调用会丢失wait()释放 mMutex → 工作线程done()递减 + notify → 主线程进入等待(信号已丢失)pthread_cond_wait的 unlock→enqueue 非原子窗口较 Windows 更大done()和add()必须在mMutex保护下操作,确保 notify 与 wait 的谓词检查互斥