一、互斥锁和读写锁到底有什么区别
假设程序中有一个共享数据:
int data = 0;
多个线程都可能访问:
Thread 1
Thread 2
Thread 3
Thread 4
如果这些线程同时修改:
data++;
就可能产生:
数据竞争
Data Race
所以最简单的办法就是加:
std::mutex
例如:
#include <mutex>
std::mutex mutex;
int data = 0;
void update() {
std::lock_guard<std::mutex> lock(mutex);
++data;
}
此时:
Thread A
↓
获得mutex
↓
访问data
Thread B
↓
等待
Thread C
↓
等待
也就是说:
互斥锁同一时间只允许一个线程进入临界区。
即使两个线程只是读取数据:
void read() {
std::lock_guard<std::mutex> lock(mutex);
std::cout << data << std::endl;
}
同样只能:
一个读线程执行
其他读线程等待
但读操作通常不会修改共享数据。
例如:
Thread A:读取配置
Thread B:读取配置
Thread C:读取配置
这三个操作理论上完全可以:
同时执行
这时候就可以考虑:
读写锁
Read-Write Lock
读写锁有两种锁:
读锁
Read Lock
写锁
Write Lock
它们之间的关系是:
读 + 读
↓
可以并发
读 + 写
↓
不能并发
写 + 写
↓
不能并发
也就是:
| 当前线程 | 新读线程 | 新写线程 |
|---|---|---|
| 无线程持锁 | 可以 | 可以 |
| 已有读线程 | 可以 | 等待 |
| 已有写线程 | 等待 | 等待 |
所以读写锁的核心规则可以记成:
读读共享
读写互斥
写写互斥
C++17 中可以使用:
std::shared_mutex
例如:
#include <shared_mutex>
std::shared_mutex rw_mutex;
int data = 0;
读线程:
void readData() {
std::shared_lock<std::shared_mutex> lock(rw_mutex);
std::cout << data << std::endl;
}
写线程:
void writeData() {
std::unique_lock<std::shared_mutex> lock(rw_mutex);
++data;
}
这里:
std::shared_lock
表示:
共享读锁
多个线程可以同时持有。
而:
std::unique_lock
表示:
独占写锁
写线程进入以后,其他读写线程都需要等待。
二、互斥锁和读写锁分别适合什么场景
并不是:
读写锁一定比mutex快
关键还是看:
业务场景
1. 互斥锁适合什么场景
如果:
读操作和写操作都很多
例如:
读50%
写50%
或者临界区本身非常短:
++counter;
那么直接使用:
std::mutex
通常更加简单。
因为读写锁内部需要维护:
当前读线程数量
写线程状态
等待线程状态
它本身也会产生额外管理开销。
所以:
临界区很小
+
竞争不激烈
+
读写比例接近
优先考虑普通:
mutex
例如:
std::mutex mutex;
std::queue<int> queue;
线程操作:
void push(int value) {
std::lock_guard<std::mutex> lock(mutex);
queue.push(value);
}
队列操作通常存在:
读
写
修改内部状态
使用互斥锁更加直接。
2. 读写锁适合什么场景
假设系统中:
95%的操作是读取
5%的操作是修改
例如:
配置中心
缓存
路由表
用户信息缓存
内存数据库查询
大量线程只是在:
读取数据
如果使用 mutex:
Reader1
↓
Reader2等待
↓
Reader3等待
明明大家都不修改数据,却不能同时执行。
换成读写锁:
Reader1 ─┐
Reader2 ─┼→ 同时读取
Reader3 ─┘
只有写线程出现时:
Writer
↓
等待所有Reader离开
↓
独占访问
所以读写锁比较适合:
读多
+
写少
+
读取操作具有一定耗时
可以简单记成:
读写比较均衡
↓
mutex
读远多于写
↓
读写锁
不过如果:
写操作非常频繁
读写锁很难发挥:
多个Reader同时执行
这个优势。
反而可能增加锁管理成本。
三、读写锁底层大概是怎么实现的
C++ 标准并没有规定:
std::shared_mutex
必须使用某种固定内部实现。
但是从原理上,可以把一个读写锁简化成几个状态:
int active_readers = 0; // 当前正在读的线程数量
bool writer_active = false; // 当前是否有写线程
int waiting_writers = 0; // 正在等待的写线程数量
再配合:
mutex
+
condition_variable
实现线程等待和唤醒。
结构大概可以理解成:
ReadWriteLock
│
┌────────────┼────────────┐
↓ ↓ ↓
active_readers writer_active waiting_writers
1. 获取读锁
最基本的读锁条件:
当前没有Writer正在写
所以可以:
void lockRead() {
std::unique_lock<std::mutex> lock(mutex_);
readers_cv_.wait(lock, [this]() {
return !writer_active_;
});
++active_readers_;
}
当:
writer_active == false
读线程就可以进入。
每进入一个 Reader:
active_readers_++;
例如:
Reader1进入
active_readers = 1
Reader2进入
active_readers = 2
Reader3进入
active_readers = 3
它们可以同时执行读取操作。
释放读锁:
void unlockRead() {
std::lock_guard<std::mutex> lock(mutex_);
--active_readers_;
if (active_readers_ == 0) {
writers_cv_.notify_one();
}
}
如果:
最后一个Reader离开
说明:
active_readers = 0
这时候就可以通知:
正在等待的Writer
2. 获取写锁
写线程要求更加严格:
当前不能有Writer
并且
当前不能有Reader
所以条件是:
!writer_active_ && active_readers_ == 0
例如:
void lockWrite() {
std::unique_lock<std::mutex> lock(mutex_);
writers_cv_.wait(lock, [this]() {
return !writer_active_ && active_readers_ == 0;
});
writer_active_ = true;
}
只要还有:
Reader
写线程都不能进入。
例如:
Reader1
Reader2
Reader3
↓
active_readers = 3
Writer
↓
等待
直到:
Reader全部退出
变成:
active_readers = 0
Writer 才能:
独占访问共享资源
释放写锁:
void unlockWrite() {
std::lock_guard<std::mutex> lock(mutex_);
writer_active_ = false;
readers_cv_.notify_all();
writers_cv_.notify_one();
}
所以整个基本原理就是:
Reader进入
↓
检查有没有Writer
↓
没有
↓
active_readers++
Writer:
Writer进入
↓
有没有Writer?
↓
有没有Reader?
↓
都没有
↓
writer_active = true
从本质上来说,读写锁就是:
在互斥保护下维护"当前读者数量"和"写者状态",再通过条件变量控制哪些线程能够继续执行。
四、什么是写饥饿,为什么会发生
读写锁有一个非常经典的问题:
写饥饿
Writer Starvation
假设现在:
Reader1
Reader2
Reader3
正在读。
这时候:
Writer
来了。
因为:
active_readers > 0
所以 Writer 必须等待。
这本来很正常。
问题是,如果当前实现只判断:
!writer_active_
就允许新的 Reader 进入,那么可能出现:
Reader1 Reader2 Reader3
↓
Writer到达
↓
Writer等待
↓
Reader4到达
↓
Reader4继续进入
↓
Reader5到达
↓
Reader5继续进入
↓
Reader6继续进入
↓
...
结果:
active_readers
可能一直无法变成:
0
Writer 就可能:
一直等待
这就是:
写饥饿
它通常出现在:
读者优先
Reader Preference
的读写锁中。
简单来说:
只要没有Writer正在执行
↓
新的Reader就能继续进
即使:
已经有Writer在等待
Reader 还是不断插队。
最终:
读线程源源不断
↓
写线程一直拿不到锁
所以写饥饿不是说:
Writer永远一定执行不了
而是:
在持续高读负载下
Writer可能等待非常长时间
甚至理论上无限等待
五、写饥饿怎么解决
一种比较直接的解决方式就是:
Writer优先
也就是说:
一旦已经有 Writer 等待,就暂时不允许新的 Reader 继续进入。
前面的读锁条件原来是:
return !writer_active_;
修改成:
return !writer_active_ && waiting_writers_ == 0;
这样:
Writer一旦开始等待
↓
waiting_writers > 0
↓
新的Reader不能进入
↓
已经存在的Reader逐渐退出
↓
active_readers变成0
↓
Writer获得写锁
一个简化版实现:
#include <condition_variable>
#include <mutex>
class ReadWriteLock {
private:
std::mutex mutex_;
std::condition_variable readers_cv_;
std::condition_variable writers_cv_;
int active_readers_ = 0; // 当前正在读的线程
int waiting_writers_ = 0; // 等待写锁的线程
bool writer_active_ = false; // 是否有Writer正在写
public:
void lockRead() {
std::unique_lock<std::mutex> lock(mutex_);
// 有Writer正在执行或者等待时,新Reader暂停进入
readers_cv_.wait(lock, [this]() {
return !writer_active_ && waiting_writers_ == 0;
});
++active_readers_;
}
void unlockRead() {
std::lock_guard<std::mutex> lock(mutex_);
--active_readers_;
// 最后一个Reader退出,可以唤醒Writer
if (active_readers_ == 0) {
writers_cv_.notify_one();
}
}
void lockWrite() {
std::unique_lock<std::mutex> lock(mutex_);
++waiting_writers_;
// Writer要求没有Reader,也没有其他Writer
writers_cv_.wait(lock, [this]() {
return !writer_active_ && active_readers_ == 0;
});
--waiting_writers_;
writer_active_ = true;
}
void unlockWrite() {
std::lock_guard<std::mutex> lock(mutex_);
writer_active_ = false;
// 优先让等待中的Writer继续执行
if (waiting_writers_ > 0) {
writers_cv_.notify_one();
} else {
// 没有Writer等待,再放行所有Reader
readers_cv_.notify_all();
}
}
};
最关键的是:
return !writer_active_ && waiting_writers_ == 0;
也就是说:
Writer正在执行
↓
Reader等待
同时:
Writer虽然还没执行
但是已经在排队
↓
新的Reader也等待
例如:
Reader1
Reader2
Reader3
↓
正在读取
Writer到达
↓
waiting_writers = 1
↓
新的Reader不能进入
Reader1退出
Reader2退出
Reader3退出
↓
active_readers = 0
↓
Writer执行
这样就能够防止:
新的Reader不断插队
从而缓解甚至避免:
Writer饥饿
不过 Writer 优先也可能带来另一个问题:
如果Writer不断到达
Reader可能等待很久
也就是:
读饥饿
所以更加完善的读写锁还可能采用:
公平策略
Fair Policy
例如维护:
等待队列
按照大致的:
先到先服务
FIFO
控制读写线程获取锁。
可以简单理解为三种策略:
读者优先
↓
提高Reader吞吐量
但可能Writer饥饿
写者优先
↓
防止Writer长期等待
但大量Writer时Reader可能等待
公平策略
↓
按照一定排队顺序调度
尽量避免双方饥饿
实际使用:
std::shared_mutex
时,其具体公平策略和内部实现与标准库及平台实现有关,C++ 标准并没有简单规定成:
一定Reader优先
或者
一定Writer优先
因此面试中最好不要把某一种实现说成所有 shared_mutex 都必须这样实现。
如果面试官问:
mutex和读写锁分别适合什么场景?
可以回答:
mutex同一时刻只允许一个线程访问临界区,实现简单,适合读写比例比较均衡、临界区较短或者竞争不严重的场景。读写锁允许多个 Reader 同时持有读锁,但 Writer 必须独占资源,因此更适合读多写少,并且读操作有一定耗时的场景。
如果继续问:
读写锁的基本原理是什么?
可以回答:
可以把读写锁理解为内部维护读者计数、写者状态以及等待线程状态。Reader 获取锁时,只要满足相应条件就增加读者计数;Writer 获取锁时需要等待当前 Reader 数量为 0 且没有其他 Writer。线程不能继续执行时,可以通过条件变量睡眠,释放锁时再唤醒对应线程。标准库的具体实现可能不同,但基本同步思想类似。
如果再问:
什么是写饥饿?
可以回答:
在读者优先的读写锁中,如果 Reader 持续不断到达,即使已经有 Writer 等待,新 Reader 仍然可以继续进入,那么读者数量可能长期无法降到 0,导致 Writer 一直得不到写锁,这就是写饥饿。
解决方式:
Writer开始等待
↓
阻止新的Reader进入
↓
已有Reader执行完
↓
Reader数量归零
↓
优先唤醒Writer
也就是:
可以记录等待 Writer 的数量,一旦存在 Writer 等待,就暂时阻止新 Reader 获取读锁,让已有 Reader 完成后优先执行 Writer。更完善的方案还可以使用公平排队机制,在 Reader 和 Writer 之间进行更公平的调度。
最后把这部分知识串起来:
普通mutex
↓
一次只允许一个线程
↓
简单、通用
读写锁
↓
Reader + Reader
可以并发
Reader + Writer
互斥
Writer + Writer
互斥
再进一步:
读者优先
↓
Reader吞吐量高
↓
可能写饥饿
写者优先
↓
Writer等待后阻止新Reader
↓
缓解写饥饿
公平策略
↓
按照排队顺序调度
↓
尽量避免长期饥饿
所以选择锁的时候,并不是:
读写锁一定比mutex高级
而是需要看:
读写比例
+
临界区大小
+
线程竞争程度
+
是否在意公平性
然后再选择更加合适的同步方式。