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):多个活动在同一时间段内同时进行
  • 两种实现方式:
    1. 多进程并发:进程间通信(管道、信号、共享内存、套接字),开销大,安全隔离好
    2. 多线程并发:同一进程内共享地址空间,开销小,数据共享方便但易出问题
  • 并发 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):

  • 两个线程互相等待对方持有的锁
  • 避免方法:
    1. 始终按相同顺序加锁
    2. 用 std::lock() 一次性锁多个互斥量
    3. 避免嵌套锁
    4. 锁内不调用用户代码
    5. 用锁层次(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():启动异步任务,返回 future
    • std::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

内存回收方案:

  1. 风险指针(Hazard Pointers):

    • 每个线程维护一组”正在访问”的指针(风险指针)
    • 删除节点前检查所有线程的风险指针,确认无人引用才释放
    • 已被纳入 Concurrency TS 提案
  2. 引用计数(Reference Counting):

    • 每个节点维护引用计数
    • 难点:原子地增减计数并判断是否为 0
    • 可用 std::shared_ptr 但性能开销大
  3. 延迟回收(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 无锁编程指南

  1. 先用 seq_cst 原型验证正确性,再考虑放松内存序
  2. 必须有内存回收方案,不能直接 delete
  3. 警惕 ABA 问题,特别是有内存复用的场景
  4. 识别忙等待循环,考虑帮助其他线程完成操作(helping)
  5. 测试极其困难,竞态条件极难重现

与《实现模式》关联:无锁编程是”性能优化”主题的极端形式,Kent Beck 的实现模式 中强调”先让代码工作,再优化性能”。无锁结构应作为性能瓶颈的最后手段,而非首选方案。


第8章 Designing Concurrent Code

8.1 线程间划分工作的技术

  1. 处理前划分数据:如每个线程处理数组的一段(最简单最常见)
  2. 递归划分:如并行 Quicksort、归并排序(分治算法)
  3. 按任务类型划分:每个线程负责一种类型的任务(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_each
  • parallel_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_n
  • find、find_if、find_if_not
  • count、count_if
  • transform、replace、replace_if
  • sort、stable_sort、partial_sort
  • reduce、transform_reduce
  • exclusive_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. 初学者:第1-4章精读,第5章理解基本概念即可(内存模型可后续深入)
  2. 中级开发者:第6-8章重点阅读,掌握并发数据结构和并发代码设计
  3. 高级开发者:第7、9章深入,研究无锁编程和高级线程管理
  4. 实践派:第10章(并行算法)和第11章(测试调试)直接可用
  5. 最重要的建议:先让代码正确,再考虑优化;先用锁,再考虑无锁;先用 seq_cst,再考虑放松内存序。