C++ Concurrency in Action (第二版)
C++ 并发编程领域最权威著作,作者 Anthony Williams 是 C++ 标准委员会并发组成员、Boost Thread 库主要维护者,直接参与了 C++11/14/17 并发特性的制定。
全书概览
592 页,11 章 + 4 个附录,覆盖从基础线程管理到内存模型、无锁数据结构、并行算法的完整知识体系。基于 C++17 标准和 Concurrency TS。
知识结构
基础篇(第1-5章)
├─ 第1章 并发与 C++ 并发入门
├─ 第2章 线程管理
├─ 第3章 线程间数据共享
├─ 第4章 并发操作同步
└─ 第5章 C++ 内存模型与原子操作 ← 全书最核心、最难的一章
进阶篇(第6-9章)
├─ 第6章 基于锁的并发数据结构设计
├─ 第7章 无锁并发数据结构设计 ← 高阶
├─ 第8章 并发代码设计
└─ 第9章 高级线程管理
应用篇(第10-11章)
├─ 第10章 并行算法(C++17)
└─ 第11章 多线程应用测试与调试
第1章 Hello, world of concurrency in C++
1.1 什么是并发
- 并发(Concurrency):多个活动在同一时间段内同时进行
- 两种实现方式:
- 多进程并发:进程间通信(管道、信号、共享内存、套接字),开销大,安全隔离好
- 多线程并发:同一进程内共享地址空间,开销小,数据共享方便但易出问题
- 并发 vs 并行:
- 并发关注任务的交替执行(逻辑层面)
- 并行关注任务的同时执行(物理层面,多核心)
- 同一程序可能同时涉及两者
1.2 为什么使用并发
- 关注点分离(SoC):将不同职责的代码放在不同线程,UI 线程 + 工作线程模式
- 性能提升:
- 任务并行:将一个任务拆成几部分并行执行
- 数据并行:每个线程对不同数据做相同操作
- 什么时候不该用并发:收益小于代价时,如任务量小、开发复杂度增加、调试困难
1.3 C++ 中的并发
- C++11 首次在标准库中引入多线程支持(此前依赖 POSIX Threads / Windows Threads 等平台库)
- C++14/17 增加了并行算法和更多并发特性
- 标准库设计追求效率,与手写底层代码性能相当
- 必要时可使用平台特定 API(
native_handle())
第2章 Managing Threads
2.1 基本线程管理
// 启动线程
std::thread t(do_work);
std::thread t(func, arg1, arg2); // 传参
// 等待线程完成
t.join();
// 异常情况下的等待:使用 RAII
class thread_guard {
std::thread& t;
public:
explicit thread_guard(std::thread& t_) : t(t_) {}
~thread_guard() {
if (t.joinable()) t.join();
}
};
// 后台运行(分离线程)
t.detach(); // 线程在后台运行,无法再 join关键要点:
std::thread对象销毁前必须调用join()或detach(),否则std::terminate()- 分离线程需注意:不能访问可能已销毁的对象
2.2 向线程函数传参
- 参数默认复制到线程内部存储,再以右值形式传给可调用对象
- 传引用需用
std::ref()显式包装 - 成员函数指针作线程函数:第一参为对象指针(或对象)
- 参数支持移动语义:
std::move()转移不可复制对象(如std::unique_ptr)
2.3 转移线程所有权
std::thread不可复制,但可移动(movable)- 函数可返回
std::thread - 可将线程存入容器:
std::vector<std::thread>
2.4 运行时选择线程数
std::thread::hardware_concurrency()获取真正的并发线程数(通常等于 CPU 核心数)- 返回 0 表示无法获取
- 是并行算法选择线程数的重要依据
2.5 线程标识
std::thread::id类型,通过t.get_id()或std::this_thread::get_id()获取- 可比较、可哈希、可作容器键
- 默认构造的
std::thread::id表示”无线程”
第3章 Sharing Data Between Threads
3.1 共享数据的问题
- 竞态条件(Race Condition):结果取决于线程执行的相对时序
- 数据竞争(Data Race):两个线程同时修改同一对象,属于未定义行为
- 避免方法:
- 用互斥量保护共享数据
- 用无锁编程修改数据结构
- 用事务内存(Software Transactional Memory,C++ 标准尚未支持)
3.2 用互斥量避免竞态
std::mutex m;
int shared_data;
void safe_increment() {
std::lock_guard<std::mutex> guard(m); // RAII,自动加解锁
++shared_data;
}接口固有的竞态:
- 如
stack的top()和pop()分开调用存在竞态(检查后使用前被修改) - 解决方案:合并操作、传引用接收返回值、返回
shared_ptr
死锁(Deadlock):
- 两个线程互相等待对方持有的锁
- 避免方法:
- 始终按相同顺序加锁
- 用
std::lock()一次性锁多个互斥量 - 避免嵌套锁
- 锁内不调用用户代码
- 用锁层次(Lock Hierarchies)强制顺序
// std::lock 一次性锁多个,避免死锁
std::lock(m1, m2);
std::lock_guard<std::mutex> lk1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lk2(m2, std::adopt_lock);std::unique_lock:
- 比
lock_guard更灵活:可延迟加锁、可解锁、可转移所有权 - 代价是略大的空间和时间开销
锁的粒度:
- 锁粒度越小,并发度越高,但复杂度也越高
- 锁内只做必要操作,尽量缩短持锁时间
3.3 保护共享数据的其他方式
- 初始化时保护:
std::call_once()+std::once_flag:保证只调用一次static局部变量:C++11 起保证线程安全的初始化- 双重检查锁定(Double-Checked Locking)是反模式,不要用
- 保护很少更新的数据:
std::shared_mutex读写锁(C++17)- 多读单写模式,读操作共享锁,写操作独占锁
- 递归锁:
std::recursive_mutex,同一线程可多次加锁(但应尽量避免,说明设计有问题)
第4章 Synchronizing Concurrent Operations
4.1 条件变量(Condition Variable)
std::mutex m;
std::condition_variable cv;
bool ready = false;
void worker() {
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{ return ready; }); // 等待条件,自动解锁/重新加锁
// 处理...
}
void notifier() {
{
std::lock_guard<std::mutex> lk(m);
ready = true;
}
cv.notify_one(); // 通知一个等待线程
}- 虚假唤醒(Spurious Wakeup):
wait可能在未被通知时返回,必须用谓词检查 - 线程安全队列的经典实现:
mutex + condition_variable + queue
4.2 Future 与一次性事件等待
std::future<T>:表示将来会有结果std::async():启动异步任务,返回 futurestd::launch::async:立即在新线程执行std::launch::deferred:延迟到get()/wait()时在调用线程执行
std::future<int> result = std::async(std::launch::async, compute, 42);
int value = result.get(); // 阻塞等待结果std::packaged_task:将任务与 future 绑定,可放入线程池等std::promise<T>:手动设置结果的值,另一端用 future 等待- 异常传递:任务中抛出的异常会存入 future,
get()时重新抛出 std::shared_future:可多个线程等待同一结果(std::future只能get()一次)
4.3 限时等待
- 时钟(Clock):
std::chrono::system_clock(墙上时间)、steady_clock(单调时钟)等 - 时长(Duration):
std::chrono::seconds、milliseconds等 - 时间点(Time Point):
clock::now() + duration - 支持超时的函数:
wait_for()、wait_until()、try_lock_for()等
4.4 用同步简化代码
- 函数式编程风格:用 future 组织并行计算,如并行 Quicksort
- 消息传递:Actor 模型,线程间通过消息队列通信,不共享可变状态
- Concurrency TS 中的延续(Continuation):
future.then(continuation):future 就绪后自动执行延续函数when_all()/when_any():等待所有/任一 future 完成
- 闩(Latch)与屏障(Barrier):
latch:一次性同步点,计数器减到 0 时所有等待线程通过barrier:可重用,线程到达后等待,全部到达后一起通过,可循环使用flex_barrier:灵活屏障,可在每轮结束时执行更新函数
第5章 C++ 内存模型与原子操作 ⭐ 核心
这是全书最重要也最难的一章。理解内存模型是写正确高效并发代码的基础。
5.1 内存模型基础
- 对象与内存位置:C++ 中一切皆对象,每个对象至少占一个内存位置
- 位域特例:同一字节内的不同位域属于同一内存位置,不能被不同线程并发修改
- 修改顺序(Modification Order):每个内存位置都有一个所有修改组成的全序
5.2 原子操作与类型
标准原子类型:
| 类型 | 说明 |
|---|---|
std::atomic_flag | 最简单的布尔标志,仅支持 test_and_set() / clear(),必锁无关 |
std::atomic<bool> | 原子布尔,支持更多操作 |
std::atomic<T*> | 原子指针,支持指针算术 |
std::atomic<integral> | 原子整数类型,支持加减、位运算等 |
std::atomic<T> | 主模板,任意可复制类型可用,但操作有限 |
关键操作:
load()/store():读写exchange():交换(读改写)compare_exchange_weak()/compare_exchange_strong():比较交换(CAS)- weak 可能伪失败(spurious failure),但某些平台性能更好
- strong 保证失败仅因值不相等
- 整数/指针特化支持:
fetch_add、fetch_sub、fetch_and、fetch_or、fetch_xor等
5.3 内存序(Memory Ordering)
六种内存序:
从强到弱:
sequential consistency (seq_cst) ← 最强,最易理解
↓
acquire-release (acquire / release / acq_rel)
↓
relaxed ← 最弱,最难用对
1. Sequential Consistency(memory_order_seq_cst)
- 默认内存序
- 所有线程看到的操作顺序完全一致
- 有一个全局的操作全序
- 最安全、最易推理,但性能开销最大
2. Acquire-Release
memory_order_acquire:读操作(load),确保后续操作不能重排到它之前memory_order_release:写操作(store),确保先前操作不能重排到它之后memory_order_acq_rel:读改写操作同时具有 acquire 和 release 语义- synchronizes-with 关系:一个线程的 release 写与另一个线程的 acquire 读同步
- 比 seq_cst 弱,但性能更好
3. Relaxed(memory_order_relaxed)
- 只保证原子性和修改顺序一致性
- 不提供任何跨线程的顺序保证
- 最宽松,性能最高,但极难用对
- 适用场景:简单计数器、不需要同步的场景
Happens-Before 关系:
- 线程内:按 sequenced-before 规则
- 线程间:通过 synchronizes-with 关系建立
- 传递性:A happens-before B,B happens-before C → A happens-before C
Release Sequence:
- 一个 release 操作后的一系列读改写操作构成 release sequence
- 即使中间有其他修改,最后读取的线程仍能与最初的 release 同步
内存栅栏(Fences):
std::atomic_thread_fence():插入内存屏障memory_order_release栅栏 + relaxed 存储 ≈ release 存储memory_order_acquire栅栏 + relaxed 加载 ≈ acquire 加载- 可用于优化:将栅栏放在条件分支后
非原子操作的顺序:
- 原子操作可以用来约束非原子操作的可见性
- 只要建立了 synchronizes-with / happens-before 关系,非原子写入对另一线程可见
实践建议:99% 的场景使用默认的
seq_cst就够了。只有在性能分析证明有必要时,才考虑使用更弱的内存序,且必须非常谨慎。
第6章 Designing Lock-Based Concurrent Data Structures
6.1 并发数据结构设计指南
- 确保数据不会因竞态而损坏
- 保证不变量成立
- 注意接口本身的竞态(条件竞争)
- 考虑异常安全
- 尽量提高并发度(减少锁的粒度和持锁时间)
6.2 基于锁的并发数据结构
线程安全栈(Stack):
- 用单个互斥量保护整个栈
pop()返回shared_ptr或传引用接收,避免接口竞态
线程安全队列(Queue):
- 单锁 + 条件变量版本
- 细粒度锁版本:头尾各一个锁(head mutex + tail mutex)
- 用 dummy 节点分离头尾,使 push 和 pop 操作可以并发
- 大幅提高并发度
6.3 更复杂的基于锁的数据结构
线程安全查找表(Lookup Table / Map):
- 分段锁(Lock Striping / Bucket Locking):哈希表每个桶一把锁
- 不同桶的访问可以并发
- 比全局锁并发度高得多
- 控制桶的数量与锁粒度的权衡
线程安全链表(List):
- 节点级锁:每个节点一把锁
- 遍历链表时依次获取下一个节点的锁,释放前一个(hand-over-hand locking)
- 实现复杂度高,但并发度更好
第7章 Designing Lock-Free Concurrent Data Structures
无锁编程是并发编程的巅峰,难度极高。只有在性能分析确有必要时才应使用。
7.1 定义与分类
- 阻塞(Blocking):使用互斥锁等同步原语,线程可能被阻塞等待
- 无锁(Lock-Free):保证至少有一个线程能前进(不依赖锁)
- 无等待(Wait-Free):保证每个线程都能在有限步骤内完成(最强保证)
无锁数据结构的优缺点:
- ✅ 避免死锁
- ✅ 更高的并发度(无锁争用开销)
- ✅ 即使线程被抢占也不影响其他线程
- ❌ 实现极其复杂
- ❌ 内存回收是难点
- ❌ 需要深入理解内存模型
7.2 无锁数据结构实例
无锁栈(Lock-Free Stack):
- 用
compare_exchange_weak实现 push/pop - 核心思想:CAS 循环,尝试原子更新头指针
- 内存回收问题:pop 后节点何时释放?
- 其他线程可能还持有指向该节点的指针
- 直接释放会导致 use-after-free
内存回收方案:
-
风险指针(Hazard Pointers):
- 每个线程维护一组”正在访问”的指针(风险指针)
- 删除节点前检查所有线程的风险指针,确认无人引用才释放
- 已被纳入 Concurrency TS 提案
-
引用计数(Reference Counting):
- 每个节点维护引用计数
- 难点:原子地增减计数并判断是否为 0
- 可用
std::shared_ptr但性能开销大
-
延迟回收(Deferred Reclamation):
- 待删节点先放入回收站
- 定期批量回收(当确认安全时)
- 实现相对简单
ABA 问题:
- CAS 检查时值为 A,但实际经历了 A→B→A 的变化,CAS 会错误地成功
- 解决方法:使用标记指针(tagged pointer),在指针低位存放版本号/计数器
- 或使用
std::atomic<std::shared_ptr<T>>
无锁队列(Lock-Free Queue):
- 比栈更难,因为涉及头尾两个指针的协调
- 经典实现:Michael-Scott Queue
- 用 dummy 节点分离头尾,使 enqueue 和 dequeue 可以并发
7.3 无锁编程指南
- 先用
seq_cst原型验证正确性,再考虑放松内存序 - 必须有内存回收方案,不能直接
delete - 警惕 ABA 问题,特别是有内存复用的场景
- 识别忙等待循环,考虑帮助其他线程完成操作(helping)
- 测试极其困难,竞态条件极难重现
与《实现模式》关联:无锁编程是”性能优化”主题的极端形式,Kent Beck 的实现模式 中强调”先让代码工作,再优化性能”。无锁结构应作为性能瓶颈的最后手段,而非首选方案。
第8章 Designing Concurrent Code
8.1 线程间划分工作的技术
- 处理前划分数据:如每个线程处理数组的一段(最简单最常见)
- 递归划分:如并行 Quicksort、归并排序(分治算法)
- 按任务类型划分:每个线程负责一种类型的任务(Pipeline 模式)
8.2 影响并发代码性能的因素
- 处理器数量:Amdahl 定律决定理论上限
- 数据争用与缓存乒乓(Cache Ping-Pong):
- 多个核心频繁修改同一缓存行(cache line),导致缓存一致性流量
- 是多线程性能杀手
- 伪共享(False Sharing):
- 无关数据在同一缓存行,被不同线程修改
- 解决:数据对齐到缓存行边界、填充(padding)
- 数据局部性(Data Locality):数据越紧凑、访问模式越连续,缓存命中率越高
- 超额订阅(Oversubscription):线程数远超核心数,频繁上下文切换
8.3 为多线程性能设计数据结构
- 数组元素划分:让每个线程访问连续的内存块
- 避免不同线程的数据在同一缓存行
- 其他数据结构的访问模式优化
8.4 并发设计的其他考虑
- 异常安全:并行算法中一个线程抛异常怎么办?通常捕获后在
get()时重新抛出 - 可扩展性与 Amdahl 定律:
加速比 = 1 / (串行比例 + 并行比例 / 核心数)- 即使并行部分占 90%,100 核也只能 10 倍加速
- 关键是减少串行部分
- 用多线程隐藏延迟:I/O 密集型场景,一个线程等 I/O 时其他线程继续工作
- 提高响应性:UI 线程只处理事件,耗时操作放后台线程
8.5 实践中的并行算法实现
书中完整实现了三个经典并行算法:
parallel_for_each:并行 for_eachparallel_find:并行查找(找到即取消其他任务)parallel_partial_sum:并行前缀和(分两阶段实现)
与《算法导论》关联:并行算法是算法设计的一个重要方向,CLRS 第27章也有对多线程算法的理论分析(工作/持续时间模型)。
第9章 Advanced Thread Management
9.1 线程池
最简单的线程池:
- 固定数量的工作线程
- 一个任务队列
- 线程循环从队列取任务执行
等待提交的任务:
- 提交任务时返回
std::future,可等待结果 - 用
std::packaged_task包装任务
任务等待任务的问题:
- 如果线程池中线程都在等待(等其他任务完成),可能死锁
- 解决方案:工作窃取(work stealing)或允许等待时主动执行其他任务
避免任务队列争用:
- 工作窃取(Work Stealing):每个线程有自己的本地队列
- 本地队列为空时,从其他线程队列”偷”任务
- 大幅减少全局队列的锁争用
- 是众多高性能线程池的核心设计
9.2 中断线程
- C++ 标准没有原生的线程中断机制(不像 Java 的
interrupt()) - 需要自己实现:用原子标志 + 在等待点检查
- 可中断的条件变量等待:自定义等待逻辑,同时等待条件和中断标志
- 处理中断:抛异常或返回特殊状态
- 应用退出时安全地中断后台任务
第10章 Parallel Algorithms (C++17)
10.1 标准库算法的并行化
C++17 引入了并行算法:许多 STL 算法新增了接受执行策略的重载。
10.2 执行策略(Execution Policies)
| 策略 | 含义 |
|---|---|
std::execution::seq | 顺序执行,不并行 |
std::execution::par | 并行执行,多线程 |
std::execution::par_unseq | 并行 + 向量化,线程间和线程内都可重排 |
par:可以在多个线程中执行,但每个操作在其线程内顺序执行par_unseq:更强的并行许可,允许向量化(SIMD),操作可以跨线程重排- 使用
par_unseq要求算法中没有数据竞争,且不使用可能导致死锁的同步
10.3 标准库并行算法
支持并行的算法包括(部分):
for_each、for_each_nfind、find_if、find_if_notcount、count_iftransform、replace、replace_ifsort、stable_sort、partial_sortreduce、transform_reduceexclusive_scan、inclusive_scan- 等等,共数十个
使用示例:
#include <execution>
#include <algorithm>
#include <vector>
std::vector<int> v = ...;
// 并行排序
std::sort(std::execution::par, v.begin(), v.end());
// 并行计数
int n = std::count_if(std::execution::par, v.begin(), v.end(),
[](int x){ return x > 10; });注意:并行算法是否真的并行由实现决定,执行策略只是许可而非强制。
第11章 Testing and Debugging Multithreaded Applications
11.1 并发相关 Bug 类型
- 不必要的阻塞:死锁、活锁、I/O 阻塞
- 竞态条件:数据竞争、逻辑竞态(结果依赖执行顺序)
11.2 定位并发 Bug 的技术
代码审查:
- 审查共享数据的访问是否都有保护
- 锁的顺序是否一致(有无死锁风险)
- 条件变量使用是否正确(是否有虚假唤醒处理)
测试技术:
- 压力测试(Stress Testing):大量重复运行,增加竞态出现概率
- 组合爆炸问题:线程调度的可能性是天文数字,测试无法覆盖所有情况
- 可测试性设计:将并发逻辑与业务逻辑分离,分别测试
- 结构化测试代码:测试框架、断言、日志
性能测试:
- 测量加速比是否符合预期
- 识别瓶颈:锁争用、缓存不命中等
调试难度:并发 Bug 往往难以重现(Heisenbug),因为附加调试器会改变时序。日志记录比调试器更有效。
附录
- 附录 A:C++11 语言特性速览(右值引用、移动语义、lambda、constexpr、auto、decltype、变参模板 等)
- 附录 B:并发库对比(Pthreads、Windows Threads、TBB 等)
- 附录 C:消息传递框架与完整 ATM 示例(Actor 模型完整实现)
- 附录 D:C++ 线程库参考(快速查阅各类和函数)
核心概念清单
必须掌握(基础知识)
-
std::thread的启动、join、detach -
std::mutex+std::lock_guard/std::unique_lock - 死锁的原因与避免方法
-
std::condition_variable的正确使用(谓词 + 虚假唤醒) -
std::future/std::async/std::promise - 数据竞争与竞态条件的区别
进阶掌握
- 内存序的种类与适用场景(重点 seq_cst 和 acquire-release)
- happens-before / synchronizes-with 关系
- 基于锁的并发数据结构设计(栈、队列、哈希表)
- 线程池设计与工作窃取
- C++17 并行算法
专家级
- 无锁数据结构实现
- 内存回收技术(风险指针、引用计数)
- ABA 问题与解决方案
- relaxed 内存序的正确使用
与其他知识的关联
- A Tour of C++:第15章简要介绍了 C++ 并发支持,本书是深入版本
- Modern C++ Design:Andrei Alexandrescu 也是 C++ 并发提案的参与者之一,Policy-Based Design 思想在并发库设计中也有应用
- 程序员的自我修养:从系统底层理解线程、内存、共享库的实现机制
- 结构化计算机组成:理解 CPU 缓存、内存一致性模型的硬件基础,对理解内存模型和伪共享至关重要
- 算法导论:并行算法的理论基础(第27章多线程算法)
- 实现模式:并发代码也需要遵循基本的实现模式原则——清晰优先,过早优化是万恶之源
阅读建议
- 初学者:第1-4章精读,第5章理解基本概念即可(内存模型可后续深入)
- 中级开发者:第6-8章重点阅读,掌握并发数据结构和并发代码设计
- 高级开发者:第7、9章深入,研究无锁编程和高级线程管理
- 实践派:第10章(并行算法)和第11章(测试调试)直接可用
- 最重要的建议:先让代码正确,再考虑优化;先用锁,再考虑无锁;先用 seq_cst,再考虑放松内存序。