读《C++并发编程实战 -- C++ Concurrency In Action》

C++ 并发编程基础

什么是并发

并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。

并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。

并发的方式包括多进程并发和多线程并发,多进程并发通过进程间通信(信号、套接字、文件、管道等)来相互传递信息;由于同一进程内的所有线程都共用相同的地址空间,所以多进程并发通过共享内存来同步数据。

并发技术可以:

  1. 分离关注点(separation of concerns),使得不同的线程关注不同的任务;
  2. 提升性能。任务并行可以采取两种方式,一种是将单一任务分成多个部分,各自并行运作,从而节省总运行耗时;第二种是利用并行资源解决规模更大的问题。

什么时候避免并发?

  1. 并发会增加额外的复杂度,增加开发时间和维护成本
  2. 多线程的性能增幅可能不如预期,因为线程的启动会有额外的时间开销
  3. 线程是一种有限的资源,过多的线程会消耗系统资源,从而导致系统整体变慢
  4. 运行的线程越多,操作系统的上下文切换就越频繁,上下文切换会减少本该用于实质工作的时间

我们先尝试一个简单的多线程版本的 Hello World

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <thread>

using namespace std;

void hello() {
cout << "Hello Concurrent World\n";
}

int main(){
thread t(hello);
t.join();
}

线程管控

线程的管控可以通过 std::thread 对象来实现。

悬空引用(Dangling Reference)是指一个引用在指向有效数据之后,被引用的数据被销毁或释放,从而引用变成了无效的情况。这种情况可能导致程序运行时的未定义行为,因为引用指向的数据已经不存在,但程序仍试图通过该引用访问这个不存在的数据。如果新线程上的函数持有指针或引用,指向主线程的局部变量;但主线程所运行的函数退出后,新线程却还没结束,这时就会访问悬空引用的情况。

上述情况的处理办法是令线程函数完全自含(self-contained),将数据复制到新线程内部,而不是共享数据。另一种方法是汇合新线程,确保主线程的函数退出前,新线程执行完毕。

等待线程完成可调用成员函数join()来实现。在std::thread对象销毁前,我们需确保已经调用join()detach().假使打算等待线程结束,则需小心地选择执行代码的位置来调用join()。原因是,如果线程启动以后主线程有异常抛出,而join()尚未执行,则该join()调用会被略过。

在线程间共享数据

  • 互斥锁(std::mutex)和守卫(std::lock_guard 是最基础的保护手段。建议用 RAII 封装锁的获取与释放,避免异常路径遗忘 unlock()
  • 读写场景 可选 std::shared_mutex,提升读多写少情况下的吞吐。
  • 共享数据最好封装在类中,通过接口隐藏同步细节,并在接口处标记线程安全语义(例如 const 方法是否需要锁)。

并发操作的同步

  • 条件变量 (std::condition_variable) 适合“等待事件”场景,注意循环等待并搭配谓词,避免虚假唤醒。
  • 期物(std::future/std::promise 让任务之间通过值传递同步结果,与 std::async 结合时可以自动派发线程。
  • 闩锁与栅栏 (std::latch/std::barrier) 在 C++20 提供了更现代的阶段同步手段,写并行算法时很实用。

C++ 内存模型和原子操作

  • std::atomic<T> 提供无锁语义,默认 memory_order_seq_cst 最保守;可根据需求选择 acquire/release 等更轻量的序。
  • 多线程可见性靠 happens-before 保证:锁解锁、条件变量通知、原子操作等都会建立 happens-before 关系。
  • 自旋锁、无锁结构都必须正确处理 ABA、缓存一致性等问题,调试难度远超互斥锁方案。

设计基于锁的并发数据结构

  • 拆分粗粒度锁是提速关键:链表可以按桶分段上锁,哈希表可对每个 bucket 配独立互斥量。
  • 避免死锁常见策略:固定加锁顺序、使用 std::lock 实现一次性加多个锁、在失败时回退并重试。
  • 封装同步原语,对外暴露 STL 风格接口,减少误用概率。

设计无锁数据结构

  • 基础原语是 CAS (compare_exchange_weak/strong),配合 hazard pointer 或 epoch 回收解决内存释放问题。
  • Michael-Scott 队列、Treiber 栈是经典无锁结构,实测性能需考虑 CPU 拓扑与 false sharing。
  • 无锁不等于性能最优:在低并发或抢占严重的场景,粗粒度锁反而更稳定。

设计并发代码

  • 先做任务划分:区分数据并行(同一任务分片)与任务并行(不同任务流水线)。
  • 提前识别共享状态并决定同步策略,尽量让任务逻辑无共享或最小共享。
  • 保持异常安全:线程函数内部捕获异常并回传给主线程,避免“silent crash”。

高级线程管理

  • 线程池/工作队列可以显著降低频繁创建线程的开销。C++17 可使用 std::async/std::packaged_task 作为轻量封装。
  • 定时器、后台维护线程要有退出机制(stop flag + condition variable),防止程序无法优雅结束。
  • 对系统线程资源进行限流(std::hardware_concurrency 只是建议值),结合绑定 CPU、调整优先级进行调优。

并行算法函数

  • C++17 引入 std::execution::par / par_unseq 让大部分 STL 算法具有并行后端,实现数据并行化。
  • 注意算法是否满足无副作用、迭代器是否随机访问,否则并行执行策略可能抛异常或回退到串行。
  • 对大量数据的转换/归约,可优先尝试平铺在 std::transform_reduce 等并行算法上。

多线程应用的测试和除错

  • 单元测试要覆盖竞态条件:可以通过注入延迟、stress test 或工具(ThreadSanitizer、Helgrind)发现数据竞争。
  • 记录线程、事件时间线(logging + trace)有助于复现场景,必要时写自定义探针。
  • 把并发问题转化为确定性重放:利用固定随机种子、任务排序或伪调度器(virtual time)简化调试。