Skip to content

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> 头文件:

cpp
# 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:更灵活,支持手动提前解锁、条件变量配合、延迟加锁。
cpp
# 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 中。

cpp
# 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&lt;N&gt;:通用信号量,最多允许同时存在 个并发。
  • std::binary_semaphore:二元信号量(等价于 std::counting_semaphore&lt;1&gt;),常用作互斥锁或线程同步。

5. 常见技术误区:信号 (Signal) 与 信号量 (Semaphore) 的区别

这两者名字相似,但毫无关系,千万不能混淆:

  1. 信号 (Signal)
    • 级别:操作系统进程级异步通知机制。
    • 作用:用于通知某个进程发生了异步事件。例如你在终端按下 Ctrl+C 会向进程发送 SIGINT 信号;发生段错误时系统发送 SIGSEGV 信号。
    • 机制:完全由操作系统内核中断驱动,进程注册信号处理函数来响应。
  2. 信号量 (Semaphore)
    • 级别:线程/进程间同步与互斥工具。
    • 作用:控制多个执行流共享一定数量的公共资源,防止冲突。
    • 机制:通过计数器和挂起队列实现。

基于 VitePress 强力驱动 | 记录技术与生活