C++ / a working model

99 / 163   ·   C++20   ·   约 13 分钟

条件变量等待的是状态,不是一次通知

先记住这句话

一次性计算交接需要同时证明状态可见、等待不丢失以及工作线程不会越过对象寿命。用互斥量保护谓词,再用 C++20 jthread 管理结束,通知早到或晚到都不会改变答案。

本篇内容
  1. 把完成事实留在状态里
  2. 唤醒不等于条件成立
  3. 等待完成与对象存活分别证明
  4. 运行示例
  5. 动手练习
READING EVIDENCE / 已读完整正文

C++ Concurrency in Action

完整阅读 2012 年第一版的第 1–10 章、附录 A–D(正文书页 1–486);另读第二版未登录预览的第 1、2 章可见部分,第二版不声称读完。第一版公开 PDF 的网页转换在书页 194 中断,改用私有本地文本继续读至附录 D 结尾;目录和索引不计正文。历史 API 与示例并非 C++20 规范,本文不照搬无锁容器实现。

查看版本、实际阅读范围与原文入口 →

把完成事实留在状态里

主线程需要等工作线程算出答案。最脆弱的设计是先睡一小段时间,再假定计算已经完成:调度器没有承诺工作线程在这段时间内得到运行机会。另一个误区是把 notify_one 当成可积累的消息;没有等待者时通知不会留下一张票。

我们把答案和 ready 放在同一把互斥量的保护下。工作线程先写答案,再令 ready 为真,随后解锁并通知。主线程用带谓词的 wait 检查 ready。如果通知早已发生,谓词已经为真,根本不需要阻塞;如果尚未完成,wait 会原子地释放锁并进入等待。完成事实存在于 ready,而不是通知次数。

唤醒不等于条件成立

等待允许虚假唤醒;即使确实有人通知,也不能由此推导我们要的业务条件已经成立。因此应使用 wait(lock, predicate),它会在持锁状态下反复检查谓词,直到结果为真才返回。这里的锁必须是可暂时释放并重新取得的 unique_lock,而不是仅负责作用域释放的 lock_guard。

主线程读取 answer 时仍持有同一把锁。工作线程解锁与随后获得该锁建立同步关系,使写入对读取可见。ready 无须再改成 atomic:只要所有访问始终由同一互斥量保护,普通 bool 就足够。把谓词移到锁外或混用另一把锁,会破坏这个证明,不能靠额外 notify 修复。

等待完成与对象存活分别证明

条件变量解决状态交接,并不自动管理工作线程。示例在同步对象之后构造 jthread,所以逆序析构时先等待工作线程结束,再销毁它引用的互斥量与条件变量。使用显式内层作用域也可以让这个关系更醒目;不得把线程 detach 后任由引用指向已经离开的栈。

原书第一版以 C++11 的 thread 和自制 RAII 守卫解释生命周期,本例改用 C++20 jthread。这不是强制取消:析构提出停止请求后仍要等待线程自行返回。示例任务是必定完成的小计算,不需要关闭协议;若改成无限消费队列,必须设计关闭状态、唤醒和退出条件。反复运行输出正确只能辅助观察,正确性最终来自状态与同步关系。

常见误区

  • notify_one 不是持久消息;不要用一次裸 wait 代替状态谓词。
  • jthread 不会强行终止死循环;自动 join 仍可能无限等待,不应在等待者持有工作线程所需锁时析构它。

运行一个例子

最低标准 C++20 · 完整程序 · 下载 .cpp

#include <cassert>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

int main() {
    std::mutex mutex;
    std::condition_variable changed;
    bool ready = false;
    int answer = 0;
    {
        std::jthread worker([&] {
            const int computed = 6 * 7;
            {
                std::lock_guard lock(mutex);
                answer = computed;
                ready = true;
            }
            changed.notify_one();
        });
        std::unique_lock lock(mutex);
        changed.wait(lock, [&] { return ready; });
        assert(answer == 42);
        std::cout << answer << "\n";
    }
}

在本地编译

g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread books-cpp-concurrency-in-action.cpp -o example && ./example

预期结果

42

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

工作线程在主线程调用 wait 前已经完成并通知,主线程为什么仍能退出?如果只保留通知而删除 ready 呢?

查看参考答案

wait 的谓词重载先在持锁状态下检查 ready。工作线程已将它置真,主线程取得锁后可见该写入,因而直接返回。若没有持久谓词,之前的通知不会缓存,后来的裸 wait 可以永久阻塞;即使它偶然虚假唤醒,也不能据此证明答案已写好。

继续查证

标准草案与官方章节会更新;版本标记只说明示例最低要求。

回到目录