Wiki
并发编程
An English translation is not available yet. The latest Simplified Chinese source is shown without triggering paid translation.
C++11 开始,标准库正式提供线程、互斥量、条件变量、原子操作和异步任务等并发工具。到了 C++17、C++20,又继续增加了 std::scoped_lock、std::shared_mutex、std::jthread、std::stop_token、std::latch、std::barrier、std::semaphore 等能力。
并发编程真正困难的地方并不是“创建一个线程”,而是下面几个问题:
- 线程什么时候开始、什么时候结束,谁负责等待它?
- 线程函数拿到的参数是副本、引用还是指针?对象会不会提前销毁?
- 多个线程同时访问同一份数据时,怎样避免数据竞争?
- 多把锁同时使用时,怎样避免死锁?
- 一个线程没有工作时,怎样高效等待,而不是一直循环占用 CPU?
- 一个任务将在未来产生结果时,怎样取得它的返回值或异常?
- 程序退出时,怎样让后台线程安全停止?
本章按照这些问题逐步展开。
1.并发与并行
| 概念 | 含义 | 典型情况 |
|---|---|---|
| 并发(concurrency) | 多个任务在同一段时间内推进 | 单核 CPU 在多个线程之间切换;程序同时等待多个 IO |
| 并行(parallelism) | 多个任务在同一时刻真正执行 | 多核 CPU 上多个线程同时计算 |
并发是一种程序组织方式,并行是一种实际执行状态。并发程序不一定并行,但并行执行通常建立在并发任务之上。
2.线程安全最先要理解:数据竞争
如果两个线程同时访问同一个内存位置,并且至少有一个线程在写,而这些访问之间又没有正确同步,就可能产生 data race(数据竞争)。
在 C++ 内存模型中,数据竞争通常意味着 未定义行为(undefined behavior),并不是简单的“最后结果偶尔少加几次”。编译器可以在未定义行为前提下做出很多看起来反直觉的优化。
例如:
int counter = 0;
void work()
{
++counter;
}
++counter 并不等于“天然不可分割的一条操作”。它通常包含读取、计算和写回。多个线程交错执行时就可能丢失更新。
常见解决方案包括:
- 用
std::mutex保护一段共享状态; - 对简单独立变量使用
std::atomic; - 尽量减少共享可变状态,让每个线程处理自己的数据;
- 需要等待某个条件时使用
std::condition_variable,而不是不断轮询。
3.学习路线
本节拆成以下子章节:
| 顺序 | 章节 | 重点 |
|---|---|---|
| 1 | std::thread |
线程创建、可调用对象、参数传递、join()、detach()、joinable()、移动语义、线程生命周期 |
| 2 | mutex 与 RAII 锁 |
mutex、lock_guard、unique_lock、scoped_lock、死锁、读写锁、一次初始化 |
| 3 | std::atomic |
load/store、原子读改写、CAS、内存序基础、C++20 wait/notify |
| 4 | condition_variable |
等待/通知、谓词、虚假唤醒、超时等待、生产者消费者 |
| 5 | future / async / promise |
异步任务返回值、异常传播、packaged_task、shared_future |
| 6 | jthread / stop_token |
C++20 自动 join、协作式停止、线程退出设计 |
| 7 | semaphore / latch / barrier |
C++20 许可计数、一次性会合、可重复阶段同步 |
建议严格按顺序学习。后面的章节会默认已经理解前面的生命周期、RAII 和同步概念。
4.常用并发工具总览
| 工具 | 标准 | 作用 | 头文件 |
|---|---|---|---|
std::thread |
C++11 | 创建和管理线程 | <thread> |
std::mutex |
C++11 | 互斥访问共享状态 | <mutex> |
std::lock_guard |
C++11 | 简单 RAII 加锁 | <mutex> |
std::unique_lock |
C++11 | 可手动解锁、延迟加锁、可移动的 RAII 锁 | <mutex> |
std::scoped_lock |
C++17 | 同时管理一把或多把锁 | <mutex> |
std::shared_mutex |
C++17 | 多读单写 | <shared_mutex> |
std::condition_variable |
C++11 | 等待共享条件变化 | <condition_variable> |
std::atomic<T> |
C++11 | 对单个对象执行原子操作 | <atomic> |
std::future |
C++11 | 接收未来产生的结果 | <future> |
std::async |
C++11 | 以任务形式异步执行函数 | <future> |
std::promise |
C++11 | 主动向 future 写入结果或异常 |
<future> |
std::packaged_task |
C++11 | 把可调用对象包装成可产生 future 的任务 |
<future> |
std::jthread |
C++20 | 析构时自动请求停止并等待线程结束 | <thread> |
std::stop_token |
C++20 | 协作式停止请求 | <stop_token> |
std::counting_semaphore |
C++20 | 计数信号量 | <semaphore> |
std::latch |
C++20 | 一次性计数同步点 | <latch> |
std::barrier |
C++20 | 可重复阶段同步点 | <barrier> |
后三种 C++20 同步工具在普通业务代码里没有前几项常用,因此应先把核心并发模型学扎实,再按实际需求使用。
5.编译
GCC / Clang 在 Linux 上使用标准线程库时通常需要链接 pthread:
g++ demo.cpp -std=c++23 -pthread -O2 -Wall -Wextra
本章节示例统一使用 C++23 的 <print>、std::print() 和 std::println() 写终端输出;并发工具本身的标准版本仍以各节说明为准。
-pthread 不只是简单添加一个库,它还可能影响编译阶段与线程相关的宏和选项,因此通常应直接使用 -pthread。
6.并发代码的基本原则
- 先保证正确,再讨论性能。 一个存在数据竞争的程序即使“跑起来很快”也没有意义。
- 优先减少共享状态。 不共享就不需要同步。
- 锁要由 RAII 对象管理。 除非确有必要,不要手写成对的
lock()/unlock()。 - 锁保护的是不变量,不只是某一行代码。 如果几个成员必须保持一致,应由同一把锁共同保护。
- 不要持锁执行耗时操作。 网络、文件 IO、休眠、大量计算通常应放在临界区外。
- 不要轻易
detach()。 脱离线程很容易制造生命周期问题。 - 线程停止应该有明确协议。 C++20 优先了解
std::jthread+std::stop_token。 atomic不等于“永远无锁”。 它保证操作的原子语义,但具体实现是否 lock-free 与类型和平台有关。
7.推荐掌握程度
学习完本章后,至少应该能够回答:
std::thread t(f, a, b)中第一个参数和后面的参数分别是什么?- 为什么给
T&形参传线程参数时常需要std::ref()? joinable()到底判断“线程是否还在运行”,还是判断“线程对象是否仍关联执行线程”?- 为什么一个已经执行完函数的
std::thread仍可能joinable() == true? join()和detach()对线程对象状态有什么影响?- 为什么 joinable 的
std::thread析构会std::terminate()? lock_guard、unique_lock、scoped_lock分别适合什么场景?- 两把互斥量为什么会产生死锁,怎样避免?
atomic与mutex应该怎样选择?- 为什么条件变量必须围绕“共享状态 + mutex + 谓词”使用?
future和promise分别站在结果通道的哪一端?jthread相比thread解决了哪些生命周期问题?semaphore、latch、barrier分别对应什么同步模式?
如果这些问题能够清楚回答,才算真正掌握了标准库并发基础。