读写锁

plus2047 于 2023-01-01 发布

我在 2020 年某大厂实习时,曾经因为业务需求在 C++ 后端实现一个 Join 逻辑。当时的业务场景是新闻推荐系统,需求是实时生成曝光和点击训练数据流,然后使用流处理技术生成实时的训练样本,后续实时训练推荐系统模型。这一套日志-训练流程原本是用户行为日志落盘到 HDFS 后,使用 Spark 以 Batch Processing 的形式离线 Join 特征的。改成实时系统之后的设计为,后端在推荐引擎(Ranking 模型)获取用户特征完成推断后,缓存这部分用户特征。然后在曝光、点击用户行为返回后端后,后端将会从缓存中获取进行推断时的用户特征,与用户行为组合成为训练数据流。

于是,后端部分就需要实现一个简单的缓存以及查询功能。我们的后端是 C++ bRPC 框架(Baidu 版本的 gRPC 框架),C++ 版本为 C++11, 无法使用标准库之外的库。这样的逻辑在这个前向推断为主的引擎中是第一次实现,并没有其他代码参考。我负责从零实现这套逻辑。

bRPC / gRPC 当然是个高并发框架,我们需要实现一个并发安全的缓存。缓存机制选用最简单的 LRU Cache,于是我确实写了一个像极了面试题的 LRU Cache 在那里,对外暴露的接口与一个 map 类似,只支持元素的读和写两种操作。剩下的问题是,如何在 C++11 中,如何保证这个 Cache 的并发安全呢?

C++11 中有基本的锁 mutex 库。如果对整个容器加锁,当然是最简单的方案,

#include <map>
#include <mutex>

std::map<int, int> myMap;
std::mutex mtx;

void readMap() {
  std::lock_guard<std::mutex> lock(mtx);
  // access the map here
}

void writeMap() {
  std::lock_guard<std::mutex> lock(mtx);
  // modify the map here
}

但这样是比较浪费的,实际上容器可以并行读取,只是不能并行写入。

我只学习过并发编程的一点皮毛。我粗浅的理解,为了实现这个需求,我可以只对写操作加锁,读操作检查一下锁就可以。结果出了线上 core dump. 之后我调试了好久,也没能解决问题。

现在重温这个问题,发现这是个著名问题 Read-Write-Lock. 一种著名实现如下,

class ReadWriteLock {
    int reader_cnt = 0;
    std::mutex reader_cnt_mutex, writer_mutex;

public:
    void begin_read() {
        std::lock_guard<std::mutex> r_lock(reader_cnt_mutex);
        reader_cnt++;
        if(reader_cnt == 1) writer_mutex.lock();
    }
    void end_read() {
        std::lock_guard<std::mutex> r_lock(reader_cnt_mutex);
        reader_cnt++;
        if(reader_cnt == 0) writer_mutex.unlock();
    }
    void begin_write() {
        write_mutex.lock();
    }
    void end_write() {
        write_mutex.unlock();
    }
}

C++14 有内建的实现,

#include <mutex>
#include <shared_mutex>
#include <map>

std::map<int, int> table;
std::shared_mutex table_shared_mutex;

int get(int key) {
    std::shared_lock<std::shared_mutex> s_lock(table_shared_mutex);
    return table[key];
}

int set(int key, int value) {
    std::unique_lock<std::shared_mutex> u_lock(table_shared_mutex);
    table[key] = value;
}