读《C++并发编程实战 -- C++ Concurrency In Action》
C++ 并发编程基础
什么是并发
并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。
并发的方式包括多进程并发和多线程并发,多进程并发通过进程间通信(信号、套接字、文件、管道等)来相互传递信息;由于同一进程内的所有线程都共用相同的地址空间,所以多进程并发通过共享内存来同步数据。
并发技术可以:
- 分离关注点(separation of concerns),使得不同的线程关注不同的任务;
- 提升性能。任务并行可以采取两种方式,一种是将单一任务分成多个部分,各自并行运作,从而节省总运行耗时;第二种是利用并行资源解决规模更大的问题。
什么时候避免并发?
- 并发会增加额外的复杂度,增加开发时间和维护成本
- 多线程的性能增幅可能不如预期,因为线程的启动会有额外的时间开销
- 线程是一种有限的资源,过多的线程会消耗系统资源,从而导致系统整体变慢
- 运行的线程越多,操作系统的上下文切换就越频繁,上下文切换会减少本该用于实质工作的时间
我们先尝试一个简单的多线程版本的 Hello World
1 |
|
线程管控
线程的管控可以通过 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)简化调试。