Rust 的所有权系统天然排斥共享可变状态 —— 同一时刻只能有一个可变借用,或多个不可变借用。这一规则在编译期消除了数据竞争,却让需要跨线程共享可变数据的场景成为工程难题。本文聚焦三条实现路径的工程权衡:Arc+Mutex 的同步原语方案、内部可变性模式(RefCell/Cell/ UnsafeCell),以及unsafe 块的极限优化。作者 Alice Ryhl 在其关于共享可变状态的经典文章中系统阐述了这一主题,本文的参数建议也源于其实践总结。
标准方案:Arc+Mutex 的安全基石
当需要跨线程共享可变数据时,标准答案是 Arc<Mutex<T>> 或 Arc<RwLock<T>>。Arc 提供共享所有权的引用计数能力,Mutex(互斥锁)则确保同一时刻只有一个线程可以访问内部数据。Alice Ryhl 推荐的最小化封装结构如下:
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct SharedState {
inner: Arc<Mutex<SharedStateInner>>,
}
struct SharedStateInner {
counter: u64,
data: Vec<String>,
}
impl SharedState {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(SharedStateInner {
counter: 0,
data: Vec::new(),
})),
}
}
pub fn increment(&self) {
let mut lock = self.inner.lock().unwrap();
lock.counter += 1;
}
pub fn get_counter(&self) -> u64 {
let lock = self.inner.lock().unwrap();
lock.counter
}
}
这种模式将锁的获取细节封装在类型内部,调用方无需知道同步机制的存在。关键工程参数:锁粒度应尽可能细,持有时间不超过 100 微秒(异步场景)或 1 毫秒(同步场景),以避免严重性能退化。
在异步代码中使用标准库的 Mutex 存在一个致命陷阱:禁止在持有锁时执行 .await。编译器在多数情况下会捕获这一问题并报出 "future cannot be sent between threads safely" 错误,因为 MutexGuard 未实现 Send。最佳实践是始终在非 async 函数中完成锁定操作,然后在异步上下文中调用这些同步方法:
impl Debouncer {
pub fn reset_deadline(&self) { // 同步方法,内部锁定
let mut lock = self.inner.lock().unwrap();
lock.deadline = Instant::now() + lock.duration;
}
pub async fn sleep(&self) { // 只读取,不锁定
loop {
let deadline = self.get_deadline(); // 辅助函数获取值
if deadline <= Instant::now() {
return;
}
tokio::time::sleep_until(deadline).await;
}
}
fn get_deadline(&self) -> Instant {
let lock = self.inner.lock().unwrap();
lock.deadline
}
}
如果确实需要在持锁时 await,应使用 tokio::sync::Mutex(异步锁)。但 Alice Ryhl 强调,异步锁比阻塞锁慢 3-5 倍,且无法在析构函数中使用,应作为最后手段。
内部可变性:RefCell 的单线程妥协
对于单线程场景下的共享可变需求,RefCell<T> 提供了运行时可变的内部可变性。它通过 borrow() 和 borrow_mut() 方法在运行时检查借用规则,违反时 panic。典型用法是配合 Rc 实现引用计数:
use std::cell::RefCell;
use std::rc::Rc;
let data = Rc::new(RefCell::new(vec![1, 2, 3]));
let data_clone = Rc::clone(&data);
data_clone.borrow_mut().push(4); // 运行时借用检查
println!("{:?}", data.borrow()); // [1, 2, 3, 4]
工程阈值:当借用检查频率超过每秒 10 万次时,RefCell 的运行时检查开销可能成为瓶颈,此时应考虑裸指针配合 unsafe(但需极度谨慎)。
Cell<T> 提供更轻量的内部可变性,适用于 Copy 类型,通过 get() 和 set() 直接取值 / 赋值,无运行时借用检查。对于高性能场景,如果数据结构足够简单,Cell 比 RefCell 快 2-3 倍。
对于线程安全需求,std::sync::RwLock 是读取密集型场景的首选 —— 多个读取器可并发访问,只有一个写入者。Alice Ryhl 特别指出,使用 RwLock 时需警惕饥饿问题:大量读取者可能导致写入者长时间无法获取锁。parking_lot 库的 RwLock 采用公平策略,可缓解此问题。
unsafe 块:性能极限与安全边界
当标准同步原语成为性能瓶颈时,unsafe 块提供了绕过借用检查的能力。UnsafeCell<T> 是 Rust 中唯一的合法可变引用_cell 容器,所有内部可变性机制(RefCell、Mutex、RwLock)底层都依赖它。
use std::cell::UnsafeCell;
unsafe {
let ptr = data.get();
// 手动保证:同一时刻只有一个可变借用
(*ptr).push(42);
}
使用 unsafe 意味着程序员承担了编译器原本负责的安全保证。适用场景极其有限:实现高性能无锁数据结构、FFI 交互、需要零开销抽象的底层库。工程决策清单:
- 共享状态修改频率是否超过 每秒 50 万次?否则
Mutex足够。 - 是否需要无锁语义(lock-free)?是 → 使用
AtomicU64/AtomicPtr或相关 crate。 - 是否必须消除所有同步开销?确认分析表明同步原语是瓶颈后再考虑 unsafe。
常见的 unsafe 误用场景包括:企图用 unsafe 绕过借用检查实现 "全局可变状态",这实际上回到了 C 语言的数据竞争陷阱。Alice Ryhl 在讨论中明确反对这种做法 —— 即使在 unsafe 块中,也必须手动保证内存安全。
替代方案:按场景选型
| 场景 | 推荐方案 | 关键参数 |
|---|---|---|
| 跨线程共享 map | Arc<RwLock<HashMap>> 或 dashmap |
碎片数 = CPU 核心数 × 2 |
| 读多写少 | Arc<RwLock<T>> 或 arc-swap |
写操作频率 < 1% |
| 高频计数器 | Arc<AtomicU64> |
无锁,原子操作 |
| 线程局部缓存 | thread_local! |
每线程独立副本 |
| 最终一致性 map | evmap |
写入延迟容忍 < 100ms |
| 异步持锁等待 | tokio::sync::Mutex |
仅在必要时使用 |
dashmap 是一个值得关注的库,它将 map 拆分为多个碎片,每个碎片独立加锁,可实现接近无锁的并发吞吐量。但其 MutexGuard 实现了 Send,编译器不会在 await 时捕获死锁,使用时必须严格遵守 "仅在同步函数中锁定" 的规范。
工程决策框架
共享可变状态的实现选择应遵循以下决策树:首先评估是否跨线程 —— 单线程场景用 Rc<RefCell<T>>,多线程场景进入下一步。其次评估同步原语是否足够 —— 大多数场景下 Arc<Mutex<T>> 或 Arc<RwLock<T>> 已足够高效,只有在 ** profiling 明确显示锁竞争是瓶颈 ** 时才考虑更复杂的方案。最后评估是否需要无锁或极限性能 —— 此时方可使用 UnsafeCell 配合手动同步,但必须进行 haustive 的并发测试。
Alice Ryhl 的核心建议是:将锁封装在类型内部,而非暴露给调用方。这不仅简化了 API,更重要的是将同步策略的变更(Mutex 改为 RwLock,或改为无锁结构)的影响限制在单一模块内。
对于异步代码,永远不要在持有锁时 await,这应作为铁律。编译器无法捕获所有此类错误(使用 spawn_local 时),而死锁的调试成本极高。
资料来源:本文技术细节主要参考 Alice Ryhl 在个人博客发表的《Shared mutable state in Rust》一文,该文系统阐述了 Rust 中共享可变状态的最佳实践。