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

Lobsters Hottest 新闻

摘要

一篇技术博客,对比了 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` 时同样表现良好。 归根结底,这不只是在有锁和无锁之间做选择的问题。同步发生的位置和频率同样重要。

相似文章

Rust语言的性能

Lobsters Hottest

本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。