大家好,我是 Shao G.
在高性能并发系统的构建中,"单写多读"(Single-Writer Multi-Reader, SWMR)是一种极其常见的访问模式。针对这一场景,Rust 标准库提供了 RwLock,社区中也有广受好评的 arc-swap。
然而,在对延迟极其敏感的场景下(如高频配置读取、实时渲染状态同步),即便是 Wait-Free 的 arc-swap 也仍有优化空间。本文将介绍 smr-swap 库,它通过一种基于版本的内存回收机制(Version-Based Memory Reclamation),实现了亚纳秒级的读取延迟,并在设计哲学上选择了"性能优先"的路径。
一、 引言:关于"轮子"的思考
在介绍 smr-swap 之前,有必要探讨一下为何我们需要一个新的并发原语。
1.1 现有方案的局限性
在 Rust 中处理 SWMR 场景,我们通常面临两个选择:
-
RwLock<T>:- 优点:语义标准,使用简单。
- 缺点:读操作需要 CAS 或原子加减,在激烈竞争下会导致 cache line 颠簸;且写锁会阻塞读锁。虽然
parking_lot优化了性能,但本质开销依然存在。
-
ArcSwap<T>:- 优点:实现了无锁(Wait-Free)读取,性能优异。
- 缺点:类型系统复杂度较高。
1.2 类型复杂度的熵增
在使用 ArcSwap 构建共享数据结构时,为了满足线程共享(Arc)和低成本快照(避免 Clone 大结构),开发者往往被迫构建出多层嵌套的类型:
// 典型的三层嵌套
let data: Arc<ArcSwapAny<Arc<HashMap<K, V>>>> = ...;
// 如果为了 Clone 优化引入 immutable collections
let data: Arc<ArcSwapAny<Arc<im::HashMap<K, V>>>> = ...;
这种"俄罗斯套娃"式的类型签名不仅在视觉上不够优雅,更在语义上模糊了所有权与修改源的界限。外层 Arc 的存在暗示了多处持有的可能性,掩盖了"单写"的本质约束。
二、 设计哲学与偶然发现
smr-swap 的诞生源于一次对 Epoch-based Synchronization(基于周期的同步)机制的探索性研究。
2.1 性能优先原则
不同于 ArcSwap 追求通用性和易用性,smr-swap 在设计之初就确立了 "Performance First"(性能优先) 的原则。我们假设:
如果用户愿意为了纳秒级的性能提升而付出额外的编码成本(如显式的句柄管理),我们能否突破现有的性能天花板?
2.2 意外的性能收益
在实现过程中,我们采用了基于版本的 GC 策略。最初的预期仅是实现一个轻量级的无锁结构,但基准测试的结果令人惊讶:在纯读取场景下,其延迟仅为 arc-swap 的十分之一(约 0.9ns vs 9.0ns)。
这一结果并非源于复杂的算法创新,而是将运行时开销前置的设计决策所带来的红利。
三、 SMR-Swap 的核心机制
smr-swap 的核心在于其底层引擎 swmr-cell,它实现了一套精简的基于版本的内存回收机制。
3.1 双重策略设计
做减法?不,无论是读还是写,我们全都要。smr-swap 现在提供两种截然不同的策略以适应不同的场景:
-
写优先 (Write-Preferred,默认):
- 均衡之道:使用对称内存屏障 (Symmetric Memory Barriers)。读写操作均承担常规的同步开销。
- 快速写入:在混合读写和多写者场景下,其写入速度显著快于读优先策略。
- 优秀读取:读取延迟 (~4.5ns) 依然比
ArcSwap(~9.2ns) 快约 一倍。
-
读优先 (Read-Preferred,Feature
read-preferred):- 极致读取:使用非对称内存屏障 (Asymmetric Memory Barriers)。同步开销几乎全部转移到了写入端。
- 亚纳秒级:读取延迟几乎可以忽略不计 (~0.9ns)。
- 写入略慢:由于重量级屏障和读者状态检查,写入操作开销较大。
3.2 读操作 (读优先策略)
对于"读优先"策略,读取路径被设计为绝对的最短路径:
- Pinning: 读取者将当前的
Global Version复制到自己的ReaderSlot中。这是一个简单的 Atomic Store。 - Load: 读取者加载数据指针。这是一个 Atomic Load。
- Access: 通过 RAII 守卫访问数据。
整个过程不包含 CAS 循环,不包含重试逻辑,也不需要复杂的内存屏障握手。这种"快照式"的注册机制,结合独立的 Cache Line,几乎完全消除了读者之间的竞争。
3.3 写操作与 GC
写者负责数据的原子交换和垃圾回收:
- Publish: 原子交换数据指针,并递增全局版本号。
- Reclaim: 扫描所有活跃读者的
ReaderSlot,计算出最小活跃版本 (Min Active Version)。任何版本小于该值的垃圾对象都将被安全释放。
四、 性能评估
我们在 Intel Core i9-13900KS 平台上进行了严格的基准测试。以下数据展示了 SMR-Swap (两种策略) 与 ArcSwap 及 Mutex 的对比。
4.1 延迟特性分析
| 场景 | SMR-Swap (写优先) | SMR-Swap (读优先) | ArcSwap | Mutex |
|---|---|---|---|---|
| 单线程读取 | 4.49 ns | 0.90 ns | 9.19 ns | ~20 ns |
| 多线程读取 (8T) | 5.10 ns | 0.94 ns | 9.60 ns | N/A |
| 混合负载 (1W+2R) | 66.10 ns | 86.01 ns | 428.14 ns | ~1.5 µs |
| 混合负载 (1W+8R) | 76.63 ns | 86.75 ns | 502.69 ns | ~1.5 µs |
| 单线程写入 | 54.84 ns | 89.81 ns | 104.79 ns | ~20 ns |
分析结论:
- 写优先 (默认):瞄准了 "Sweet Spot"。它在提供优秀的读取性能(延迟仅为 ArcSwap 的一半)的同时,保持了极快且可扩展的写入性能(在混合负载下显著超越 ArcSwap)。
- 读优先:读多写少场景的王者。如果你的应用 99% 以上的时间都在读取,该策略能提供亚纳秒级的延迟,带来数量级的性能优势。
4.2 开销权衡 (Trade-off)
性能的提升并非没有代价。SMR-Swap 遵循运行时成本前置的原则,导致其初始化和销毁成本相对较高。
| 操作 | SMR-Swap | ArcSwap | 说明 |
|---|---|---|---|
| 句柄创建 | ~152 ns | ~130 ns | 需分配并注册 ReaderSlot |
| 句柄销毁 | ~81 ns | ~110 ns | 需注销 Slot |
| 运行时检查 | ~0.2 ns | N/A | 本地状态检查几乎无感 |
这决定了 SMR-Swap 的最佳适用场景是长生命周期的任务(如服务器工作线程),而非频繁创建销毁的短任务。
五、 生态组件:swmr-cell 与 lfrlock
为了构建完整的解决方案,我们提供了分层的组件生态:
5.1 swmr-cell (Core Engine)
这是底层的 unsafe 原语,提供了最细粒度的控制。它负责指针管理、版本维护和内存回收。如果你需要构建自定义的并发数据结构,可以直接基于 swmr-cell 开发。
5.2 lfrlock (Lock-Free Read Lock)
针对需要多写者的场景,我们设计了 lfrlock。
- 定义:
LfrLock<T> = Mutex<T> (Write) + Wait-Free (Read) - 特性:写入串行化,读取无锁化。
- 约束:由于内部持有 Thread-Local 状态,
LfrLock是!Sync的。
注意:
- 简易封装:
LfrLock仅仅是smr-swap的一个便捷包装。对于需要更复杂的线程间共享模型,或者需要精细控制LocalReader生命周期的场景,我们强烈建议直接使用Arc<Mutex<SmrSwap<T>>>并手动管理读者的创建与分发,这样能获得更大的灵活性。 - 非 Drop-in Replacement:它要求使用者在多线程环境下通过克隆句柄(而非共享引用)来工作。
- 写操作成本:由于写操作涉及
Clone -> Modify -> Swap流程,对于 Cloning 成本巨大的类型需谨慎评估。
六、 No-std 支持
swmr-swap、swmr-cell 和 lfrlock 目前均已支持 no_std 环境。
lfrlock 支持 no_std 环境。要在 no_std crate 中使用它:
- 禁用默认特性。
- 如果需要 Mutex 支持(
LfrLock用于写入),请启用spin特性。 - 确保
alloc可用。
[dependencies]
lfrlock = { version = "0.2", default-features = false, features = ["spin"] }
注意:LfrLock 依赖 Mutex 来串行化写入。在 std 环境中,它使用 std::sync::Mutex。在启用了 spin 特性的 no_std 环境中,它使用 spin::Mutex。
七、 结语
smr-swap 是我们在 Rust 高性能并发领域的一次探索。它证明了通过合理的 API 约束和设计取舍(如通过 !Sync 换取 Thread-Local 优化,通过增加初始化开销换取运行时性能),我们仍然可以大幅突破现有的性能边界。
如果你的系统对延迟有着严苛的要求,或者你正在寻找一个语义更清晰的单写多读方案,欢迎尝试 smr-swap。
- SMR-Swap: https://github.com/ShaoG-R/smr-swap
- LfrLock: https://github.com/ShaoG-R/lfrlock
- Swmr-Cell: https://github.com/ShaoG-R/swmr-cell
Ext Link: https://github.com/ShaoG-R/smr-swap
评论区
写评论6
--
👇
ShaoG-R: 是类似于crossbeam-epoch,我最早实现是设计了一个swmr特化版本的epoch based内存回收,这里的version based只是将维护全局epoch简化成了维护对象版本,只回收历史对象版本小于安全回收版本的对象
--
👇
bestgopher: 类似 crossbeam-epoch 这样的吗
创造轮子不易,高低得点个赞
是类似于crossbeam-epoch,我最早实现是设计了一个swmr特化版本的epoch based内存回收,这里的version based只是将维护全局epoch简化成了维护对象版本,只回收历史对象版本小于安全回收版本的对象
--
👇
bestgopher: 类似 crossbeam-epoch 这样的吗
类似 crossbeam-epoch 这样的吗