读密集型工作负载中RwLock的性能开销

读密集型工作负载中RwLock败给了无锁原子操作。文章构建了两种版本,对它们进行基准测试,并解释了为什么代价与其说在于锁本身,不如说在于同步发生的位置和频率。

中文
复制

2026-10-05

在之前的一次面试里,我聊到自己做过的一个项目,提到我们在某个服务里用了无锁数据结构。面试官回了一句:现在大家都说自己用了无锁结构,但我不知道它到底有多大好处。

这个问题问得没毛病。选有锁还是无锁,本来就没有标准答案。取决于同步的是什么、数据更新有多频繁、读的模式长什么样。

下面是我用过两种方案的一个场景的简化版。

数据模型


我们当时有一套挺复杂的关系型数据模型,下面是其中最小的一块核心数据结构,需要读写它。

struct Data {
    index: u32,
    metrics: Metrics, // mutable
    // other mutable/immutable fields
}

type Store = HashMap<u32, Arc<Data>>; 
type Block = Vec<Arc<Data>>; // Arc<Data> is a ref of data in `Store`

Store 为每个 ID 保存一个 Data,而每个 Block 又持有对其中一部分 ID 的引用。

我们的服务读多写少。每个读请求都会遍历 Block 中的所有元素,并在上面做一些计算。这些读是并发进行的。

写只有一个线程,它持续更新 Store 里的数据。

修改数据


每次写入,我们都要替换整个 Metrics 的值。为此考虑过两种做法:用 RwLock 包一层,或者原子地替换这个值。

RwLock

多读单写的场景下,读写锁通常是第一个想到的方案:

use parking_lot::RwLock;

struct Data {
    index: u32,
    metrics: RwLock<Metrics>,
}

fn read(block: &Block) -> u32 {
    let mut max_count = 0;

    for data in block {
        max_count = max_count.max(data.metrics.read().count);
    }

    max_count
}

fn write(store: &Store, index: u32, metrics: Metrics) {
    *store[&index].metrics.write() = metrics;
}

std 和 Tokio 里还有别的 RwLock 实现,但我们选了 parking_lot。std::sync::RwLock 把读/写的优先级顺序交给操作系统决定,而 tokio::sync::RwLock 是异步的,获取它可能会 yield。我们的遍历是同步计算,不想在获取每一项时都 yield,所以 parking_lot 更合适。

Crossbeam Atomic

另一种做法是把一个不可变的 Metrics 值放在原子指针后面。读方加载当前指针,写方则用新分配的值把它换掉。

use crossbeam_epoch::{Atomic, Owned};
use std::sync::atomic::Ordering;

struct Data {
    index: u32,
    metrics: Atomic<Metrics>,
}

fn read(block: &Block) -> u32 {
    let guard = crossbeam_epoch::pin();
    let mut max_count = 0;

    for data in block {
        let metrics = data.metrics.load(Ordering::Acquire, &guard);

        // SAFETY: The epoch guard ensures the pointer remains valid
        if let Some(metrics) = unsafe { metrics.as_ref() } {
            max_count = max_count.max(metrics.count);
        }
    }

    max_count
}

fn write(store: &Store, index: u32, metrics: Metrics) {
    let guard = crossbeam_epoch::pin();
    let data = &store[&index];

    let old = data.metrics.swap(
        Owned::new(metrics),
        Ordering::AcqRel,
        &guard,
    );

    if !old.is_null() {
        // SAFETY: `old` is no longer stored in the atomic and is protected by the epoch guard
        unsafe {
            guard.defer_destroy(old);
        }
    }
}

读方加载 Metrics 时,epoch guard 会让这个值一直存活到读方用完为止。

写方把 metrics 换成新值后,旧值不会立刻被释放。defer_destroy 会把旧值标记为稍后 drop,等所有还在访问它的活跃读方都结束之后再释放。

基准测试


这个简化示例的完整基准测试代码在 Git 上。

设置

  • 读线程:50 个并发 Tokio 任务
  • 写线程:1 个,每秒 500 次写入
  • 块大小:16,384
  • CPU 预热:2 秒
  • 测量时长:10 秒,取 5 次运行的平均值
  • 机器:搭载 Apple Silicon 的 MacBook Air

每个读任务在循环中调用 read(),每次调用后执行 yield_now()。写任务以固定速率调用 write(),每秒 500 次写入。

