第 22.19.4 节

并发编程

作者
董佳辉的头像董佳辉
发布于 2023/10/05更新于 2026/08/12

C++11 开始,标准库正式提供线程、互斥量、条件变量、原子操作和异步任务等并发工具。到了 C++17、C++20,又继续增加了 std::scoped_lock、std::shared_mutex、std::jthread、std::stop_token、std::latch、std::barrier、std::semaphore 等能力。

并发编程真正困难的地方并不是“创建一个线程”,而是下面几个问题:

  1. 线程什么时候开始、什么时候结束,谁负责等待它?

  2. 线程函数拿到的参数是副本、引用还是指针?对象会不会提前销毁?

  3. 多个线程同时访问同一份数据时,怎样避免数据竞争?

  4. 多把锁同时使用时,怎样避免死锁?

  5. 一个线程没有工作时,怎样高效等待,而不是一直循环占用 CPU?

  6. 一个任务将在未来产生结果时,怎样取得它的返回值或异常?

  7. 程序退出时,怎样让后台线程安全停止?

本章按照这些问题逐步展开。

并发与并行

概念含义典型情况
并发(concurrency)多个任务在同一段时间内推进单核 CPU 在多个线程之间切换;程序同时等待多个 IO
并行(parallelism)多个任务在同一时刻真正执行多核 CPU 上多个线程同时计算

并发是一种程序组织方式,并行是一种实际执行状态。并发程序不一定并行,但并行执行通常建立在并发任务之上。

线程安全最先要理解:数据竞争

如果两个线程同时访问同一个内存位置,并且至少有一个线程在写,而这些访问之间又没有正确同步,就可能产生 data race(数据竞争)。

在 C++ 内存模型中,数据竞争通常意味着 未定义行为(undefined behavior),并不是简单的“最后结果偶尔少加几次”。编译器可以在未定义行为前提下做出很多看起来反直觉的优化。

例如:

int counter = 0;

void work()
{
    ++counter;
}

++counter 并不等于“天然不可分割的一条操作”。它通常包含读取、计算和写回。多个线程交错执行时就可能丢失更新。

常见解决方案包括:

  • 用 std::mutex 保护一段共享状态;

  • 对简单独立变量使用 std::atomic;

  • 尽量减少共享可变状态,让每个线程处理自己的数据;

  • 需要等待某个条件时使用 std::condition_variable,而不是不断轮询。

学习路线

本节拆成以下子章节:

顺序章节重点
1std::thread线程创建、可调用对象、参数传递、join()、detach()、joinable()、移动语义、线程生命周期
2mutex 与 RAII 锁mutex、lock_guard、unique_lock、scoped_lock、死锁、读写锁、一次初始化
3std::atomicload/store、原子读改写、CAS、内存序基础、C++20 wait/notify
4condition_variable等待/通知、谓词、虚假唤醒、超时等待、生产者消费者
5future / async / promise异步任务返回值、异常传播、packaged_task、shared_future
6jthread / stop_tokenC++20 自动 join、协作式停止、线程退出设计
7semaphore / latch / barrierC++20 许可计数、一次性会合、可重复阶段同步

建议严格按顺序学习。后面的章节会默认已经理解前面的生命周期、RAII 和同步概念。

常用并发工具总览

工具标准作用头文件
std::threadC++11创建和管理线程<thread>
std::mutexC++11互斥访问共享状态<mutex>
std::lock_guardC++11简单 RAII 加锁<mutex>
std::unique_lockC++11可手动解锁、延迟加锁、可移动的 RAII 锁<mutex>
std::scoped_lockC++17同时管理一把或多把锁<mutex>
std::shared_mutexC++17多读单写<shared_mutex>
std::condition_variableC++11等待共享条件变化<condition_variable>
std::atomic<T>C++11对单个对象执行原子操作<atomic>
std::futureC++11接收未来产生的结果<future>
std::asyncC++11以任务形式异步执行函数<future>
std::promiseC++11主动向 future 写入结果或异常<future>
std::packaged_taskC++11把可调用对象包装成可产生 future 的任务<future>
std::jthreadC++20析构时自动请求停止并等待线程结束<thread>
std::stop_tokenC++20协作式停止请求<stop_token>
std::counting_semaphoreC++20计数信号量<semaphore>
std::latchC++20一次性计数同步点<latch>
std::barrierC++20可重复阶段同步点<barrier>

后三种 C++20 同步工具在普通业务代码里没有前几项常用,因此应先把核心并发模型学扎实,再按实际需求使用。

编译

GCC / Clang 在 Linux 上使用标准线程库时通常需要链接 pthread:

g++ demo.cpp -std=c++23 -pthread -O2 -Wall -Wextra

本章节示例统一使用 C++23 的 <print>、std::print() 和 std::println() 写终端输出;并发工具本身的标准版本仍以各节说明为准。

-pthread 不只是简单添加一个库,它还可能影响编译阶段与线程相关的宏和选项,因此通常应直接使用 -pthread。

并发代码的基本原则

  1. 先保证正确,再讨论性能。 一个存在数据竞争的程序即使“跑起来很快”也没有意义。

  2. 优先减少共享状态。 不共享就不需要同步。

  3. 锁要由 RAII 对象管理。 除非确有必要,不要手写成对的 lock() / unlock()。

  4. 锁保护的是不变量,不只是某一行代码。 如果几个成员必须保持一致,应由同一把锁共同保护。

  5. 不要持锁执行耗时操作。 网络、文件 IO、休眠、大量计算通常应放在临界区外。

  6. 不要轻易 detach()。 脱离线程很容易制造生命周期问题。

  7. 线程停止应该有明确协议。 C++20 优先了解 std::jthread + std::stop_token。

  8. atomic 不等于“永远无锁”。 它保证操作的原子语义,但具体实现是否 lock-free 与类型和平台有关。

推荐掌握程度

学习完本章后,至少应该能够回答:

  • 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 分别对应什么同步模式?

如果这些问题能够清楚回答,才算真正掌握了标准库并发基础。