读密集型工作负载中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 次写入。
结果
| 基准测试 | 读/秒 | 写/秒 | p50 | p95 | p99 |
|---|---|---|---|---|---|
| RwLock | 15.93k | 499.97 | 441.42µs | 910.18µs | 1.81ms |
| Atomic | 229.07k | 500.00 | 22.44µs | 49.84µs | 241.97µs |
与 RwLock 相比,无锁版本的读吞吐量显著更高,p50、p95 和 p99 的读延迟也低得多。
乍看之下,RwLock 吞吐量较低似乎是因为读写争用。但关掉写线程后,读吞吐量几乎没有变化。
| 基准测试 | 读/秒 | 写/秒 | p50 | p95 | p99 |
|---|---|---|---|---|---|
| RwLock(有写线程) | 15.93k | 499.97 | 441.42µs | 910.18µs | 1.91ms |
| RwLock(无写线程) | 16.01k | 0.00 | 431.50µs | 905.85µs | 1.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>>>;
| Benchmark | Reads/s | Writes/s | Inserts/sec | p50 | p95 | p99 |
|---|---|---|---|---|---|---|
| Locked block | 204.40k | 500.01 | 1.06 | 26.17µs | 52.59µs | 258.83µs |
这里,Data 中各个 Metrics 值仍然使用 Crossbeam 的 Atomic 指针。由于插入并不频繁,我们认为没有必要让 Block 本身也做到无锁。
结论
这些结果只针对我们的工作负载,并不一定意味着无锁总是更快。
无锁帮我们省去了获取和释放成千上万个读锁的开销。但我们也看到,只要把简单的 RwLock 套在 Block 外面,效果同样不错。
归根结底,问题并不只是在于选择有锁还是无锁。同步发生在哪里、发生得有多频繁,同样重要。