【C++面试】互斥锁与读写锁:使用场景、实现原理与写饥饿问题

一、互斥锁和读写锁到底有什么区别

假设程序中有一个共享数据:

复制代码
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高级

而是需要看:

复制代码
读写比例
+
临界区大小
+
线程竞争程度
+
是否在意公平性

然后再选择更加合适的同步方式。

0voice · GitHub

相关推荐
longlongzihan1 小时前
回溯法详解:LeetCode 46. 全排列
算法·leetcode·回溯
秋名RG1 小时前
时间复杂度、空间复杂度与算法稳定性详解
数据结构·算法·排序算法
白色的北极熊1 小时前
c语言 求余运算符
c语言·数据结构·算法
波尔德1 小时前
Origin 2024 绘制钟形正态分布曲线:填充颜色并导出透明 PNG
人工智能·算法
Omics Pro2 小时前
微软:多模态生物世界模型
数据库·人工智能·算法·microsoft·机器学习·自然语言处理
by209992 小时前
list 的使用:把节点、位置和操作联系起来的综合叙述(上)
数据结构·c++·笔记·list
hai3152475432 小时前
语言学集合定律
人工智能·算法
小溪学编程2 小时前
C语言篇:语言结构
c语言·开发语言·算法
welfare9622 小时前
新号别搞:vector
c++