Skip to content

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 的无同步访问还会产生数据竞态。正确的工作线程需要互斥保护状态,并用条件变量在状态未满足时睡眠。

二、本课目标 ​

  1. 理解线程、共享状态和 happens-before 的基本关系。
  2. 用 mutex 建立互斥访问。
  3. 用 lock_guard/unique_lock 管理锁。
  4. 正确使用 condition_variable 的谓词等待。
  5. 设计可停止、可 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 保护状态事实。