大家好,我是 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 核心组件
- Global Version (全局版本):一个原子递增的计数器,代表数据的当前代际。
- Local Reader (本地读者):每个读取线程持有的独立句柄,包含一个缓存行对齐的
ReaderSlot。 - Garbage Queue (垃圾队列):存储被替换的历史数据及其关联版本。
3.2 读操作:极致的 Wait-Free
读取路径被设计为绝对的最短路径:
- 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 | ArcSwap | Mutex | 性能差异 |
|---|---|---|---|---|
| 单线程读取 | 0.90 ns | 9.20 ns | ~20 ns | 10x 提升 |
| 多线程读取 (8T) | 0.93 ns | 9.75 ns | N/A | 10x 提升 |
| 混合负载 (1W+8R) | 58.21 ns | 509.31 ns | ~1.5 µs | 89% 提升 |
| 单线程写入 | 66.61 ns | 104.97 ns | ~20 ns | 快 37% (vs ArcSwap) |
分析结论:
- 在读取密集型负载下,
smr-swap展现出数量级的优势,延迟稳定在 1ns 以下。 - 在混合读写场景下,由于 GC 是分批异步进行的,且写不阻塞读,整体吞吐量依然保持在高位。
4.2 开销权衡 (Trade-off)
性能的提升并非没有代价。SMR-Swap 遵循运行时成本前置的原则,导致其初始化和销毁成本相对较高。
| 操作 | SMR-Swap | ArcSwap | 说明 |
|---|---|---|---|
| 句柄创建 | ~214 ns | ~130 ns | 需分配并注册 ReaderSlot |
| 句柄销毁 | ~65 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
评论区
写评论创造轮子不易,高低得点个赞
是类似于crossbeam-epoch,我最早实现是设计了一个swmr特化版本的epoch based内存回收,这里的version based只是将维护全局epoch简化成了维护对象版本,只回收历史对象版本小于安全回收版本的对象
--
👇
bestgopher: 类似 crossbeam-epoch 这样的吗
类似 crossbeam-epoch 这样的吗