RwLock 允许多个读者并发访问,但“允许并发”不等于“读取路径没有竞争”。Left-Right 用两份数据、操作日志和显式发布,把大部分协调成本从读者转移给写者。本文从缓存一致性、算法流程和 Rust 实现三个层面解释这笔交换何时值得。

从一个看似合理的 RwLock 开始

假设服务中有一份商品价格表:请求线程频繁查询价格,后台线程偶尔更新价格。最直接的实现是:

use std::{collections::BTreeMap, sync::RwLock};

struct Catalog {
    prices: RwLock<BTreeMap<u64, u64>>,
}

impl Catalog {
    fn price(&self, id: u64) -> Option<u64> {
        self.prices.read().unwrap().get(&id).copied()
    }

    fn upsert(&self, id: u64, price: u64) {
        self.prices.write().unwrap().insert(id, price);
    }
}

这段代码容易理解,也通常是正确的起点。RwLock 的读锁可以被多个线程同时持有,因此它并没有把所有读取串行化。

问题出现在高并发下:获取和释放读锁仍然需要协调共享状态。多个 CPU 核会反复更新或观察同一块锁元数据,对应的缓存行会在核心之间迁移。数据查询可能只是一次很快的 BTreeMap::get,锁状态的原子操作和缓存一致性流量反而逐渐成为热点。

因此,准确的说法不是“RwLock 的读取不能并行”,而是:

RwLock 的读取能够并行,但读者仍在同一个同步点上协调。

此外,写者到来时,新读者是否继续进入、写者是否优先等策略依赖具体实现和操作系统。平均延迟可能不错,尾延迟却会随竞争上升。

Left-Right 的核心交换

Left-Right 同时维护两份逻辑相同的数据,通常称为 Left 和 Right:

flowchart LR
    R1["Reader 1"] --> P["active pointer"]
    R2["Reader 2"] --> P
    RN["Reader N"] --> P
    P --> L["Left: 当前读副本"]
    W["Single writer"] --> O["operation log"]
    O --> RR["Right: 当前写副本"]
    W -. "publish / flip" .-> P

一次发布可以分成下面几个阶段:

  1. 写者把修改应用到当前写副本,并把修改记入 oplog。
  2. 写者以原子方式把 active pointer 切到更新后的副本。