C++ 多线程并发控制:原子量、锁与信号量详解
在多线程编程中,处理共享资源的并发访问是保证系统稳定与高效的核心。当多个线程同时读写同一块内存时,如果没有适当的同步机制,就会产生竞态条件(Race Condition),导致不可预测的数据损坏。
C++ 提供了多种层级的并发控制工具,包括最底层的 原子操作 (Atomic),中层的 互斥锁与条件变量 (Mutex & Condition Variable),以及高层的 信号量 (Semaphore)。本篇将详细剖析它们的底层原理、C++11/C++20 标准实现,以及在实际工程中的选型与差异。
1. 核心概念对比与选型指南
下表对比了三种核心同步机制的差异:
| 特性 | 原子量 (std::atomic) | 锁 (std::mutex / std::unique_lock) | 信号量 (std::counting_semaphore) |
|---|---|---|---|
| 本质 | CPU 指令级不可分割操作 | 二元互斥访问控制区域 | 资源计数器 + 等待队列 |
| 粒度 | 单个变量级别 | 代码临界区(多行/复杂对象) | 资源数量控制(限流) |
| 阻塞行为 | 不阻塞(无锁/自旋等待) | 阻塞(线程休眠并挂起) | 阻塞(可配置阻塞或超时) |
| 开销 | 极小(硬件指令级别开销) | 中等(需进行用户态与内核态切换) | 较大(涉及内核对象与等待队列) |
| 典型场景 | 引用计数、标志位、无锁数据结构 | 保护共享 Map、文件写入、链表修改 | 数据库连接池限流、生产者-消费者缓存 |
选型口诀:
- 如果只需要保护单个简单变量(计数器、开关) 优先选择原子量。
- 如果需要保护一段代码或复杂数据结构(多步操作、Map/Vector 写入) 优先选择锁。
- 如果需要限制某些资源的最大并发访问数量(限流、资源池管理) 优先选择信号量。
2. 原子操作 (std::atomic)
原子量是通过 CPU 硬件指令(如 Compare-And-Swap (CAS) 或 Load-Linked/Store-Conditional (LL/SC))来保证操作不可分割的。它不依赖于操作系统的线程阻塞调度,因而有着极高的性能。
2.1 C++ 标准实现
C++11 引入了 <atomic> 头文件:
# include <atomic>
# include <iostream>
std::atomic<int> global_counter(0);
void increment() {
// 硬件原子自增指令,无锁且线程安全
global_counter++;
}
int main() {
increment();
std::cout << "Counter: " << global_counter.load() << std::endl;
return 0;
}3. 锁与条件变量机制
锁是一种独占互斥机制,用来保护一块代码临界区。
3.1 互斥锁 (std::mutex)
互斥锁(Mutex)是一种 Sleep-waiting 锁。如果线程 A 持有了锁,线程 B 尝试获取锁时会被阻塞,操作系统会将线程 B 挂起放入等待队列,释放 CPU 资源去执行其他任务。
现代 C++ 最佳实践:RAII 锁管理
不要手动调用 .lock() 和 .unlock(),防止由于中途抛出异常或 return 导致死锁。应当使用 RAII 模板:
std::lock_guard:最轻量,不可手动解锁。std::unique_lock:更灵活,支持手动提前解锁、条件变量配合、延迟加锁。
# include <mutex>
# include <vector>
std::mutex mtx;
std::vector<int> shared_data;
void safe_push(int val) {
// RAII 自动加锁,函数退出时自动解锁
std::lock_guard<std::mutex> lock(mtx);
shared_data.push_back(val);
}3.2 读写锁与递归锁
- 读写锁 (std::shared_mutex):允许多个线程同时“读”,但只允许一个线程“写”。读多写少场景下性能极佳。
- 递归锁 (std::recursive_mutex):允许同一个线程对同一个锁进行多次重复加锁,而不会导致自己把自己锁死的死锁现象。通常用于递归函数中保护共享变量。
3.3 自旋锁 (Spinlock)
自旋锁是一种 Busy-waiting(忙等)锁。当自旋锁被占用时,请求锁的线程不会进入睡眠,而是在 CPU 上执行一个死循环不断查询锁是否已被释放。
- 优点:避免了线程上下文切换(Context Switch)的开销,适用于锁持有时间极短的场景。
- 缺点:若持有锁的线程长时间不释放,会严重榨干 CPU 资源。
3.4 条件变量 (std::condition_variable)
条件变量用于线程间的同步通信。当某个条件未满足时,线程调用 .wait() 挂起;一旦其他线程使条件成立,调用 .notify_one() 唤醒挂起的线程。
避坑指南:必须在 while 循环中调用 wait(防止虚假唤醒)
在操作系统中,被阻塞的线程可能在没有任何信号通知的情况下被意外唤醒(称为虚假唤醒 (Spurious Wakeup))。因此,条件检查必须放在 while 循环体中,而不是 if 中。
# include <mutex>
# include <condition_variable>
# include <queue>
std::mutex queue_mtx;
std::condition_variable cv;
std::queue<int> task_queue;
// 消费者线程
void consume_task() {
std::unique_lock<std::mutex> lock(queue_mtx);
// 💡 修正:放在 while 循环中,防止虚假唤醒
while (task_queue.empty()) {
cv.wait(lock); // 释放锁并进入阻塞;被唤醒后会自动重新加锁
}
int task = task_queue.front();
task_queue.pop();
}
// 生产者线程
void produce_task(int val) {
{
std::lock_guard<std::mutex> lock(queue_mtx);
task_queue.push(val);
}
cv.notify_one(); // 唤醒一个等待的消费者
}4. 信号量 (Semaphore)
信号量(Semaphore)是一个经典的并发控制工具,用于限制可以同时访问某组资源的线程最大数量。
- P 操作 (wait):尝试获取一个资源,计数器减 1;若计数器为负,则当前线程阻塞挂起。
- V 操作 (signal):释放一个资源,计数器加 1;如果有阻塞线程,则唤醒它。
C++20 正式引入了标准信号量支持:
std::counting_semaphore<N>:通用信号量,最多允许同时存在 个并发。std::binary_semaphore:二元信号量(等价于std::counting_semaphore<1>),常用作互斥锁或线程同步。
5. 常见技术误区:信号 (Signal) 与 信号量 (Semaphore) 的区别
这两者名字相似,但毫无关系,千万不能混淆:
- 信号 (Signal):
- 级别:操作系统进程级异步通知机制。
- 作用:用于通知某个进程发生了异步事件。例如你在终端按下
Ctrl+C会向进程发送SIGINT信号;发生段错误时系统发送SIGSEGV信号。 - 机制:完全由操作系统内核中断驱动,进程注册信号处理函数来响应。
- 信号量 (Semaphore):
- 级别:线程/进程间同步与互斥工具。
- 作用:控制多个执行流共享一定数量的公共资源,防止冲突。
- 机制:通过计数器和挂起队列实现。