C++ 并发编程详解 —— 由浅入深
一、从问题出发:为什么需要并发?
1.1 一个直观的例子
想象你在经营一家餐厅:
单线程(一个服务员):
┌────────────────────────────────────────┐
│ 服务员小王的一天: │
│ │
│ 1. 带客人入座 (5 分钟) │
│ 2. 等客人点菜 (10 分钟) ← 等待中... │
│ 3. 下单给厨房 (2 分钟) │
│ 4. 等菜做好 (30 分钟) ← 等待中... │
│ 5. 上菜 (3 分钟) │
│ 6. 等客人吃完 (20 分钟) ← 等待中... │
│ 7. 结账 (5 分钟) │
│ │
│ 总计:75 分钟,只服务了一桌客人! │
└────────────────────────────────────────┘
多线程(多个服务员):
┌────────────────────────────────────────┐
│ 服务员小王 + 小李 + 小张: │
│ │
│ 小王:带客人入座 → 下单 → 上菜 → 结账 │
│ 小李:在 A 桌等待点菜时,去服务 B 桌 │
│ 小张:在等菜做好时,去服务 C 桌 │
│ │
│ 同一时间服务多桌客人,效率翻倍! │
└────────────────────────────────────────┘
1.2 程序中的例子
// 单线程:顺序执行
void single_thread() {
// 任务 1: 下载文件 (耗时 10 秒)
download_file("file1.zip"); // 等待 10 秒...
// 任务 2: 处理数据 (耗时 5 秒)
process_data(); // 等待 5 秒...
// 任务 3: 更新界面 (耗时 1 秒)
update_ui(); // 等待 1 秒...
// 总计:16 秒,界面 16 秒无响应!
}
// 多线程:并发执行
void multi_thread() {
// 启动下载线程
std::thread t1(download_file, "file1.zip");
// 主线程继续响应用户操作
while (downloading) {
handle_user_input(); // 界面仍然流畅
}
t1.join(); // 等待下载完成
}
二、核心概念:什么是线程?
2.1 进程 vs 线程
进程 (Process):
┌─────────────────────────────────┐
│ 进程 A │
│ ┌─────────────────────────┐ │
│ │ 内存空间 (代码、数据) │ │
│ │ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │线程 1│ │线程 2│ │线程 3│ │ │
│ │ └───┘ └───┘ └───┘ │ │
│ │ ↑ ↑ ↑ │ │
│ │ 共享同一块内存 │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
线程 (Thread):
- 进程中的一个执行单元
- 同一进程的线程共享内存
- 每个线程有自己的栈和寄存器
2.2 线程的内存模型
┌──────────────────────────────────────────┐
│ 进程内存空间 │
│ │
│ ┌────────────────────────────────────┐ │
│ │ 代码段 │ │ ← 所有线程共享
│ ├────────────────────────────────────┤ │
│ │ 数据段 │ │ ← 所有线程共享
│ ├────────────────────────────────────┤ │
│ │ 堆 (全局变量) │ │ ← 所有线程共享
│ ├────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ │ │
│ │ │ 线程 1 栈 │ │ 线程 2 栈 │ ... │ │ ← 每个线程独立
│ │ └─────────┘ └─────────┘ │ │
│ │ 局部变量 局部变量 │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
关键:
- 堆上的数据:线程共享 → 需要同步
- 栈上的数据:线程独立 → 无需同步
三、为什么需要锁?—— 数据竞争问题
3.1 一个直观的例子
想象银行取款:
账户余额:1000 元
线程 A (ATM 取款) 线程 B (柜台取款)
↓ ↓
1. 读取余额:1000 1. 读取余额:1000
↓ ↓
2. 计算:1000 - 100 = 900 2. 计算:1000 - 200 = 800
↓ ↓
3. 写入余额:900 3. 写入余额:800
↓ ↓
最终余额:800 元 ← 错了!应该是 700 元!
3.2 代码示例:数据竞争
#include <iostream>
#include <thread>
#include <vector>
int balance = 1000; // 共享数据
void withdraw(int amount) {
// 步骤 1: 读取余额
int current = balance;
// 步骤 2: 计算新余额
current -= amount;
// 步骤 3: 写入余额
balance = current;
}
int main() {
std::thread t1(withdraw, 100); // ATM 取 100
std::thread t2(withdraw, 200); // 柜台取 200
t1.join();
t2.join();
std::cout << "最终余额:" << balance << std::endl;
// 可能是 800 或 900,但绝不是 700!
return 0;
}
3.3 问题根源:竞态条件
时间线分析:
时刻 线程 A 线程 B balance
----------------------------------------------------
T1 读取 balance=1000 1000
T2 读取 balance=1000 1000
T3 计算 1000-100=900 1000
T4 计算 1000-200=800 1000
T5 写入 balance=900 900
T6 写入 balance=800 800 ← 覆盖了 A 的结果!
这就是数据竞争 (Data Race)!
四、锁:解决数据竞争
4.1 互斥锁 (mutex) 的工作原理
互斥锁就像卫生间的锁:
┌────────────────────────────────────────┐
│ 卫生间 (共享资源) │
│ ┌─────┐ │
│ │ 锁 │ │
│ └─────┘ │
└────────────────────────────────────────┘
线程 A: 线程 B:
↓ ↓
1. 尝试锁门 1. 尝试锁门
↓ ↓
2. 成功!进入使用 2. 失败!门外等待
↓ ↓
3. 使用完毕 3. 继续等待...
↓ ↓
4. 解锁开门 4. 成功!进入使用
↓
5. 使用完毕
↓
6. 解锁开门
4.2 代码示例:用锁保护共享数据
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
int balance = 1000; // 共享数据
std::mutex mtx; // 互斥锁
void withdraw(int amount) {
mtx.lock(); // 1. 加锁
// 临界区:同一时间只有一个线程能执行
int current = balance; // 2. 读取
current -= amount; // 3. 计算
balance = current; // 4. 写入
mtx.unlock(); // 5. 解锁
}
int main() {
std::thread t1(withdraw, 100);
std::thread t2(withdraw, 200);
t1.join();
t2.join();
std::cout << "最终余额:" << balance << std::endl;
// 现在一定是 700!
return 0;
}
4.3 更好的方式:lock_guard (RAII)
#include <mutex>
std::mutex mtx;
int balance = 1000;
void withdraw_safe(int amount) {
// lock_guard 在构造时加锁,析构时解锁
std::lock_guard<std::mutex> lock(mtx);
// 临界区
balance -= amount;
// 函数返回时,lock_guard 析构,自动解锁
// 即使抛出异常也会解锁!
}
// 对比:手动锁的问题
void withdraw_unsafe(int amount) {
mtx.lock();
balance -= amount;
if (balance < 0) {
mtx.unlock(); // 可能忘记解锁!
throw std::runtime_error("余额不足");
}
mtx.unlock(); // 可能忘记解锁!
}
4.4 为什么用 RAII?
场景:临界区中可能抛出异常
不用 RAII (危险):
void dangerous() {
mtx.lock();
// 步骤 1
do_something();
// 步骤 2: 可能抛出异常!
might_throw(); // ← 异常!下面的 unlock 不会执行
// 步骤 3
do_more();
mtx.unlock(); // ← 不会执行!死锁!
}
使用 RAII (安全):
void safe() {
std::lock_guard<std::mutex> lock(mtx); // 构造时加锁
do_something();
might_throw(); // ← 即使异常
do_more();
} // ← 离开作用域,lock_guard 析构,自动解锁
五、条件变量:线程间通信
5.1 为什么需要条件变量?
场景:生产者 - 消费者问题
┌─────────────┐ ┌─────────────┐
│ 生产者线程 │ ────→ │ 缓冲区 │ ────→ │ 消费者线程 │
│ 生产数据 │ 放入 │ (队列) │ 取出 │ 消费数据 │
└─────────────┘ └─────────────┘ └─────────────┘
问题:
1. 缓冲区空时,消费者怎么办? → 等待
2. 缓冲区满时,生产者怎么办? → 等待
3. 如何通知对方? → 条件变量
5.2 条件变量的工作原理
条件变量就像"叫醒服务":
消费者线程: 生产者线程:
↓ ↓
1. 锁住缓冲区 1. 锁住缓冲区
↓ ↓
2. 检查:有数据吗? 2. 生产数据
↓ ↓
3. 没有!等待条件变量 ←──────── 3. 放入数据
(释放锁,进入等待) ↓
↓ (睡眠中...) 4. 通知条件变量
↓ ┌───→ (唤醒等待者)
↓ │ ↓
↓ (被唤醒) ←──────────────┘ 5. 解锁
↓
4. 重新获取锁
↓
5. 再次检查:有数据吗?
↓
6. 有!取走数据
↓
7. 解锁
5.3 代码示例:生产者 - 消费者
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>
std::queue<int> buffer; // 共享缓冲区
std::mutex mtx; // 互斥锁
std::condition_variable cv; // 条件变量
const int MAX_SIZE = 10; // 缓冲区最大容量
// 生产者
void producer(int id) {
for (int i = 0; i < 20; i++) {
std::unique_lock<std::mutex> lock(mtx);
// 等待:缓冲区不满
cv.wait(lock, [] { return buffer.size() < MAX_SIZE; });
// 生产数据
buffer.push(i);
std::cout << "生产者" << id << "生产:" << i << std::endl;
// 通知消费者
cv.notify_one();
}
}
// 消费者
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
// 等待:缓冲区不空
cv.wait(lock, [] { return !buffer.empty(); });
// 消费数据
int data = buffer.front();
buffer.pop();
std::cout << "消费者消费:" << data << std::endl;
// 通知生产者
cv.notify_one();
// 退出条件
if (data == 19) break;
}
}
int main() {
std::thread p1(producer, 1);
std::thread p2(producer, 2);
std::thread c(consumer);
p1.join();
p2.join();
c.join();
return 0;
}
5.4 为什么 wait 要在循环里?
// ❌ 错误:用 if 检查
void consumer_wrong() {
std::unique_lock<std::mutex> lock(mtx);
if (buffer.empty()) { // ← 问题在这里!
cv.wait(lock); // 被唤醒后直接继续
}
// 可能有多个消费者,数据已被别人取走!
int data = buffer.front(); // ← 可能崩溃!
}
// ✅ 正确:用 while 检查
void consumer_correct() {
std::unique_lock<std::mutex> lock(mtx);
while (buffer.empty()) { // ← 循环检查
cv.wait(lock);
}
// 确保真的有数据
int data = buffer.front(); // ← 安全
}
// 或者使用带谓词的 wait (推荐)
cv.wait(lock, [] { return !buffer.empty(); });
5.5 虚假唤醒 (Spurious Wakeup)
什么是虚假唤醒?
线程 A 等待条件变量
↓
操作系统:唤醒线程 A (但没有通知!)
↓
线程 A:从 wait 返回
↓
如果不用循环检查 → 错误执行!
为什么会有虚假唤醒?
- 操作系统优化
- 多处理器同步问题
- 信号中断
解决方案:永远用循环检查条件!
六、原子操作:无锁编程
6.1 为什么需要原子操作?
普通操作的问题:
counter++ 看起来是一条语句,实际是三步:
1. 读取 counter 的值
2. 加 1
3. 写回 counter
多线程同时执行:
线程 A 线程 B
↓ ↓
读取 counter=0 读取 counter=0
↓ ↓
计算 0+1=1 计算 0+1=1
↓ ↓
写入 counter=1 写入 counter=1
↓ ↓
结果:counter=1 ← 错了!应该是 2!
6.2 原子操作保证
原子操作:不可分割的操作
counter++ (原子版本):
线程 A 线程 B
↓ ↓
[读取 - 加 1- 写回] 等待...
↓ ↓
counter=1 [读取 - 加 1- 写回]
↓
counter=2 ← 正确!
硬件保证:原子操作执行时,其他线程无法打断
6.3 代码示例:atomic
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
// 普通变量 (不安全)
int normal_counter = 0;
// 原子变量 (安全)
std::atomic<int> atomic_counter(0);
void increment_normal() {
for (int i = 0; i < 10000; i++) {
normal_counter++; // 数据竞争!
}
}
void increment_atomic() {
for (int i = 0; i < 10000; i++) {
atomic_counter++; // 安全!
}
}
int main() {
std::vector<std::thread> threads;
// 测试普通变量
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment_normal);
}
for (auto& t : threads) t.join();
std::cout << "普通变量:" << normal_counter << std::endl;
// 可能是 50000-100000 之间的任意值
threads.clear();
// 测试原子变量
for (int i = 0; i < 10; i++) {
threads.emplace_back(increment_atomic);
}
for (auto& t : threads) t.join();
std::cout << "原子变量:" << atomic_counter << std::endl;
// 一定是 100000!
return 0;
}
6.4 原子操作 vs 锁
| 特性 | 原子操作 | 互斥锁 |
|---|---|---|
| 粒度 | 单个变量 | 代码块 |
| 性能 | 高 (无锁) | 中 (有开销) |
| 复杂度 | 低 | 中 |
| 适用场景 | 计数器、标志位 | 复杂数据结构 |
| 死锁风险 | 无 | 有 |
// 适合原子操作的场景
std::atomic<bool> running(true); // 标志位
std::atomic<int> counter(0); // 计数器
std::atomic<void*> ptr(nullptr); // 指针
// 适合锁的场景
std::map<int, std::string> data; // 复杂数据结构
std::vector<int> queue; // 需要多个操作的容器
七、线程池:为什么需要?
7.1 线程创建的成本
创建线程的成本:
1. 系统调用开销
└─> 进入内核态
2. 内存分配
└─> 栈空间 (默认 1-8MB)
3. 内核数据结构
└─> task_struct 等
4. 上下文切换
└─> 保存/恢复寄存器
频繁创建销毁线程 = 浪费资源!
7.2 线程池的思想
不用线程池:
任务 1 → 创建线程 → 执行 → 销毁线程
任务 2 → 创建线程 → 执行 → 销毁线程
任务 3 → 创建线程 → 执行 → 销毁线程
...
成本高!
使用线程池:
┌─────────────────────────────────────┐
│ 线程池 │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │线程 1│ │线程 2│ │线程 3│ │线程 4│ │ ← 预先创建
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ ↑ ↑ ↑ ↑ │
│ └───────┴───────┴───────┘ │
│ 任务队列 │
└─────────────────────────────────────┘
任务 1,2,3,4... → 放入任务队列 → 空闲线程取走执行
线程复用,成本低!
7.3 简单线程池实现
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <vector>
class ThreadPool {
public:
ThreadPool(size_t num_threads) {
// 创建指定数量的工作线程
for (size_t i = 0; i < num_threads; ++i) {
workers_.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex_);
// 等待任务或停止信号
condition_.wait(lock, [this] {
return stop_ || !tasks_.empty();
});
if (stop_ && tasks_.empty()) {
return; // 退出线程
}
task = std::move(tasks_.front());
tasks_.pop();
}
// 执行任务 (不在锁内)
task();
}
});
}
}
// 提交任务
template<class F>
void submit(F&& f) {
{
std::unique_lock<std::mutex> lock(queue_mutex_);
tasks_.emplace(std::forward<F>(f));
}
condition_.notify_one();
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex_);
stop_ = true;
}
condition_.notify_all();
for (std::thread& worker : workers_) {
worker.join();
}
}
private:
std::vector<std::thread> workers_; // 工作线程
std::queue<std::function<void()>> tasks_; // 任务队列
std::mutex queue_mutex_; // 队列锁
std::condition_variable condition_; // 条件变量
bool stop_ = false; // 停止标志
};
// 使用示例
int main() {
ThreadPool pool(4); // 4 个工作线程
// 提交 100 个任务
for (int i = 0; i < 100; i++) {
pool.submit([i] {
std::cout << "任务 " << i << " 执行中..." << std::endl;
});
}
// 等待所有任务完成 (实际使用需要更完善的等待机制)
std::this_thread::sleep_for(std::chrono::seconds(1));
return 0;
}
八、内存模型:为什么需要?
8.1 指令重排序问题
代码:
bool ready = false; // 1. 准备标志
int data = 0;
// 线程 1: 生产
data = 42; // 2. 写入数据
ready = true; // 3. 设置标志
// 线程 2: 消费
while (!ready); // 4. 等待标志
use(data); // 5. 使用数据
问题:编译器/CPU 可能重排序!
线程 1 实际执行顺序可能是:
ready = true; // 先设置标志
data = 42; // 后写入数据
线程 2 看到:
ready = true ← 但 data 还没写入!
use(data) ← 读到旧值!
8.2 内存序 (Memory Order)
#include <atomic>
std::atomic<int> data(0);
std::atomic<bool> ready(false);
// 线程 1: 生产
void producer() {
data.store(42, std::memory_order_relaxed); // 宽松序
ready.store(true, std::memory_order_release); // 释放序
}
// 线程 2: 消费
void consumer() {
while (!ready.load(std::memory_order_acquire)); // 获取序
// 保证能看到 data = 42
use(data.load(std::memory_order_relaxed));
}
8.3 内存序类型
memory_order_relaxed (宽松序):
- 只保证原子性
- 不保证顺序
- 性能最高
- 适用:计数器
memory_order_acquire (获取序):
- 读操作
- 保证之后的读写不会被重排到前面
- 适用:获取锁、读取标志
memory_order_release (释放序):
- 写操作
- 保证之前的读写不会被重排到后面
- 适用:释放锁、设置标志
memory_order_acq_rel (获取 - 释放序):
- 读 + 写
- acquire + release
- 适用:原子交换
memory_order_seq_cst (顺序一致序):
- 最严格
- 所有线程看到相同顺序
- 默认选项
- 性能最低
九、死锁:如何避免
9.1 死锁的四个必要条件
死锁产生需要同时满足四个条件:
1. 互斥条件
└─> 资源一次只能被一个线程占用
2. 请求与保持
└─> 线程持有资源的同时请求其他资源
3. 不剥夺
└─> 已获得的资源不能被强制剥夺
4. 循环等待
└─> 形成等待环:A→B→C→A
打破任一条件即可避免死锁!
9.2 死锁示例
std::mutex mtx1, mtx2;
// 线程 1
void thread1() {
mtx1.lock(); // 1. 锁 mtx1
std::this_thread::sleep_for(std::chrono::milliseconds(10));
mtx2.lock(); // 2. 锁 mtx2 (可能等待)
// 临界区
mtx2.unlock();
mtx1.unlock();
}
// 线程 2
void thread2() {
mtx2.lock(); // 1. 锁 mtx2
std::this_thread::sleep_for(std::chrono::milliseconds(10));
mtx1.lock(); // 2. 锁 mtx1 (可能等待)
// 临界区
mtx1.unlock();
mtx2.unlock();
}
// 死锁场景:
// 线程 1: 持有 mtx1,等待 mtx2
// 线程 2: 持有 mtx2,等待 mtx1
// 互相等待,永不执行!
9.3 避免死锁的方法
// 方法 1: 固定顺序加锁 (打破循环等待)
void safe_lock() {
// 总是先锁 mtx1,再锁 mtx2
mtx1.lock();
mtx2.lock();
// ...
mtx2.unlock();
mtx1.unlock();
}
// 方法 2: 使用 std::lock (同时加锁)
void safer_lock() {
std::lock(mtx1, mtx2); // 同时锁两个,避免死锁
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
// ...
}
// 方法 3: 使用 std::scoped_lock (C++17)
void safest_lock() {
std::scoped_lock lock(mtx1, mtx2); // 最简单!
// ...
}
// 方法 4: 尝试加锁 (带超时)
bool try_lock() {
if (mtx1.try_lock_for(std::chrono::milliseconds(100))) {
if (mtx2.try_lock_for(std::chrono::milliseconds(100))) {
// 成功
mtx2.unlock();
mtx1.unlock();
return true;
}
mtx1.unlock();
}
return false; // 失败,稍后重试
}
十、总结:并发编程核心思想
10.1 为什么这么设计?
1. 为什么需要锁?
└─> 保护共享数据,避免数据竞争
2. 为什么用 RAII 管理锁?
└─> 自动释放,避免死锁,异常安全
3. 为什么需要条件变量?
└─> 线程间通信,避免忙等待
4. 为什么用原子操作?
└─> 无锁编程,更高性能
5. 为什么需要线程池?
└─> 减少线程创建开销,复用线程
6. 为什么需要内存序?
└─> 控制指令重排,保证正确性
7. 为什么会有死锁?
└─> 多个锁的循环等待
10.2 最佳实践
// 1. 优先使用高级抽象
std::async([] { /* 任务 */ }); // 比 thread 更简单
std::atomic<int> counter; // 比 mutex 更高效
std::scoped_lock lock(mtx1, mtx2); // 比手动 lock 更安全
// 2. 减少共享数据
void thread_func() {
int local = 0; // 栈上数据,无需同步
// 只在线程结束时返回结果
return local;
}
// 3. 明确所有权
class ThreadSafeQueue {
std::queue<int> queue_; // 私有数据
mutable std::mutex mtx_; // 保护数据
public:
void push(int val) {
std::scoped_lock lock(mtx_);
queue_.push(val);
}
};
// 4. 避免在锁内做耗时操作
void bad() {
std::lock_guard<std::mutex> lock(mtx_);
slow_operation(); // 锁住太久!
}
void good() {
int data;
{
std::lock_guard<std::mutex> lock(mtx_);
data = quick_copy(); // 只复制数据
}
slow_operation(data); // 在锁外执行
}
10.3 工具选择指南
场景 推荐工具
─────────────────────────────────────────
计数器/标志位 std::atomic
保护共享数据结构 std::mutex + std::lock_guard
线程间等待通知 std::condition_variable
生产者 - 消费者 std::queue + mutex + condition_variable
一次性初始化 std::call_once
异步任务 std::async + std::future
大量短任务 线程池
读多写少 std::shared_mutex
十一、快速参考
// 包含头文件
#include <thread> // 线程
#include <mutex> // 互斥锁
#include <condition_variable> // 条件变量
#include <atomic> // 原子操作
#include <future> // 异步任务
// 创建线程就是
std::thread t(func, arg1, arg2);
t.join(); // 等待完成
t.detach(); // 分离线程
// 互斥锁
std::mutex mtx;
std::lock_guard<std::mutex> lock(mtx);
std::unique_lock<std::mutex> ulock(mtx);
// 条件变量
std::condition_variable cv;
cv.wait(lock, [] { return condition; });
cv.notify_one();
cv.notify_all();
// 原子操作
std::atomic<int> counter(0);
counter++;
counter.load();
counter.store(10);
// 异步任务
auto future = std::async(std::launch::async, func);
auto result = future.get();