这是我操作系统 / 多线程手撕题的 Day3。
- Day1:我刚认识
std::thread、std::mutex、std::condition_variable,写了奇偶数交替打印。 - Day2:我把同样的思路搬到生产者–消费者模型上,手写了一个阻塞队列。
- Day3:我想再往上一个台阶,挑战面试里非常常见的一题——读写锁(Reader–Writer Lock) ,而且是带 “写者优先”策略 的版本。
这篇文章记录的是我从 完全不会 到写出下面这一份代码的过程:
-
我一开始并不理解:
- 为什么读写锁要拆成
ReadLock/ReadUnlock/WriteLock/WriteUnlock四个函数? - 多个读者同时存在时,后来的读者拿不到锁会怎么样?线程会直接结束吗?
- 为什么读写锁要拆成
-
中间我写了几版有问题的 RWLock:
std::unique_lock没绑定 mutex- 没有给内部状态加锁
- 条件变量等在了错误的条件上
waiting_writers_成了一个“摆设”
-
最后我整理出一版写者优先读写锁,再写了一个小 demo 来可视化它的时间线。
一、题目:一个简化版读写锁 + 写者优先
Day3 的目标版本是这样的:
用 C++ 手写一个
RWLock,提供四个接口:void ReadLock(); void ReadUnlock(); void WriteLock(); void WriteUnlock();要求:
- 多个读者可以同时读;
- 写者必须独占;
- 一旦有写者在排队,新的读者不能插队(写者优先)。
一开始我还问过一个很菜但又很典型的问题:
“为什么不写成一个
Reader()、一个Writer()函数,把读写过程都包进去?
非要拆成 Lock / Unlock 四个函数吗?”
后来想通了:锁只负责“控制谁能进临界区”,但不会替我执行具体的业务逻辑。
- 业务逻辑是:读线程要读什么数据、写线程要改什么数据
- 锁只管:在读写这段代码前后“加锁 / 解锁”
如果不拆成四个函数,我的读写操作就只能写死在锁里面,不可能在别的地方复用。
从这个角度看,ReadLock/ReadUnlock/WriteLock/WriteUnlock 的设计就顺眼多了。
二、我写的第一版 RWLock:一堆典型错误
我一开始完全没查资料,直接“凭记忆”写了一个简陋版 RWLock。
这一步对我来说其实很重要,它帮我暴露出了自己对锁 + 条件变量 + 状态机的很多误解。
下面是我经历过的几个关键问题(虽然代码没保留完整,这里用文字复盘一下):
问题一:std::unique_lock 默认构造 ≠ 已经加锁
我最开始在 ReadLock 里写的是:
void ReadLock() {
std::unique_lock lock; // 我当时以为这样就“有锁了”
cv_read_.wait(lock, [this] { /*...*/ });
reader_count_++;
}
当时我以为:
lock是一个std::unique_lock对象,它应该自动“代表某把锁”。
但现实是:
- 默认构造的
std::unique_lock根本没有绑定任何 mutex; cv.wait(lock, ...)需要的是“已经锁住某把 mutex 的 unique_lock”,否则就是未定义行为;- 修改
reader_count_完全没有互斥保护 → 彻底 data race。
正确写法应该是:
std::unique_lock lock(mtx_);
这一步我算是正式搞清楚了:
unique_lock 是“帮你管理 mutex 的 RAII 包装”,
不是“自带锁”的魔法对象。
问题二:在 Unlock 里修改状态却没加锁
我在 ReadUnlock 和 WriteUnlock 里一开始是这样写的:
void ReadUnlock() {
reader_count_--;
cv_write_.notify_one();
}
void WriteUnlock() {
writer_present_ = false;
cv_read_.notify_all();
}
问题非常直接:
这些内部状态(reader_count_、writer_present_)同时被多个线程读写,却没有任何保护。
修改方式也很暴力,根本没考虑:
- 什么时候读者变成 0 个?
- 什么时候该唤醒写者?
- 什么时候该唤醒所有读者?
这一步让我意识到:读写锁本身其实就是一个“小状态机”,而不是几行“if + notify”就能搞定的玩具。
问题三:waiting_writers_ 完全是摆设
我一开始就预感到:
“要实现写者优先,我肯定需要一个
waiting_writers_来统计当前有多少写者在排队。”
于是我在类里加了:
int waiting_writers_ = 0;
然后在 ReadLock 的判断条件里写:
cv_read_.wait(lock, [this] {
return !writer_present_ && waiting_writers_ == 0;
});
逻辑看上去非常高级:
“只要有写者在排队(
waiting_writers_ > 0),读者就不应该再进来。”
但最大的问题是:我从来没在 WriteLock / WriteUnlock 里对 waiting_writers_ 做 ++ / --。
它整个程序的生命周期里一直都是 0。
写者优先?根本没实现。
这一点让我很直观地意识到:
“写者优先”不是一句话说说,而是写在状态变化逻辑里的东西。
问题四:搞不清“拿不到锁的线程会怎么样”
在理解 RWLock 的过程中,我脑子里冒出来过一个很关键的困惑:
“当一个读者拿到了
mtx_,还没执行到wait,
这时第二个读者也来调用ReadLock()。
第二个读者拿不到mtx_,那它是线程直接结束了,还是卡住?”
这个问题后来是靠 demo 实验 + 日志才彻底想明白的,
后面我会专门用一节来讲这一点。
在经历了这些“瞎写 + 崩溃 + 疑惑”之后,我决定暂时停下写代码,先认真整理一遍:
读写锁的内部状态到底有哪些?规则是什么?
三、整理状态机:RWLock 其实就三个状态变量
我最后把 RWLock 抽象成了三个状态:
int reader_count_ = 0; // 当前正在读的读者数量
int waiting_writers_ = 0; // 正在排队等待写的写者数量
bool writer_present_ = false; // 是否有写者正在写
所有对这些变量的读写都必须在这把锁的保护下完成:
std::mutex mtx_;
然后,我给自己列了一套“写者优先读写锁”的规则。
四、规则梳理:写者优先读写锁应该长什么样?
1)读者进入条件(ReadLock)
当一个读者想进入时,我们希望:
- 当前没有写者在写:
!writer_present_ - 当前没有写者在排队:
waiting_writers_ == 0
也就是:
cv_read_.wait(lock, [this] {
return !writer_present_ && waiting_writers_ == 0;
});
++reader_count_;
只要有写者在排队,新来的读者就被挡在门外。
这就是“写者优先”策略的核心。
2)读者退出(ReadUnlock)
当一个读者结束时:
reader_count_--- 如果此时读者已经变成 0 个,且有写者在排队
→ 唤醒一个写者
--reader_count_;
if (reader_count_ == 0 && waiting_writers_ != 0) {
cv_write_.notify_one();
}
3)写者进入(WriteLock)
写者进入的过程稍微复杂一点:
-
来到门口 → 先声明“我在排队”:
++waiting_writers_ -
然后等待以下条件满足:
- 没有读者:
reader_count_ == 0 - 没有写者正在写:
!writer_present_
- 没有读者:
-
条件满足后:
waiting_writers_--writer_present_ = true
翻译成代码:
std::unique_lock lock(mtx_);
++waiting_writers_;
cv_write_.wait(lock, [this] {
return reader_count_ == 0 && !writer_present_;
});
--waiting_writers_;
writer_present_ = true;
4)写者退出(WriteUnlock)
写者离开时:
writer_present_ = false- 如果还有写者在排队 → 优先唤醒一个写者
- 否则 → 一次性放行所有读者
writer_present_ = false;
if (waiting_writers_) {
cv_write_.notify_one();
} else {
cv_read_.notify_all();
}
到这里为止,读写锁的内部规则就已经完整了。
后面就只是机械翻译成代码的问题。
五、最终版 RWLock:写者优先实现
整理完规则之后,我写出了这样的版本(就是现在我认可的 final 版本):
class RWLock {
public:
void ReadLock() {
std::unique_lock lock(mtx_);
cv_read_.wait(lock, [this] {
return !writer_present_ && waiting_writers_ == 0;
}); // 写者优先策略:有写者排队时,新读者要等
reader_count_++;
}
void ReadUnlock() {
std::unique_lock lock(mtx_);
reader_count_--;
if (reader_count_ == 0 && waiting_writers_ != 0) {
cv_write_.notify_one();
}
}
void WriteLock() {
std::unique_lock lock(mtx_);
waiting_writers_++;
cv_write_.wait(lock, [this] {
return reader_count_ == 0 && !writer_present_;
});
waiting_writers_--;
writer_present_ = true;
}
void WriteUnlock() {
std::unique_lock lock(mtx_);
writer_present_ = false;
if (waiting_writers_) {
cv_write_.notify_one();
} else {
cv_read_.notify_all();
}
}
// 为了让日志输出不打架,我在 RWLock 里顺手加了一个线程安全的 Log
void Log(const std::string& msg) {
std::lock_guard lock(log_mtx);
std::cout << msg << std::endl;
}
private:
std::mutex log_mtx; // 只保护日志输出
std::mutex mtx_; // 保护锁的内部状态
std::condition_variable cv_read_;
std::condition_variable cv_write_;
int reader_count_ = 0;
int waiting_writers_ = 0;
bool writer_present_ = false;
int data_ = 0; // 真正的数据这里没用上,只是预留
};
到这一步为止,读写锁本身已经算是写完了。
但是我还有两个问题没彻底搞明白:
- 第二个读者拿不到
mtx_时,它到底在干嘛?会不会直接“线程结束”? - 如何更直观地看清楚“读者并行 + 写者优先”的时间线?
于是我写了一个 demo 来做“实验”。
六、小 Demo:可视化读写锁的行为
我设计的逻辑是这样:
- 先启动两个读者:
Reader 1和Reader 2 - 稍等一下,让他们都拿到读锁
- 再启动一个写者:
Writer 1(此时必须排队等待) - 再启动第三个读者:
Reader 3(此时存在写者排队,读者必须被挡住) - 最后把所有线程 join,在控制台里看完整时间线
完整 demo 代码如下:
RWLock rwlock;
int shared_data = 0;
void ReaderFunc(int id) {
using namespace std::chrono_literals;
rwlock.Log("[Reader " + std::to_string(id) + "] 准备获取读锁");
rwlock.ReadLock();
rwlock.Log("[Reader " + std::to_string(id) + "] 已获取读锁,开始读取 shared_data = " +
std::to_string(shared_data));
std::this_thread::sleep_for(200ms);
rwlock.Log("[Reader " + std::to_string(id) + "] 读操作结束,准备释放读锁");
rwlock.ReadUnlock();
rwlock.Log("[Reader " + std::to_string(id) + "] 已释放读锁");
}
void WriterFunc(int id) {
using namespace std::chrono_literals;
rwlock.Log(" [Writer " + std::to_string(id) + "] 准备获取写锁");
rwlock.WriteLock();
rwlock.Log(" [Writer " + std::to_string(id) + "] 已获取写锁,开始修改 shared_data");
++shared_data;
std::this_thread::sleep_for(300ms);
rwlock.Log(" [Writer " + std::to_string(id) + "] 写操作结束,准备释放写锁");
rwlock.WriteUnlock();
rwlock.Log(" [Writer " + std::to_string(id) + "] 已释放写锁");
}
int main() {
using namespace std::chrono_literals;
std::vector threads;
// 先启动两个读者
threads.emplace_back(ReaderFunc, 1);
threads.emplace_back(ReaderFunc, 2);
// 稍微等一下,让他们先进入读锁
std::this_thread::sleep_for(50ms);
// 启动写者
threads.emplace_back(WriterFunc, 1);
// 再等一下,让写者开始排队
std::this_thread::sleep_for(50ms);
// 启动第三个读者(此时有写者排队,应该被“写者优先”挡住)
threads.emplace_back(ReaderFunc, 3);
for (auto &t : threads) {
t.join();
}
std::cout << "最终 shared_data = " << shared_data << std::endl;
return 0;
}
日志输出是通过
rwlock.Log()完成的,内部用了一把log_mtx来保证一条日志不会被拆成两半。
七、实际输出:写者优先的时间线长什么样?
实际跑出来的结果(配合截图理解)大致如下:
[Reader 2] 准备获取读锁
[Reader 1] 准备获取读锁
[Reader 2] 已获取读锁,开始读取 shared_data = 0
[Reader 1] 已获取读锁,开始读取 shared_data = 0
[Writer 1] 准备获取写锁
[Reader 3] 准备获取读锁
[Reader 1] 读操作结束,准备释放读锁
[Reader 1] 已释放读锁
[Reader 2] 读操作结束,准备释放读锁
[Reader 2] 已释放读锁
[Writer 1] 已获取写锁,开始修改 shared_data
[Writer 1] 写操作结束,准备释放写锁
[Writer 1] 已释放写锁
[Reader 3] 已获取读锁,开始读取 shared_data = 1
[Reader 3] 读操作结束,准备释放读锁
[Reader 3] 已释放读锁
最终 shared_data = 1
我从这段输出里看到了几件关键的事情:
- Reader 1 和 Reader 2 可以同时持有读锁
→ 说明读-读是并行的,读锁可共享 ✅ - Writer 1 在两个读者完成之前一直停在“准备获取写锁”之后
→ 说明写者要等所有读者释放读锁才能写 ✅ - Reader 3 虽然也很早就“准备获取读锁”,但真正拿到读锁是在 Writer 1 写完之后
→ 这说明:一旦写者开始排队,新的读者就不能插队进入
→ 写者优先策略生效 ✅ - 最后
shared_data从 0 变为 1
→ 只有 Writer 1 改了一次数据,没有出现多线程写乱的情况 ✅
这比我只在纸上看逻辑要清楚太多。
日志 + demo 对理解并发行为非常有用。
八、一个关键问题:拿不到锁的线程会不会“直接结束”?
回到我之前那个没想明白的问题:
“当一个读者已经拿到了
mtx_,第二个读者也来调用ReadLock(),拿不到mtx_,这个线程会不会直接结束?”
基于 demo + 输出,我现在可以非常肯定地回答:
❌ 不会结束,它只是“阻塞等待”。
更具体一点:
-
第二个读者会卡在这一行:
std::unique_lock lock(mtx_);如果此时 mutex 被别的线程持有,它就会进入 blocked 状态,等待 mutex 释放。
-
当第一个线程执行到:
cv.wait(lock, ...);时,
wait会:- 自动把 mutex 解锁;
- 把线程挂到条件变量的等待队列;
- 当前线程从 running → blocked;
-
一旦 mutex 被释放,第二个读者就有机会拿到它,继续往下执行。
所以:
- 拿不到锁 ≠ 线程结束
- 拿不到锁 = 线程暂时睡在“获取锁”这一步,等别人释放锁后再继续跑
这一点想明白之后,我对“mutex + condition_variable + 多线程调度”的直觉清晰了很多。
九、Day3 小结:这道题给我的收获
Day3 对我来说,是一个从“瞎写锁”到“真正懂一点锁”的过程。
总结一下今天的收获:
1)读写锁是一个小型状态机
它不仅仅是几行 mutex + cv,而是:
- 有自己的状态:
reader_count_ / waiting_writers_ / writer_present_ - 状态变化有规则:谁进来、谁出去、什么时候唤醒谁
2)写者优先不是一句话,是写在逻辑里的
真正实现“写者优先”的关键在于:
- 写者进入时:
waiting_writers_++ - 读者进入条件里要检查
waiting_writers_ == 0 - 读者退出时,如果读者已经为 0 且
waiting_writers_ > 0,就唤醒写者
3)理解 wait 的行为比记住 API 更重要
cv.wait(lock, predicate) 做了三件事:
- 在进入 wait 时自动释放 mutex;
- 线程睡在条件变量上;
- 被唤醒时重新加锁,并重新检查 predicate。
搞清楚这一点,对理解后面所有并发模型都很重要。
4)日志 + demo 是并发调试的好朋友
这次我专门写了一个 Log() 函数,用单独的 mutex 串行化输出。
这样我可以很清楚地看到:
- 每个线程在什么时候拿到了锁
- 谁在等谁
- 写者有没有优先
这比单纯看代码、脑内模拟,要真实和直观得多。
十、后记:我的并发手撕题路线
到 Day3 为止,我的“并发手撕题学习路线”大概长这样:
-
Day1:两个线程争抢一个变量 → 奇偶打印
- 认识
std::thread/std::mutex/std::condition_variable - 第一次体会“用条件 + 标志位控制轮到谁”
- 认识
-
Day2:从“轮到谁”到“队列状态” → 生产者–消费者
- 把条件变成“队列是否为空 / 是否已满”
- 初次接触“模型 + 封装(BlockingQueue)”
-
Day3:锁本身也有状态机 → 写者优先读写锁
- 学会给 Lock 画状态图
- 理解写者优先的真正含义
- 知道怎么用 demo 验证自己的实现
下一步,我会在这个 RWLock 的基础上继续往上叠:
-
Day4 目标:实现一个简易线程池(ThreadPool)
- 内部任务队列
- 多个工作线程
- 使用
std::future获取结果 - 正确处理线程池的“停止 / 析构”逻辑