多线程与并发
C++11 起,标准库提供了完整的并发支持:线程、互斥量、条件变量、原子操作和异步任务。这些设施定义在 <thread>、<mutex>、<condition_variable>、<atomic>、<future> 等头文件中,让并发程序不再依赖平台特定的 API。
但并发编程的困难不在于 API,而在于正确性。数据竞争、死锁、虚假唤醒、内存序错误这些问题往往在测试中难以复现,却会在生产环境造成严重后果。更麻烦的是,很多错误不会立即崩溃,而是表现为「偶尔算错一个数」——这种 bug 的排查成本极高。
Warning
并发问题的本质
只要多个线程同时访问同一块内存,且其中至少一个是写操作,且没有同步措施,就构成数据竞争(data race)——这是未定义行为,编译器可以做任何假设,结果完全不可预测。
加锁或使用原子操作是消除数据竞争的唯一途径。
Note
加锁了也不一定安全
数据竞争只是并发问题的一类。还有一类更隐蔽的问题叫 TOCTOU(检查时刻到使用时刻):即使每一处访问都加了锁,只要「检查」和「使用」被拆到两个临界区,中间的状态变化仍会导致错误。详见互斥量与锁。
本章内容
| 章节 | 说明 |
|---|---|
| 线程基础 | std::thread 与 std::jthread、线程局部存储、启动与结束 |
| 互斥量与锁 | 五种互斥量类型、RAII 包装器、死锁避免、TOCTOU、call_once |
| 条件变量 | 等待与通知、虚假唤醒、生产者消费者模式 |
| 原子操作 | std::atomic、CAS 循环、内存序、无锁编程的限度 |
| 异步任务 | 回调 / Future / 协程三种风格、async、promise、packaged_task |
| 并发模式与陷阱 | 线程池、回调与事件驱动、常见模式与错误汇总 |
怎么选同步手段
面对一个并发问题,先想清楚「线程之间需要交换什么」,再选工具:
| 需求 | 工具 | 说明 |
|---|---|---|
| 保护一小段临界区 | std::mutex + lock_guard | 最常用,优先考虑 |
| 保护多个资源,需要一起加锁 | std::scoped_lock | 自动避免死锁 |
| 读多写少 | std::shared_mutex + shared_lock | 允许多个读者并发 |
| 等待某个条件成立 | std::condition_variable | 配合互斥量使用 |
| 单个变量的原子读写 | std::atomic | 比加锁轻量 |
| 需要「稍后取结果」 | std::future | 配合 async / promise |
| 等待一批任务完成 | std::latch / std::barrier | C++20 |
| 限制并发数量 | std::counting_semaphore | C++20 |
| 请求取消长时间任务 | std::stop_token | C++20,配合 jthread |
默认从互斥量开始。原子操作和无锁结构看起来更快,但正确性极难保证,除非有明确的性能需求,否则不要轻易尝试。
几条经验
- 优先用
std::jthread。它会在析构时自动 join,不会因为忘记join而让程序终止,还内置了取消机制。 - 不要手动
lock()/unlock()。始终用 RAII 包装器,异常路径才安全。 - 共享数据越少越好。最有效的并发优化是减少共享——用消息传递代替共享内存,或者让每个线程持有自己的数据。
- 先保证正确,再考虑性能。
seq_cst最慢但最安全,确认瓶颈后再降级内存序。 - 并发 bug 靠工具找。用 ThreadSanitizer(
-fsanitize=thread)而非肉眼审查。
与内存模型的分工
本章讲怎么用这些并发设施,C++ 内存模型 讲为什么它们能工作——happens-before 关系、内存序的语义、编译器与 CPU 的重排规则。
如果只是想写出正确的并发代码,本章足够;如果需要理解底层机制或做无锁编程,建议两章对照阅读。