【C++面试】线程同步机制:互斥锁、条件变量、原子操作与读写锁

摘要:

一、为什么多线程需要同步

先看一个简单的例子:

复制代码
#include <iostream>
#include <thread>

int count = 0;                                 // 多个线程共享的变量

void add() {
    for (int i = 0; i < 10000; ++i) ++count;    // 多线程同时修改
}

int main() {
    std::thread t1(add);                       // 线程1
    std::thread t2(add);                       // 线程2

    t1.join();                                 // 等待线程1结束
    t2.join();                                 // 等待线程2结束

    std::cout << count << '\n';                // 结果不可靠
}

理论上:

复制代码
线程1加10000次
       +
线程2加10000次
       ↓
结果应该是20000

但是实际运行:

复制代码
可能不是20000

原因在于:

复制代码
++count;

并不意味着底层一定只执行一条不可分割的操作。

可以简化理解为:

复制代码
1. 读取count
2. count加1
3. 写回count

假设两个线程同时执行:

复制代码
线程A                   线程B

读取count = 10          读取count = 10
      ↓                       ↓
计算11                   计算11
      ↓                       ↓
写回11                   写回11

实际上执行了两次加一:

复制代码
预期:10 → 12

实际可能:10 → 11

这种现象叫:

数据竞争(Data Race)。

在 C++ 中,多个线程没有正确同步,同时对同一个非原子对象进行冲突访问,属于未定义行为。

所以我们需要线程同步机制,确保共享数据得到正确访问。

线程同步的核心目标:

复制代码
多个线程
    ↓
访问共享资源
    ↓
控制访问顺序与可见性
    ↓
保证程序正确执行

二、互斥锁mutex:保证同一时间只有一个线程访问

1. mutex的作用

mutex 是最常见的线程同步工具。

它的核心特点:

同一时刻,只允许一个线程持有这把互斥锁。

例如:

复制代码
线程A ──→ 获得mutex ──→ 修改共享数据
                              ↑
线程B ──→ 等待mutex ──────────┘

只有线程A释放锁以后,线程B才有机会获取锁。

C++ 中需要:

复制代码
#include <mutex>

使用:

复制代码
#include <iostream>
#include <thread>
#include <mutex>

int count = 0;                                 // 共享变量
std::mutex mtx;                                // 互斥锁

void add() {
    for (int i = 0; i < 10000; ++i) {
        mtx.lock();                            // 加锁
        ++count;                               // 访问临界区
        mtx.unlock();                          // 解锁
    }
}

int main() {
    std::thread t1(add);                       // 创建线程1
    std::thread t2(add);                       // 创建线程2

    t1.join();                                 // 等待线程1
    t2.join();                                 // 等待线程2

    std::cout << count << '\n';                // 正确结果为20000
}

这里:

复制代码
mtx.lock();

表示:

复制代码
尝试获得锁

如果其他线程正在持有:

复制代码
当前线程等待

而:

复制代码
mtx.unlock();

表示:

复制代码
释放锁
允许其他线程竞争

加锁保护的代码区域叫:

复制代码
临界区
Critical Section

2. 为什么更推荐lock_guard

刚才这样写:

复制代码
mtx.lock();
++count;
mtx.unlock();

虽然可以,但有一个问题。

假设:

复制代码
mtx.lock();                  // 获取锁
doSomething();               // 如果抛出异常
mtx.unlock();                // 可能执行不到

那么锁可能一直没有释放。

因此 C++ 更推荐使用:

复制代码
std::lock_guard

例如:

复制代码
void add() {
    for (int i = 0; i < 10000; ++i) {
        std::lock_guard<std::mutex> lock(mtx); // 构造时加锁,离开作用域自动解锁
        ++count;                               // 修改共享数据
    }
}

其原理就是 RAII:

复制代码
创建lock_guard对象
       ↓
自动加锁
       ↓
执行临界区
       ↓
离开作用域
       ↓
析构时自动解锁

所以面试中写互斥锁,通常优先写:

复制代码
std::lock_guard<std::mutex> lock(mtx);         // 自动管理互斥锁

而不是手动:

复制代码
mtx.lock();
mtx.unlock();

这样更安全,也更容易维护。

三、unique_lock与condition_variable:线程之间如何等待和通知

互斥锁解决的是:

复制代码
多个线程不能同时修改共享资源

但还有一个问题:

如果线程需要等待某个条件成立,该怎么办?

例如:

复制代码
生产者线程
    ↓
生产数据
    ↓
放入任务队列
    ↓
通知消费者

