Day3:我第一次从零实现“写者优先”的读写锁 RWLock

5 阅读13分钟

这是我操作系统 / 多线程手撕题的 Day3

  • Day1:我刚认识 std::threadstd::mutexstd::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 里修改状态却没加锁

我在 ReadUnlockWriteUnlock 里一开始是这样写的:

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)

写者进入的过程稍微复杂一点:

  1. 来到门口 → 先声明“我在排队”:++waiting_writers_

  2. 然后等待以下条件满足:

    • 没有读者:reader_count_ == 0
    • 没有写者正在写:!writer_present_
  3. 条件满足后:

    • 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;  // 真正的数据这里没用上,只是预留
};

到这一步为止,读写锁本身已经算是写完了。
但是我还有两个问题没彻底搞明白:

  1. 第二个读者拿不到 mtx_ 时,它到底在干嘛?会不会直接“线程结束”?
  2. 如何更直观地看清楚“读者并行 + 写者优先”的时间线?

于是我写了一个 demo 来做“实验”。


六、小 Demo:可视化读写锁的行为

我设计的逻辑是这样:

  1. 先启动两个读者:Reader 1Reader 2
  2. 稍等一下,让他们都拿到读锁
  3. 再启动一个写者:Writer 1(此时必须排队等待)
  4. 再启动第三个读者:Reader 3(此时存在写者排队,读者必须被挡住)
  5. 最后把所有线程 join,在控制台里看完整时间线

完整 demo 代码如下:

RWLock rwlock;
int shared_data = 0;

void ReaderFunc(int id) {
    using namespace std::chrono_literals;

    rwlock.Log(&#34;[Reader &#34; + std::to_string(id) + &#34;] 准备获取读锁&#34;);

    rwlock.ReadLock();
    rwlock.Log(&#34;[Reader &#34; + std::to_string(id) + &#34;] 已获取读锁,开始读取 shared_data = &#34; +
        std::to_string(shared_data));

    std::this_thread::sleep_for(200ms);

    rwlock.Log(&#34;[Reader &#34; + std::to_string(id) + &#34;] 读操作结束,准备释放读锁&#34;);
    rwlock.ReadUnlock();

    rwlock.Log(&#34;[Reader &#34; + std::to_string(id) + &#34;] 已释放读锁&#34;);
}

void WriterFunc(int id) {
    using namespace std::chrono_literals;

    rwlock.Log(&#34;    [Writer &#34; + std::to_string(id) + &#34;] 准备获取写锁&#34;);

    rwlock.WriteLock();
    rwlock.Log(&#34;    [Writer &#34; + std::to_string(id) + &#34;] 已获取写锁,开始修改 shared_data&#34;);

    ++shared_data;
    std::this_thread::sleep_for(300ms);

    rwlock.Log(&#34;    [Writer &#34; + std::to_string(id) + &#34;] 写操作结束,准备释放写锁&#34;);
    rwlock.WriteUnlock();

    rwlock.Log(&#34;    [Writer &#34; + std::to_string(id) + &#34;] 已释放写锁&#34;);
}

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 << &#34;最终 shared_data = &#34; << 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

我从这段输出里看到了几件关键的事情:

  1. Reader 1 和 Reader 2 可以同时持有读锁
    → 说明读-读是并行的,读锁可共享 ✅
  2. Writer 1 在两个读者完成之前一直停在“准备获取写锁”之后
    → 说明写者要等所有读者释放读锁才能写 ✅
  3. Reader 3 虽然也很早就“准备获取读锁”,但真正拿到读锁是在 Writer 1 写完之后
    → 这说明:一旦写者开始排队,新的读者就不能插队进入
    → 写者优先策略生效 ✅
  4. 最后 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) 做了三件事:

  1. 在进入 wait 时自动释放 mutex
  2. 线程睡在条件变量上;
  3. 被唤醒时重新加锁,并重新检查 predicate。

搞清楚这一点,对理解后面所有并发模型都很重要。

4)日志 + demo 是并发调试的好朋友

这次我专门写了一个 Log() 函数,用单独的 mutex 串行化输出。
这样我可以很清楚地看到:

  • 每个线程在什么时候拿到了锁
  • 谁在等谁
  • 写者有没有优先

这比单纯看代码、脑内模拟,要真实和直观得多。


十、后记:我的并发手撕题路线

到 Day3 为止,我的“并发手撕题学习路线”大概长这样:

  1. Day1:两个线程争抢一个变量 → 奇偶打印

    • 认识 std::thread / std::mutex / std::condition_variable
    • 第一次体会“用条件 + 标志位控制轮到谁”
  2. Day2:从“轮到谁”到“队列状态” → 生产者–消费者

    • 把条件变成“队列是否为空 / 是否已满”
    • 初次接触“模型 + 封装(BlockingQueue)”
  3. Day3:锁本身也有状态机 → 写者优先读写锁

    • 学会给 Lock 画状态图
    • 理解写者优先的真正含义
    • 知道怎么用 demo 验证自己的实现

下一步,我会在这个 RWLock 的基础上继续往上叠:

  • Day4 目标:实现一个简易线程池(ThreadPool)

    • 内部任务队列
    • 多个工作线程
    • 使用 std::future 获取结果
    • 正确处理线程池的“停止 / 析构”逻辑