结果

基准测试读/秒写/秒p50p95p99
RwLock15.93k499.97441.42µs910.18µs1.81ms
Atomic229.07k500.0022.44µs49.84µs241.97µs

与 RwLock 相比,无锁版本的读吞吐量显著更高,p50、p95 和 p99 的读延迟也低得多。

乍看之下,RwLock 吞吐量较低似乎是因为读写争用。但关掉写线程后,读吞吐量几乎没有变化。

基准测试读/秒写/秒p50p95p99
RwLock(有写线程)15.93k499.97441.42µs910.18µs1.91ms
RwLock(无写线程)16.01k0.00431.50µs905.85µs1.87ms

读锁获取


parking_lot 内部使用 AtomicUsize 来跟踪活跃读线程的数量。获取读锁时,它通过原子比较交换操作递增活跃读线程计数。释放读锁时,则通过原子减法递减该计数。

因此,对于 Block 中的一个条目,一次简化的读取是:

        reader
          |
          | read lock: readers_count + 1
          v
        read Metrics.count
          |
          | read unlock: readers_count - 1
          v
        continue

一次逻辑块读取会将上述过程重复 16,384 次:

read one Block
  |
  +-- Block[0]      lock -> read -> unlock
  +-- Block[1]      lock -> read -> unlock
  +-- Block[2]      lock -> read -> unlock
  |
  |   ...
  |
  +-- Block[16_383] lock -> read -> unlock

这里,单次遍历就有 16,384 次读锁获取和 16,384 次锁释放。因此,在每秒约 16k 次遍历的情况下,RwLock 内部每秒大约执行 5.24 亿次更新。

虽然 CPU 每秒能执行数十亿次操作,但比较交换和原子减法这类原子操作通常比普通读写更昂贵。这些原子操作必须在并发更新下准确跟踪活跃读线程计数,即便没有争用也会带来额外开销。

为什么 Crossbeam 的原子读取更便宜


Crossbeam 的 pin() 函数会在整个 Block 遍历期间,把当前线程固定到一个 epoch 上。这等于告诉 Crossbeam:当前线程可能正在访问共享数据,因此该线程可能用到的值不应被回收。

这样一来,Block 中的每个元素只需一次原子指针加载,而不必为每个元素原子地增减活跃读者计数。

pin epoch once
  |
  +-- Block[0]      atomic pointer load
  +-- Block[1]      atomic pointer load
  +-- Block[2]      atomic pointer load
  |
  |   ...
  |
  +-- Block[16_383] atomic pointer load
  |
drop epoch guard

Epoch 固定和回收本身也有簿记开销,但用一次 Crossbeam epoch guard 加原子指针加载取代 16,384 个 RwLock 读守卫,效果差别很大。

其他方案


ArcSwap:ArcSwap 是读多写少场景的另一个选择。就这种读取模式而言,对 Block 中的每个元素使用 ArcSwap::load() 仍会带来一定的逐元素开销,而用 Crossbeam 只需为整个 Block 固定一次。

left-right:left-right 通过保留两份状态、把更多工作转移给写者,可以大幅提高读吞吐。对我们的服务来说,生产数据集在内存中约 4GB,复制这份状态会显著增加内存占用。而用 Crossbeam 时,我们已经满足了吞吐和延迟要求,所以不需要做这个取舍。

可变块


我们的 Metrics 变化频繁,但新条目每秒只往 Block 里加一两次。为此,在 Vec 外面套一个简单的 RwLock 就够了。

use parking_lot::RwLock;

type Block = RwLock<Vec<Arc<Data>>>;
BenchmarkReads/sWrites/sInserts/secp50p95p99
Locked block204.40k500.011.0626.17µs52.59µs258.83µs

这里,Data 中各个 Metrics 值仍然使用 Crossbeam 的 Atomic 指针。由于插入并不频繁,我们认为没有必要让 Block 本身也做到无锁。

结论


这些结果只针对我们的工作负载,并不一定意味着无锁总是更快。

无锁帮我们省去了获取和释放成千上万个读锁的开销。但我们也看到,只要把简单的 RwLock 套在 Block 外面,效果同样不错。

归根结底,问题并不只是在于选择有锁还是无锁。同步发生在哪里、发生得有多频繁,同样重要。

来源: pranitha.dev← 返回首页