消费者线程
    ↓
等待队列有数据
    ↓
取出数据
    ↓
处理任务

这种场景通常使用:

复制代码
std::condition_variable

1. unique_lock有什么作用

unique_lock 也是管理互斥锁的工具。

例如:

复制代码
std::unique_lock<std::mutex> lock(mtx);        // 加锁,离开作用域自动解锁

它和:

复制代码
std::lock_guard<std::mutex>

最大的区别是:

unique_lock 更灵活,可以主动执行:

复制代码
lock.unlock();                                // 手动提前解锁
lock.lock();                                  // 再次加锁

并且支持条件变量等待。

对比:

工具 特点 使用场景
lock_guard 简单、自动加锁和解锁 普通临界区
unique_lock 支持手动解锁、重新加锁和延迟加锁 条件变量、复杂锁控制

所以:

复制代码
普通互斥保护
    ↓
lock_guard

需要等待条件或灵活管理锁
    ↓
unique_lock

2. condition_variable的作用

假设消费者线程需要:

复制代码
队列有任务
    ↓
才能继续工作

一种不好的写法:

复制代码
while (tasks.empty()) {}                      // 一直循环检查,浪费CPU

这叫:

复制代码
忙等待
Busy Waiting

即使队列一直没有数据,线程仍然不断检查。

更合理的方法是:

复制代码
队列为空
    ↓
消费者进入等待
    ↓
生产者加入任务
    ↓
通知消费者
    ↓
消费者被唤醒

C++ 中可以使用:

复制代码
std::condition_variable

3. 手写生产者消费者模型

复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::queue<int> tasks;                        // 共享任务队列
std::mutex mtx;                               // 保护任务队列
std::condition_variable cv;                   // 条件变量
bool finished = false;                        // 生产者是否结束

void producer() {
    for (int i = 1; i <= 5; ++i) {
        {
            std::lock_guard<std::mutex> lock(mtx); // 加锁
            tasks.push(i);                         // 添加任务
        }
        cv.notify_one();                           // 通知一个消费者
    }

    {
        std::lock_guard<std::mutex> lock(mtx);     // 保护结束标志
        finished = true;                          // 标记生产结束
    }
    cv.notify_all();                               // 唤醒所有等待线程
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);    // 获取互斥锁

        cv.wait(lock, [] { return !tasks.empty() || finished; }); // 等待任务或结束信号

        if (tasks.empty() && finished) break;      // 没有剩余任务,退出

        int task = tasks.front();                  // 读取队头
        tasks.pop();                               // 删除队头

        lock.unlock();                             // 提前释放锁
        std::cout << "处理任务:" << task << '\n';  // 锁外处理任务
    }
}

int main() {
    std::thread t1(producer);                      // 生产者线程
    std::thread t2(consumer);                      // 消费者线程

    t1.join();                                     // 等待生产者
    t2.join();                                     // 等待消费者
}

这里最核心的是:

复制代码
cv.wait(lock, [] { return !tasks.empty() || finished; });

它的执行逻辑:

复制代码
检查条件
    ↓
条件不成立
    ↓
释放mutex并进入等待
    ↓
收到通知或发生虚假唤醒
    ↓
重新获得mutex
    ↓
再次检查条件
    ↓
条件成立后继续执行

为什么需要:

复制代码
[] { return !tasks.empty() || finished; }

?

因为条件变量可能发生:

复制代码
虚假唤醒
Spurious Wakeup

线程被唤醒,不代表队列一定有任务。

因此需要使用条件判断,不能仅仅认为:

复制代码
被唤醒
    ↓
肯定有任务

另外这里还有一个重要优化:

复制代码
lock.unlock();

在处理任务之前主动释放锁。

因为:

复制代码
取任务
    ↓
需要保护共享队列

执行任务
    ↓
通常不需要继续持有队列锁

这样可以减少锁竞争。

四、atomic原子操作与shared_mutex读写锁

1. atomic:适合简单共享变量

如果只是多个线程对一个计数器执行:

复制代码
++count;

每次都使用互斥锁,有时并不是最合适的方法。

C++ 提供:

复制代码
std::atomic

例如:

复制代码
#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> count{0};                   // 原子整型

void add() {
    for (int i = 0; i < 10000; ++i) ++count;  // 原子递增
}

int main() {
    std::thread t1(add);                     // 线程1
    std::thread t2(add);                     // 线程2

    t1.join();                               // 等待线程1
    t2.join();                               // 等待线程2

    std::cout << count.load() << '\n';       // 输出20000
}

这里:

