Rust 用 Send/Sync 在编译期挡掉了数据竞争,但挡不住逻辑竞态:两个原子操作之间的窗口、错误的内存序、被优化掉的可见性,编译器都不会替你检查。Arc<Mutex<T>> 能解决 90% 的场景,剩下 10% 需要原子类型和无锁结构。
本文先讲清楚 Ordering 到底在约束什么,再对比锁的选型,最后给出一份可运行的无锁队列骨架和几个真实踩坑点。前提是已经理解线程与 Send/Sync,基础可参考 Rust 异步与并发编程
。
内存模型与 Ordering
Rust 的内存模型沿用 C++11:多核 CPU 和编译器都可以重排内存访问,Ordering 是给它们的约束。五个取值:
| Ordering | 约束 | 典型用途 |
|---|---|---|
Relaxed | 只保证原子性,不保证顺序 | 计数器、统计量 |
Acquire | 读之后的操作不能上移到它前面 | 加锁、读取「已发布」数据 |
Release | 写之前的操作不能下移到它后面 | 解锁、发布数据 |
AcqRel | 同时具备 Acquire + Release | fetch_add 类读改写 |
SeqCst | 全序,所有线程看到同一顺序 | 难以推理时保底 |
happens-before 才是关键
Relaxed 单独用几乎总是错的,除非只关心数值不关心关联数据。经典的「发布-订阅」模式:
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
static READY: AtomicBool = AtomicBool::new(false);
static DATA: AtomicUsize = AtomicUsize::new(0);
// 线程 A:先写数据,再置标志
DATA.store(42, Ordering::Relaxed);
READY.store(true, Ordering::Release); // Release:DATA 的写不会跑到它后面
// 线程 B:先看标志,再读数据
while !READY.load(Ordering::Acquire) {} // Acquire:DATA 的读不会跑到它前面
assert_eq!(DATA.load(Ordering::Relaxed), 42);
如果两边都用 Relaxed,B 可能看到 READY == true 却读到 DATA == 0。Release/Acquire 配对建立了 happens-before 边,才保证「看到标志即看到数据」。
经验法则:用最弱的、你能证明正确的 Ordering。证明不了就用
SeqCst——性能损失通常远小于调试一个只在 ARM 上复现的竞态。
各架构差异
x86 是强内存模型,Relaxed 与 SeqCst 的差别很小;ARM/PowerPC 是弱模型,错误的内存序会在那里暴露。在 x86 上测试通过不代表正确,CI 里跑 ARM 或加 --cfg 压力测试很有价值。
Mutex 与 RwLock 选型
std 与 parking_lot
| 特性 | std::sync::Mutex | parking_lot::Mutex |
|---|---|---|
| 大小 | 较大(含系统 futex 状态) | 1 字节 |
| 锁中毒 | 有(PoisonError) | 无 |
| 公平性 | 平台相关 | 默认较公平,可配置 |
| 性能 | 无竞争时更快 | 有竞争时更快,自旋更积极 |
| 调试 | 标准库集成 | 支持 deadlock_detection feature |
use parking_lot::Mutex; // 无 poison,unwrap() 可以直接省掉
let m = Mutex::new(0);
{
let mut g = m.lock(); // 直接返回 guard,不是 Result
*g += 1;
}
RwLock 什么时候值得
RwLock 读多写少才划算,因为它的开销比 Mutex 高:
use std::sync::RwLock;
let cache = RwLock::new(HashMap::new());
{
let r = cache.read().unwrap(); // 多读者可并发
r.get(&key)
}
{
let mut w = cache.write().unwrap(); // 写者独占
w.insert(key, value);
}
判断依据是读写比与临界区长度:读写比低于约 10:1,或临界区只有几个指令,Mutex 反而更快。std::sync::RwLock 还可能出现写者饥饿(读持续到来导致写永远拿不到锁),对写延迟敏感的服务应改用 parking_lot::RwLock 并开启公平模式。
减少临界区
比选锁更重要的是缩小临界区:
// ❌ 在锁内做 I/O 或重计算
let mut map = state.lock().unwrap();
let v = slow_compute(&key); // 锁被长期持有
map.insert(key, v);
// ✅ 先算好再进锁
let v = slow_compute(&key);
state.lock().unwrap().insert(key, v);
分片锁(sharded lock)是另一个常见手段:把 HashMap 拆成 16 个分片,各配一把锁,冲突概率降到 1/16。
Atomic 基础
标准库提供的原子类型覆盖整数、布尔和指针:AtomicUsize、AtomicI64、AtomicBool、AtomicPtr<T> 等。复合类型(如 AtomicU64 在 32 位平台上)可能由锁模拟,需查 cfg(target_has_atomic)。
常用操作
use std::sync::atomic::{AtomicUsize, AtomicU64, Ordering};
let counter = AtomicUsize::new(0);
counter.fetch_add(1, Ordering::Relaxed); // 返回旧值
counter.fetch_sub(1, Ordering::AcqRel);
counter.load(Ordering::Relaxed);
counter.store(10, Ordering::Release);
counter.swap(5, Ordering::AcqRel); // 交换并返回旧值
CAS 循环
compare_exchange 是构建无锁结构的基石,返回 Result<旧值, 当前值>:
use std::sync::atomic::{AtomicUsize, Ordering};
fn increment_if_below(max: usize, counter: &AtomicUsize) -> bool {
let mut cur = counter.load(Ordering::Relaxed);
loop {
if cur >= max {
return false;
}
match counter.compare_exchange_weak(
cur, cur + 1,
Ordering::AcqRel, // 成功时的序
Ordering::Relaxed, // 失败时的序
) {
Ok(_) => return true,
Err(actual) => cur = actual, // 被别人改过,用新值重试
}
}
}
循环里用 compare_exchange_weak(允许伪失败,在 LL/SC 架构上生成更好的代码),只有在循环外只用一次时才用 _strong。
无锁队列:SPSC 环形缓冲
单生产者单消费者(SPSC)是无锁结构里最容易写对的,核心是 head/tail 两个原子索引:
use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicUsize, Ordering};
pub struct SpscRing<T, const N: usize> {
buf: [UnsafeCell<Option<T>>; N],
head: AtomicUsize, // 消费者位置
tail: AtomicUsize, // 生产者位置
}
unsafe impl<T: Send, const N: usize> Sync for SpscRing<T, N> {}
impl<T, const N: usize> SpscRing<T, N> {
pub fn push(&self, item: T) -> Result<(), T> {
let tail = self.tail.load(Ordering::Relaxed);
let head = self.head.load(Ordering::Acquire); // 看到消费者的释放
if tail.wrapping_sub(head) == N {
return Err(item); // 满
}
let idx = tail % N;
unsafe { *self.buf[idx].get() = Some(item); }
// Release:保证写入对消费者可见
self.tail.store(tail.wrapping_add(1), Ordering::Release);
Ok(())
}
pub fn pop(&self) -> Option<T> {
let head = self.head.load(Ordering::Relaxed);
let tail = self.tail.load(Ordering::Acquire); // 看到生产者的发布
if head == tail {
return None; // 空
}
let idx = head % N;
let item = unsafe { (*self.buf[idx].get()).take() };
self.head.store(head.wrapping_add(1), Ordering::Release);
item
}
}
要点:索引用 wrapping_add 而非取模递增,避免溢出;生产者的 Release 与消费者的 Acquire 配对,保证数据先于索引可见;UnsafeCell 是「我能改但编译器别假设」的唯一合法容器。
生产环境优先用
crossbeam或rtrb等成熟 crate。自己写的无锁结构必须过 Loom 的模型检验,否则等于没测。
陷阱与验证
ABA 问题
CAS 只比较值,不比较「是否被改过又改回来」。指针栈的 pop 中,若 A 被弹出、释放、又分配回同一地址,CAS 会误判成功。解法是加版本号(AtomicU64 打包 指针+版本),或用 crossbeam_epoch 做内存回收。
伪共享(False Sharing)
两个无关的原子变量落在同一条缓存行(通常 64 字节),一个核改它会失效另一个核的缓存行:
use crossbeam_utils::CachePadded;
struct Counters {
a: CachePadded<AtomicUsize>, // 各自独占缓存行
b: CachePadded<AtomicUsize>,
}
高频更新的原子计数器一定要 CachePadded,否则多核扩展性会随核数下降。
内存序用错的隐蔽性
错误的内存序在 x86 上往往「看起来对」,在 ARM 上偶发失败。用 Loom 做穷举式交错验证:
#[test]
fn spsc_model() {
loom::model(|| {
let q = loom::sync::Arc::new(loom::sync::Mutex::new(0));
// loom 会枚举所有线程交错,找出违反不变量的一处
});
}
loom 把 std::sync::atomic 换成自己的实现,穷举所有可能的执行顺序,能抓出人工测试永远复现不了的竞态。无锁代码提交前跑一遍 loom 应成为硬性要求。
何时不该无锁
| 信号 | 建议 |
|---|---|
| 临界区包含多个变量、需要整体一致 | 用 Mutex,无锁很难维护不变量 |
| 竞争激烈(写多) | 无锁的 CAS 重试风暴比锁更慢 |
| 团队没有无锁经验 | 用 crossbeam/dashmap,别自研 |
| 需要阻塞等待、条件变量 | Condvar + Mutex 才是对的工具 |
| 只是偶尔读写的配置 | RwLock 或 arc-swap |
无锁的价值在于消除不可控的阻塞和优先级反转,而不是「一定更快」。测出来才算数,别凭直觉选。Rust 的并发安全边界(Send/Sync、unsafe 的正确使用)与 所有权与内存安全
一脉相承,而底层原子指令与缓存行为的硬件视角可延伸阅读 Rust 系统编程
。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。