跳到正文

Wiki

并发编程

约 7 分钟阅读

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

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

  1. 线程什么时候开始、什么时候结束,谁负责等待它?
  2. 线程函数拿到的参数是副本、引用还是指针?对象会不会提前销毁?
  3. 多个线程同时访问同一份数据时,怎样避免数据竞争?
  4. 多把锁同时使用时,怎样避免死锁?
  5. 一个线程没有工作时,怎样高效等待,而不是一直循环占用 CPU?
  6. 一个任务将在未来产生结果时,怎样取得它的返回值或异常?
  7. 程序退出时,怎样让后台线程安全停止?

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

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 锁 mutexlock_guardunique_lockscoped_lock、死锁、读写锁、一次初始化
3 std::atomic load/store、原子读改写、CAS、内存序基础、C++20 wait/notify
4 condition_variable 等待/通知、谓词、虚假唤醒、超时等待、生产者消费者
5 future / async / promise 异步任务返回值、异常传播、packaged_taskshared_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++20 -pthread -O2 -Wall -Wextra

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

6.并发代码的基本原则

  1. 先保证正确,再讨论性能。 一个存在数据竞争的程序即使“跑起来很快”也没有意义。
  2. 优先减少共享状态。 不共享就不需要同步。
  3. 锁要由 RAII 对象管理。 除非确有必要,不要手写成对的 lock() / unlock()
  4. 锁保护的是不变量,不只是某一行代码。 如果几个成员必须保持一致,应由同一把锁共同保护。
  5. 不要持锁执行耗时操作。 网络、文件 IO、休眠、大量计算通常应放在临界区外。
  6. 不要轻易 detach() 脱离线程很容易制造生命周期问题。
  7. 线程停止应该有明确协议。 C++20 优先了解 std::jthread + std::stop_token
  8. 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_guardunique_lockscoped_lock 分别适合什么场景?
  • 两把互斥量为什么会产生死锁,怎样避免?
  • atomicmutex 应该怎样选择?
  • 为什么条件变量必须围绕“共享状态 + mutex + 谓词”使用?
  • futurepromise 分别站在结果通道的哪一端?
  • jthread 相比 thread 解决了哪些生命周期问题?
  • semaphorelatchbarrier 分别对应什么同步模式?

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