复制代码
std::atomic<int> count{0};

能够保证:

复制代码
++count;

作为原子读改写操作,不会出现普通非原子计数器那样的数据竞争。

还可以:

复制代码
count.fetch_add(1);                          // 原子加1
count.store(10);                             // 原子写入
int value = count.load();                    // 原子读取

atomic 很适合:

复制代码
线程安全计数器
任务完成数量
运行状态标志
引用计数

不过需要注意:

atomic 不意味着所有复杂操作都自动线程安全,也不保证一定无锁。

例如:

复制代码
std::atomic<int> a{0};
std::atomic<int> b{0};

虽然:

复制代码
a.store(10);
b.store(20);

分别是原子操作,但它们并不会自动组成一个不可分割的整体。

如果需要同时保护多个变量之间的不变关系,通常还是需要互斥锁。

2. shared_mutex:适合读多写少

普通 mutex:

复制代码
读线程A获得锁
       ↓
读线程B也必须等待

即使两个线程都只是读取数据,也无法同时持有普通互斥锁。

如果业务特点是:

复制代码
读取很多
修改很少

可以使用读写锁:

复制代码
std::shared_mutex

示例:

复制代码
#include <shared_mutex>
#include <mutex>

int value = 100;                             // 共享数据
std::shared_mutex rwMutex;                   // 读写锁

int readData() {
    std::shared_lock<std::shared_mutex> lock(rwMutex); // 共享加锁,允许多个读者
    return value;                                      // 读取数据
}

void writeData(int x) {
    std::unique_lock<std::shared_mutex> lock(rwMutex); // 独占加锁
    value = x;                                         // 修改共享数据
}

读写锁的基本规则:

复制代码
读 + 读
    ↓
可以同时进行

读 + 写
    ↓
不能同时进行

写 + 写
    ↓
不能同时进行

所以:

复制代码
读线程A ───┐
           ├── 同时读取
读线程B ───┘

写线程C ───── 等待独占访问

这种方式适合:

复制代码
配置管理
缓存查询
共享状态读取
读多写少的数据结构

但如果写入非常频繁,读写锁未必比普通 mutex 更快,因为读写锁本身也存在管理成本和潜在的饥饿问题。

3. semaphore:限制同时访问的线程数量

C++20 提供了信号量:

复制代码
#include <semaphore>

它与 mutex 的主要区别:

复制代码
mutex
↓
通常只允许1个线程进入临界区

semaphore
↓
可以允许指定数量的线程同时获取许可

例如最多允许三个任务同时访问资源:

复制代码
#include <semaphore>

std::counting_semaphore<3> sem(3);            // 初始3个许可

void accessResource() {
    sem.acquire();                            // 获取许可,没有许可就等待
    // 访问受限资源
    sem.release();                            // 释放许可
}

适用场景:

复制代码
限制数据库并发连接数
限制同时执行的下载任务数量
控制有限设备资源访问数量

但信号量通常不替代互斥锁对复杂共享数据的保护。

五、如何避免死锁,以及面试怎么回答

线程同步还有一个重要问题:

复制代码
Deadlock
死锁

例如两把锁:

复制代码
std::mutex m1;
std::mutex m2;

线程A:

复制代码
m1.lock();                                    // 先获得m1
m2.lock();                                    // 再尝试获得m2

线程B:

复制代码
m2.lock();                                    // 先获得m2
m1.lock();                                    // 再尝试获得m1

可能出现:

复制代码
线程A                     线程B
  │                         │
获得m1                     获得m2
  │                         │
等待m2                     等待m1
  │                         │
  └────────互相等待─────────┘
               ↓
              死锁

解决方式之一是:

复制代码
统一加锁顺序

例如所有线程都必须:

复制代码
先锁m1
再锁m2

还有一种更方便的方法是使用:

复制代码
std::scoped_lock

C++17 支持:

复制代码
#include <mutex>

std::mutex m1;
std::mutex m2;

void task() {
    std::scoped_lock lock(m1, m2);           // 同时管理两把锁,采用避免死锁的加锁算法
    // 安全访问需要两把锁共同保护的数据
}

相比手写:

复制代码
m1.lock();
m2.lock();

更加安全。

实际开发中还应该遵守:

  • 尽量缩小临界区,不要持锁执行耗时操作。

  • 避免在持锁期间执行不可控的回调或阻塞 I/O。

  • 多把锁尽量统一加锁顺序,或者使用 std::scoped_lock。

  • 优先使用 RAII 自动管理锁,防止异常时忘记解锁。

  • 等待条件变量时使用条件谓词,防止虚假唤醒造成逻辑错误。


