C++ 并发综合复习 / C++ Concurrency Review
本篇是复习入口,细节以六个专题为准。
1. 权威专题
首次学习从并发路线开始。
2. 一张图串起正确性
对象所有权覆盖所有线程/任务
|
v
每个共享可变状态有同步协议
|
+-- mutex unlock -> lock
+-- release write -> acquire read
+-- thread completion -> join
|
v
冲突访问之间形成 happens-before
|
v
无 data race(仍需检查业务竞态和死锁)“无 data race”只是底线。银行转账可能每个字段都加锁却在错误时机检查余额,仍有业务竞态;锁顺序错误还可能死锁。
3. 同步设施选择
| 问题 | 首选起点 |
|---|---|
| 多字段共享不变量 | mutex + RAII lock |
| 等待状态变化 | condition_variable + predicate |
| 同时获取多锁 | scoped_lock / 固定顺序 |
| 独立计数/状态 | atomic,先用 seq_cst |
| 一次初始化 | 函数静态或 call_once |
| 异步单结果 | future/promise/task |
| C++20 线程取消 | jthread + stop_token |
| 资源许可计数 | C++20 semaphore |
4. 高频对比
mutex vs atomic
mutex 可保护任意复杂不变量并支持阻塞;atomic 适合单对象状态机或经过证明的无锁协议。atomic 不必然更快,也可能内部用锁。
condition_variable vs atomic wait
condition_variable 与 mutex 组合任意谓词和多字段状态;C++20 atomic wait 适合围绕单个原子值的等待。两者都要求业务状态和关闭协议。
thread vs task
thread 是执行资源句柄;task 表达要完成的工作和结果。高层业务通常应提交任务,由执行器决定线程。
race condition vs data race
data race 是内存模型定义的 UB;race condition 是更宽的时序依赖错误,可以发生在完全加锁的程序中。
5. 条件变量答题模板
- 状态由 mutex 保护;
- 等待方持
unique_lock; wait(lock, predicate)循环检查;- 修改方在锁内更新状态;
- notify 提示重新检查;
- predicate 包含关闭/取消状态。
条件变量没有通知记忆,predicate 才是事实来源。
6. 内存序答题模板
- 先指出 data race 和共享对象;
- 画出每线程 sequenced-before;
- 指出哪次 acquire 读到哪次 release;
- 用传递性推出 payload 写 happens-before 读;
- 若无法证明,回到 seq_cst 或 mutex;
- 最后才讨论性能。
7. 线程池答题模板
完整线程池至少回答:
- 有界队列和满载策略;
- submit/close 原子竞争;
- 任务异常进入何处;
- drain 还是 cancel;
- 运行任务怎样观察取消;
- 如何唤醒 worker/提交者;
- 如何避免池内等待死锁;
- 最终 join 和资源析构顺序。
8. 高频纠错
volatile不是同步;- detach 不是后台任务所有权模型;
x = x + 1对 atomic 是 load+store,不是单次 RMW;- relaxed 不发布旁边普通数据;
- future timeout 不取消任务;
- stop_token 不强杀线程;
- shared_ptr 保活对象但不保护其成员;
- lock-free 不等于 wait-free 或更快;
- TSan 无报告不等于证明正确。
9. 综合自测与答案
- 为什么 join 后可读取工作线程写入的普通数据?
- 为什么条件变量谓词要包含 closed?
- CAS 能避免 ABA 吗?
- 队列为空为何不一定代表线程池排空?
- 为什么 shared_mutex 可能比 mutex 慢?
<details> <summary>参考答案</summary>
- 线程完成 synchronizes-with 成功 join 返回,形成 happens-before。
- 否则关闭时空队列等待者没有可满足条件,会永久睡眠。
- 不能,值可能变化后又恢复;需版本标签和安全回收等协议。
- worker 可能已取出任务且仍在执行,需要跟踪 in-flight 数量。
- 读写状态、更复杂原子操作、公平性和缓存争用都有成本,小临界区不一定受益。
</details>
10. 下一步
继续学习系统与网络编程,观察 C++ 并发协议如何与进程、文件描述符、非阻塞 I/O 和 Reactor 结合。
11. 一次任务从提交到关闭的全链路
caller submit(task)
|
lock queue -> check Running -> enqueue -> notify
|
worker wait(predicate)
|
dequeue -> unlock -> execute -> publish result
|
shutdown: stop accepting -> drain/cancel -> wake all -> join -> destroy state任何并发组件都应同时回答工作流和关闭流;只实现“正常有任务时怎么跑”,没有处理空队列等待、提交/关闭竞争与在途任务,就不是完整线程池。
12. 数据竞争、逻辑竞态与死锁分别证明
data race:冲突非原子访问之间是否有 happens-before?
logic race:即使每次访问原子,检查与行动是否仍被插入?
deadlock:等待图是否可能形成环?给两个字段都加 atomic 可以消除字段级 data race,但无法让“余额检查 + 扣款”成为事务;这类复合不变量通常用 mutex。
13. 发布数据的三种常见方式
mutex: producer unlock -> consumer subsequent lock
atomic: release store -> acquire load that observes it
future: producer sets shared state -> consumer get/wait returns三者都可建立可见关系,但适用结构不同。不要把 condition_variable 的 notify 当发布本身;共享状态仍需 mutex/atomic 协议。
14. 背压是稳定性的一部分
无限队列把短期吞吐不足变成无限内存增长和延迟:
arrival rate > service rate
queue length -> grows -> memory/latency -> failure有界队列满时可阻塞提交、返回拒绝、丢弃低优先级、调用者同步执行或降级。策略必须匹配业务,且不能在持有上游关键锁时阻塞造成锁链死锁。
15. 取消分三个层次
queued task:尚未开始,可从队列标记/移除
running task:只能协作观察 token
external I/O:还需底层取消 API 与完成竞态协议“future 超时”只让等待者停止等,不会自动取消任务。取消结果、部分副作用和资源清理要进入任务契约。
16. 性能诊断的因果链
低吞吐/高尾延迟
-> workers busy 还是 blocked?
-> queue wait、mutex wait、I/O wait 各多少?
-> 任务粒度是否太小?
-> cache line 争用/NUMA/false sharing?
-> 增加线程是否只增加竞争?平均延迟会掩盖长尾;至少观察 p95/p99、队列深度、拒绝数、活动 worker 与任务服务时间。
17. 综合面试与自测
问:线程池线程越多越好吗? 答:不是。CPU 工作受核心与内存带宽限制,过多线程增加切换/竞争;I/O 工作则结合等待比例和背压测量。
问:notify 是否保存一次信号? 答:condition_variable 不保存事件计数,必须用受保护谓词记录状态。
- 为什么关闭先停止接收? 答:避免 join 过程中仍有新任务进入,使终止条件不可达。
- 原子计数能否保护队列? 答:不能替代队列多字段结构与元素生命周期协议。
- TSan clean 是否证明无逻辑竞态? 答:不能,它主要检测执行覆盖到的数据竞争。
8. 真实问题:偶发错误该从哪里开始
并发故障往往只在特定调度交错中出现。 第一步不是把所有字段改成 atomic。 先找出谁拥有任务、谁拥有状态、谁负责关闭。 然后画出状态转换和等待边。 每条跨线程数据流都应有发布方式。 每个阻塞点都应有唤醒和停止路径。 每个线程都应有 join 或等价的完成确认。 这套问题能把“偶发”变成可审查的协议。
9. 统一直觉:四种关系
所有权回答对象何时销毁。 同步回答写入何时对另一个线程可见。 排队回答过载时工作放在哪里。 取消回答不再需要的工作如何退出。 锁、原子、future 和线程池各自覆盖其中一部分。 没有一种工具自动覆盖全部四种关系。 选择工具前先写清缺的关系。 这比从库名称开始更可靠。
10. 最小设计练习:可关闭队列
队列状态需要 open、closing 和 closed。 生产者只能在 open 时入队。 消费者在队列为空且 closing 时退出。 提交和状态检查在同一把锁内完成。 关闭时唤醒所有消费者和等待提交者。 任务在锁外运行,异常在边界捕获。 join 后才能销毁队列所引用的资源。 这个练习连接了线程、锁、条件变量和生命周期。
11. 选择原语的快速表
单一计数或标志且无复合不变量,可考虑 atomic。 多个字段必须同时一致时,先用 mutex。 需要等待条件变化时,用 condition_variable 或 atomic wait。 需要一次结果和异常传播时,用 future/promise。 需要限制并发和排队时,用线程池。 需要极端性能时,先测量再讨论 lock-free。 不要用一个原子 bool 模拟完整服务关闭。 不要用 detach 回避所有权问题。
12. 内存序复习
普通读写跨线程冲突需要同步。 release 发布之前的写入。 acquire 获取对应发布后的可见性。 relaxed 只保证该原子对象的修改序。 seq_cst 提供更强全局顺序,代价和必要性应测量。 内存序只能约束可见性,不表达业务终态。 原子计数不能替代队列锁。 复杂状态先证明不变量,再降低内存序。
13. 锁与等待复习
锁保护一组不变量,而非一个语句。 条件变量必须配受锁保护的谓词。 notify 不是持久事件。 伪唤醒和超时后都要重检谓词。 多锁使用统一顺序或 scoped_lock。 持锁调用用户代码容易重入和死锁。 锁外执行慢 I/O,锁内只完成状态转换。 关闭时要唤醒所有潜在等待者。
14. 任务和结果复习
thread 句柄必须 join、detach 或转移。 jthread 仍要求入口协作停止。 future 表示一次终态,不等于取消。 promise 的异常要在消费者 get 时报告。 线程池应有容量、拒绝和关闭语义。 池内同步等待同池任务可能饥饿。 取消要覆盖未开始任务和运行任务。 结果、日志和指标要保留任务上下文。
15. 性能与诊断复习
先确定瓶颈是锁等待、调度还是缓存争用。 false sharing 可能发生在完全无数据竞争的程序中。 无锁算法需要安全回收方案。 CAS 失败率和 p99 比平均吞吐更有价值。 TSan 能发现执行到的数据竞争。 ASan 能帮助发现生命周期错误。 性能工具解释样本,仍需结合状态协议。 优化前保存互斥基线和可复现输入。
16. 失败场景练习
把队列容量设为一,并让生产者快速提交。 验证过载被拒绝或阻塞,而不是无限占内存。 让消费者在 wait 前后随机 sleep。 验证谓词不会丢失通知。 让任务抛异常。 验证 worker 不会 terminate 进程。 让关闭与提交并发发生。 验证没有任务进入无人处理的队列。 让线程在停止时阻塞 I/O。 验证存在超时或关闭句柄的唤醒路径。
17. 自测题
问:先选 mutex 还是 atomic?答:先看不变量是否跨多个字段,复杂时从 mutex 开始。 问:如何判断未来是否会死锁?答:维护锁顺序、避免持锁回调,并做并发压力测试。 问:超时后资源能立刻释放吗?答:只有任务已停止或不再借用该资源时才行。 问:无锁队列为何还会崩溃?答:原子替换不等于对象回收安全。 问:最重要的关闭条件是什么?答:不再接收、唤醒等待、任务终态和线程完成都可证明。
18. 这一节先记住
并发正确性由协议组成,不由单个关键字保证。 从所有权、同步、排队和取消四个方向审查设计。 先让关闭可证明,再追求吞吐。 先测量真实瓶颈,再引入更复杂的原语。
工程深化:把本篇知识落到项目里
这一节不是为了凑篇幅,而是把《C++ 并发综合复习 / C++ Concurrency Review》从“知道概念”推进到“能在项目里稳定使用”。 阅读时可以把每个知识点都追问成四件事:它保护什么边界,失败时有什么现象,怎样最小复现,怎样写进团队流程。
concept -> boundary -> failure signal -> minimal proof -> project rule工程切片 1:最小可复现样例
**场景。**围绕 线程对象 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 线程对象、join/detach 还是 共享状态 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 线程对象 -> join/detach -> 共享状态 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 2:接口边界
**场景。**围绕 join/detach 建一个很小的案例,不要一开始就放进完整业务系统。写清调用方能依赖什么、不能依赖什么,并把隐式假设转换成命名函数、配置项或测试。
**边界。**先判断这里讨论的是 join/detach、共享状态 还是 生命周期 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> join/detach -> 共享状态 -> 生命周期 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 3:失败注入
**场景。**围绕 共享状态 建一个很小的案例,不要一开始就放进完整业务系统。故意制造一个常见错误,让日志、断言或测试先失败,再用修复后的证据说明规则生效。
**边界。**先判断这里讨论的是 共享状态、生命周期 还是 异常边界 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 共享状态 -> 生命周期 -> 异常边界 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 4:跨平台差异
**场景。**围绕 生命周期 建一个很小的案例,不要一开始就放进完整业务系统。至少比较 Windows、Linux 或不同编译器下的一个差异,记录差异属于标准、实现还是平台约定。
**边界。**先判断这里讨论的是 生命周期、异常边界 还是 调试日志 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 生命周期 -> 异常边界 -> 调试日志 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 5:性能观察
**场景。**围绕 异常边界 建一个很小的案例,不要一开始就放进完整业务系统。用小规模和压力规模各跑一次,区分算法成本、同步成本、I/O 成本和工具链配置成本。
**边界。**先判断这里讨论的是 异常边界、调试日志 还是 线程对象 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 异常边界 -> 调试日志 -> 线程对象 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 6:生命周期
**场景。**围绕 调试日志 建一个很小的案例,不要一开始就放进完整业务系统。标出资源创建、转移、共享、停止和销毁的顺序,特别关注错误返回和提前退出路径。
**边界。**先判断这里讨论的是 调试日志、线程对象 还是 join/detach 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 调试日志 -> 线程对象 -> join/detach -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 7:诊断证据
**场景。**围绕 线程对象 建一个很小的案例,不要一开始就放进完整业务系统。保留命令、日志、符号、栈、测试输出或截图,让结论可以被另一个环境重新验证。
**边界。**先判断这里讨论的是 线程对象、join/detach 还是 共享状态 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 线程对象 -> join/detach -> 共享状态 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 8:维护策略
**场景。**围绕 join/detach 建一个很小的案例,不要一开始就放进完整业务系统。把一次性经验沉淀到 README、CI、脚本、示例工程或检查表里,避免只存在个人记忆中。
**边界。**先判断这里讨论的是 join/detach、共享状态 还是 生命周期 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> join/detach -> 共享状态 -> 生命周期 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 9:版本演进
**场景。**围绕 共享状态 建一个很小的案例,不要一开始就放进完整业务系统。为未来变更预留兼容策略:版本号、特性位、弃用窗口、迁移脚本和回滚入口。
**边界。**先判断这里讨论的是 共享状态、生命周期 还是 异常边界 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 共享状态 -> 生命周期 -> 异常边界 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 10:最小消费者
**场景。**围绕 生命周期 建一个很小的案例,不要一开始就放进完整业务系统。从使用者角度写一个小程序或小工程,只依赖公开接口,验证安装包、头文件和运行时行为。
**边界。**先判断这里讨论的是 生命周期、异常边界 还是 调试日志 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 生命周期 -> 异常边界 -> 调试日志 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 11:边界复盘
**场景。**围绕 异常边界 建一个很小的案例,不要一开始就放进完整业务系统。用一张表说明问题发生在源码、编译、链接、加载、运行、关闭或发布中的哪一层。
**边界。**先判断这里讨论的是 异常边界、调试日志 还是 线程对象 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 异常边界 -> 调试日志 -> 线程对象 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 12:反例训练
**场景。**围绕 调试日志 建一个很小的案例,不要一开始就放进完整业务系统。写出一个看似能工作但不可维护的方案,再说明它在规模、团队协作或发布时会怎样失败。
**边界。**先判断这里讨论的是 调试日志、线程对象 还是 join/detach 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 调试日志 -> 线程对象 -> join/detach -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 13:最小可复现样例
**场景。**围绕 线程对象 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 线程对象、join/detach 还是 共享状态 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 线程对象 -> join/detach -> 共享状态 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
本篇收束
掌握《C++ 并发综合复习 / C++ Concurrency Review》的标志,不是记住所有名词,而是能把 线程对象、join/detach、共享状态、生命周期、异常边界、调试日志 放进一条可验证的工程链路。 先用最小样例建立判断,再用测试和脚本固定判断,最后把失败证据留给未来的自己和团队。