摘要:
一、为什么多线程需要同步
先看一个简单的例子:
#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}; // 原子变量
掌握这几种工具后,就能够进一步理解线程池、线程安全队列、生产者消费者模型以及高并发服务器中的线程同步设计。