面试常见问题

问题1:C++有哪些线程同步方式?

可以回答:

C++ 常见的线程同步机制包括互斥锁 mutex、条件变量 condition_variable、原子操作 atomic、读写锁 shared_mutex,以及信号量 semaphore。mutex 适合保护共享资源,条件变量适合线程之间等待和通知,atomic 适合简单原子状态和计数操作,读写锁适合读多写少的场景,信号量适合限制并发访问数量。

问题2:lock_guard和unique_lock有什么区别?

可以回答:

lock_guard 是简单的 RAII 锁管理工具,构造时加锁,析构时解锁;unique_lock 功能更灵活,支持提前解锁、重新加锁和延迟加锁,也可以配合条件变量使用。因此普通临界区通常用 lock_guard,需要更复杂锁控制时使用 unique_lock。

问题3:互斥锁和原子操作有什么区别?

可以回答:

互斥锁可以保护一段包含多个操作的临界区,适合维护复杂共享状态;atomic 保证针对原子对象的指定操作具有原子性,更适合计数器和状态标志等简单场景。atomic 不代表多个独立操作能自动形成一个事务。

问题4:条件变量为什么需要配合互斥锁?

可以回答:

条件变量本身负责等待和通知,但真正决定是否可以继续执行的是共享条件。互斥锁用来保护这个条件对应的共享状态,wait 会在等待时释放锁,在唤醒后重新获取锁,并再次检查条件,避免并发修改和虚假唤醒导致的问题。

问题5:怎么避免死锁?

可以回答:

常见方法包括统一加锁顺序、尽量减少同时持有的锁数量、缩小临界区,以及使用 scoped_lock 同时获取多把锁。另外要尽量避免持锁执行耗时操作或调用不可控的外部代码。

问题6:join是不是线程同步?

可以回答:

join() 也是一种线程同步操作,它让当前线程等待目标线程执行结束。但它主要负责线程完成顺序的同步,而 mutex、atomic 和 condition_variable 更多用于线程运行期间对共享数据和执行条件的同步,两者解决的问题不同。


最后把几种同步方式总结成一张表:

同步机制 核心作用 典型场景
mutex 同一时刻互斥访问 共享容器、共享对象
lock_guard 自动加锁与解锁 简单临界区
unique_lock 灵活管理互斥锁 条件变量
condition_variable 线程等待与通知 生产者消费者、任务队列
atomic 原子读取、写入和修改 计数器、状态标志
shared_mutex 多读单写 配置、缓存
semaphore 控制同时访问数量 连接池、并发限流
join 等待线程执行完成 线程生命周期管理

可以简单记成:

复制代码
              C++线程同步
                    │
       ┌────────────┼──────────────┐
       ↓            ↓              ↓
     互斥锁       条件变量        原子操作
       ↓            ↓              ↓
   保护共享数据    等待/通知       简单状态操作
       │                           │
       ↓                           ↓
     mutex                       atomic
       │
       ├── lock_guard
       ├── unique_lock
       └── shared_mutex

对于 C++ 面试而言,最应该优先掌握的是下面四种:

复制代码
std::mutex mtx;                              // 互斥锁
std::lock_guard<std::mutex> lock(mtx);       // 自动加锁与解锁
std::condition_variable cv;                  // 线程等待与通知
std::atomic<int> count{0};                   // 原子变量

掌握这几种工具后,就能够进一步理解线程池、线程安全队列、生产者消费者模型以及高并发服务器中的线程同步设计。

0voice · GitHub

相关推荐
七牛云行业应用1 小时前
刚刚!GPT-6.1 Sol Ultrafast 上线:速度、价格、API 接入全指南
算法
Omics Pro1 小时前
研究证实AI虚拟细胞可用于药物靶点发现
数据库·人工智能·算法·机器学习·自然语言处理
在所不辞兄1 小时前
常微分方程详解与应用
人工智能·神经网络·算法·机器学习
-dzk-1 小时前
【动态规划】LC 416.分割等和子集
算法·动态规划·代理模式
CV工程师丁Sir1 小时前
ArkWeb 手记 06|window.open 与页面跳转拦截
开发语言·php·harmonyos
夜不会漫长1 小时前
C++:内存管理
java·开发语言·c++
彦1 小时前
C和C++笔记
c语言·c++·笔记
徐小黑ACG2 小时前
Golang 基础01
开发语言·后端·golang
进击的大海贼2 小时前
基于C#开发的久坐提醒工具
开发语言·c#