外观
Stage07|Modern C++ Foundation
Lesson091|Thread、Mutex、Condition Variable
Tags: #C++17 #Thread #Mutex #ConditionVariableDifficulty: ⭐⭐⭐⭐⭐
一、问题场景:队列空时为什么 CPU 仍然满载
cpp
while (!stopped) {
if (!jobs.empty()) run(jobs.front());
}这是忙等:没有任务仍持续占用 CPU,而且对 stopped 和 jobs 的无同步访问还会产生数据竞态。正确的工作线程需要互斥保护状态,并用条件变量在状态未满足时睡眠。
二、本课目标
- 理解线程、共享状态和 happens-before 的基本关系。
- 用 mutex 建立互斥访问。
- 用 lock_guard/unique_lock 管理锁。
- 正确使用 condition_variable 的谓词等待。
- 设计可停止、可 join 的线程生命周期。
三、回到 TaskSystem:为什么需要 worker
前面的 TaskSystem 只能由调用线程同步执行。现在增加 worker 后,问题从“任务怎么写”变成“共享队列如何保护、空闲线程如何等待、停止时谁拥有最后一个任务”。并发设计的顺序应是:先定义状态和 owner,再选择 mutex/condition_variable,最后才讨论线程数量。
四、线程不是自动并行加速
创建线程会带来栈、调度、同步、上下文切换和缓存一致性成本。适合后台执行的任务通常具有足够粒度,且不直接操作只允许主线程访问的引擎对象。
cpp
std::thread worker([] {
// 后台任务
});
worker.join();std::thread 析构时若仍 joinable 会调用 std::terminate。必须明确 join、detach 或使用更高层线程管理器。一般不应随意 detach,因为生命周期和错误无法收束。
五、Mutex 保护不变量
cpp
std::mutex mutex;
std::queue<Job> jobs;
void push(Job job) {
std::lock_guard<std::mutex> lock(mutex);
jobs.push(std::move(job));
}锁保护的是一组共享状态不变量,不只是某一行。所有访问者必须遵守同一协议;只有写入加锁而读取不加锁仍然是数据竞态。
六、Condition Variable
cpp
std::condition_variable cv;
bool stopping = false;
void workerLoop() {
for (;;) {
Job job;
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [] { return stopping || !jobs.empty(); });
if (stopping && jobs.empty()) return;
job = std::move(jobs.front());
jobs.pop();
}
job();
}
}必须使用谓词,因为条件变量允许伪唤醒,也可能在开始等待前已发生通知。真正的事实在受锁保护的状态中,通知只是提醒重新检查。
七、缩小锁范围
从队列取出任务后先解锁再执行,否则一个慢任务会阻止生产者入队。锁内只维护共享状态,耗时 IO、回调和未知用户代码应尽量在锁外执行。
八、停止协议
cpp
void stop() {
{
std::lock_guard<std::mutex> lock(mutex);
stopping = true;
}
cv.notify_all();
if (worker.joinable()) worker.join();
}还要定义停止语义:处理完已入队任务,还是立即丢弃?stopping && jobs.empty() 表达“排空后退出”。立即停止则应在锁内清队列并让 worker 优先退出。
九、可编译验证:生产者消费者
实现一个线程安全队列,主线程推入 1000 个递增整数,两个 worker 累加结果。最终结果应等于 1000 * 1001 / 2。重复运行 100 次,并分别测试空队列等待、执行中 stop、重复 stop。
编译:
bash
g++ -std=c++17 -O2 -pthread queue_lab.cpp -o queue_lab十、TaskSystem 的并发状态机
推荐状态是 Running → Stopping → Stopped。Running 允许提交,Stopping 拒绝新任务但可以排空旧任务,Stopped 不再拥有可执行工作。条件变量只负责唤醒,真正决定行为的是 mutex 保护的 stopping || !tasks.empty() 谓词。
十一、死锁诊断
text
线程卡住 → 获取所有线程堆栈
多个线程各持一把锁等待另一把 → 锁顺序环
回调在持锁期间进入外部代码 → 重入风险
忘记 notify → 状态已满足但等待者未醒
谓词读写未用同一 mutex → 丢失同步协议多个锁规定全局顺序,或使用 std::scoped_lock 同时获取。
十二、Cocos/Native 边界
后台线程不能直接访问多数 Scene、Node、Renderer 或脚本引擎对象。后台线程只处理独立数据,把不可变结果投递到主线程;主线程再检查页面/session 是否有效并更新 Cocos 对象。
十三、练习与答案
1. notify 是否需要持锁?
通常先在锁内修改状态,解锁后 notify,以减少被唤醒线程立刻再次阻塞;正确性来自状态和 mutex,而非 notify 本身。
2. 为什么 wait 使用 unique_lock?
wait 需要原子地释放锁并睡眠,醒来后重新加锁;unique_lock 支持这种可解锁操作。
3. detach 有什么风险?
线程可能访问已销毁 owner、进程退出时无法收束、异常与完成状态无人处理。
十四、本课总结
线程安全不是“加一把锁”,而是定义共享状态、访问协议、等待条件和停止生命周期。条件变量等待状态变化,mutex 保护状态事实。
