读密集型工作负载中 RwLock 的性能开销
摘要
一篇技术博客,对比了 Rust 中 parking_lot RwLock 与 crossbeam epoch 原子指针在读密集型服务中的性能表现,指出读写锁在高并发读场景下存在明显开销。
<p><a href="https://lobste.rs/s/pioj21/performance_cost_rwlock_our_read_heavy">评论</a></p>
查看缓存全文
缓存时间: 2026/10/08 16:00
# 读密集型负载下 RwLock 的性能代价
来源:https://pranitha.dev/posts/rwlock-vs-lockfree/
在一次过去的面试中,我聊到之前的工作,提到我们在一个服务中使用了无锁数据结构。面试官回应道,*现在的人总是说自己用无锁结构,但我不知道它们到底有多大好处。*
这是一个合理的问题。在有锁和无锁之间做选择,并没有标准答案。它取决于要同步的是什么、数据更新的频率,以及读取模式是什么样的。以下是我们在一个负载中同时使用两种方式的简化版本。
## 我们的数据模型
---
我们有一个相当复杂的关系型数据模型,但下面是需要读写的一个最小核心数据结构:
```
struct Data {
index: u32,
metrics: Metrics, // 可变的
// 其他可变/不可变字段
}
type Store = HashMap<u32, Arc<Data>>;
type Block = Vec<Arc<Data>>;
// Arc 是 `Store` 中数据的引用
```
`Store` 按 ID 保存 `Data`,每个 `Block` 包含对其中一部分数据的引用。我们的服务是读密集型的。每个读请求都会遍历 `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` 是异步的,获取锁时可能会让出执行。我们的遍历是同步计算,不希望在获取每个元素时都让出执行,所以 `parking_lot` 更适合。
### Crossbeam 原子操作
另一种方案是将不可变的 `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: epoch guard 确保指针在使用期间有效
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` 已不再存放在原子变量中,且受 epoch guard 保护
unsafe {
guard.defer_destroy(old);
}
}
}
```
当读者加载 `Metrics` 时,epoch guard 会保持该值存活,直到读者完成读取。当写者将 `metrics` 替换为新值时,旧值不会被立即释放。`defer_destroy` 会标记旧值在之后延迟释放,等到所有正在访问它的活跃读者完成后才真正销毁。
## 基准测试
---
这个简化示例的完整基准测试代码在 Git 上:https://github.com/sattva9/rwlock-vs-lockfree
### 设置
- **读者**:50 个并发 Tokio 任务
- **写者**:1 个写者,每秒 500 次写入
- **Block 大小**: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 |
无锁版本在读取吞吐量上显著更高,且在 p50、p95 和 p99 延迟上都远低于 `RwLock`。
乍一看,`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` 来跟踪活跃读者的数量。获取读锁时,通过原子比较交换(compare-and-exchange)操作增加活跃读者计数。释放读锁时,通过原子减法操作减少读者计数。因此对于 `Block` 中的每一个条目,一个简化的读取过程如下:
```
reader
|
| 读锁:readers_count + 1
v
读取 Metrics.count
|
| 解锁:readers_count - 1
v
继续下一个
```
一次逻辑的 Block 读取,会重复上述过程 16,384 次:
```
读取一个 Block
+-- Block[0] 加锁 -> 读取 -> 解锁
+-- Block[1] 加锁 -> 读取 -> 解锁
+-- Block[2] 加锁 -> 读取 -> 解锁
|
...
|
+-- Block[16_383] 加锁 -> 读取 -> 解锁
```
这里我们对一次遍历执行了 16,384 次读锁获取和 16,384 次锁释放。所以,按照每秒约 16k 次遍历计算,`RwLock` 内部每秒执行了约 5.24 亿次更新。虽然 CPU 每秒可以执行数十亿次操作,但比较交换和原子减法这类原子操作通常比普通读写更加昂贵。这些原子操作必须在并发更新下精确地跟踪活跃读者计数,即使在没有竞争的情况下也会产生额外开销。
## 为什么 Crossbeam 原子读取更廉价
---
Crossbeam 的 `pin()` 函数会在整个 `Block` 遍历期间将当前线程固定(pin)到一个 epoch 上。这告诉 Crossbeam 当前线程可能正在访问共享数据,因此该线程可能使用的任何值都不应被回收。
现在 `Block` 中的每个元素只需要一次原子指针加载,而不再需要对每个项目都原子地递增/递减活跃读者计数。
```
固定 epoch 一次
+-- Block[0] 原子指针加载
+-- Block[1] 原子指针加载
+-- Block[2] 原子指针加载
|
...
|
+-- Block[16_383] 原子指针加载
释放 epoch guard
```
虽然 epoch 固定和回收本身也有簿记开销,但用一个 Crossbeam epoch guard 和原子指针加载来替代 16,384 个 `RwLock` 读锁,带来了显著的性能差异。
## 其他替代方案
---
**ArcSwap**(https://docs.rs/arc-swap/latest/arc_swap/):`ArcSwap` 是读密集型负载的另一个选项。对于这种读取模式,对 `Block` 中每个项目使用 `ArcSwap::load()` 仍然会增加每项的开销,而使用 Crossbeam 只需为整个 `Block` 固定一次 epoch。
**left-right**(https://docs.rs/left-right/latest/left_right/):`left-right` 通过维护重复的状态并将更多工作转移到写者侧,可以提供更高的读取吞吐量。对于我们的服务,生产数据集在内存中约为 4GB,复制这份状态将显著增加内存使用。而使用 Crossbeam,我们已经满足了吞吐量和延迟要求,所以不需要做这个权衡。
## 可变 Block
---
虽然我们的 `Metrics` 频繁变化,但向 `Block` 中添加新条目每秒只发生一两次。对此,一个简单的 `RwLock` 包裹 `Vec` 就足够了。
```
use parking_lot::RwLock;
type Block = RwLock<Vec<Arc<Data>>>;
```
| 基准测试 | 读取/秒 | 写入/秒 | 插入/秒 | p50 | p95 | p99 |
|---|---|---|---|---|---|---|
| 锁保护的 Block | 204.40k | 500.01 | 1.06 | 26.17μs | 52.59μs | 258.83μs |
这里,`Data` 中的单个 `Metrics` 值仍然使用 Crossbeam `Atomic` 指针。由于插入操作并不频繁,我们不认为有必要让 `Block` 本身也变成无锁的。
## 结论
---
这些结果是针对我们的特定负载得出的,并不意味着无锁总是更快。采用无锁方案帮助我们避免了获取和释放数千个读锁的开销。但我们也看到,简单的 RwLock 在包裹 `Block` 时同样表现良好。
归根结底,这不只是在有锁和无锁之间做选择的问题。同步发生的位置和频率同样重要。
相似文章
只改一个函数,性能提升10倍?解读一个 Rust 性能 PR
一篇深度博文,通过基准测试复现和代码分析,解读 GreptimeDB 中让 Prometheus 读取转换速度提升 10 倍的 Rust 性能 PR。
Rust语言的性能
本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。
分片你的锁:对6种Go缓存设计进行基准测试
本文在不同工作负载下对六种Go内存缓存设计进行了基准测试,发现使用256个锁的分片映射在单互斥锁和读写锁方法中表现最佳,尤其是在多核系统上。
@mylifcc: LiteLLM 正式迁移到 Rust 了! AI Gateway 迎来史诗级性能升级: 单请求开销降低 150 倍(~0.05ms vs Python 7.5ms) 吞吐量提升 15 倍 内存占用降低 11 倍(峰值仅 32MB) 单个 …
LiteLLM已从Python迁移到Rust,性能大幅提升:请求开销降低150倍至0.05ms,吞吐量提升15倍,内存占用降低11倍至32MB。
性能去哪了?理解并修复 C 到 Rust 自动翻译中的性能回退问题
该论文研究了 C2Rust 自动 C 到 Rust 翻译中的性能回退问题,分析出九类常见性能问题,并提出基于 LLM 规则引导的 PerfR 方法,在多个项目上恢复甚至超越原始 C 性能。