前一阵子帮朋友排查一个基于 axum 部署的 Web 服务,压力测试时发现 CPU 占用还有余量,但 p99 延迟就是下不来,曲线像心电图一样一跳一跳。翻遍日志最后定位到一行不起眼的代码:异步处理链路里有人用std::sync::Mutex保护一个共享内存缓存。锁竞争一上来,工作线程直接睡在那里,而同一个线程上排队的其他请求只能干等。这件事让我重新把 Rust 异步并发基石里的异步锁(Mutex、RwLock)完整梳理了一遍:它们到底比标准锁多做了什么,为什么在 async 代码里几乎成了必需品,以及什么时候用它们反而是一种浪费。
1. 旧锁在异步世界的三大毛病:阻塞、跨await与调度冲突
在接触异步锁之前,先搞清楚它要解决的问题。Rust 标准库的std::sync::Mutex是一把线程阻塞锁,在同步代码里非常可靠;但到了 async 世界,它的几个特性反而变成致命问题。
1.1 阻塞线程等于让所有其他任务陪跑
异步运行时是协作式调度:一个工作线程轮流 poll 自己队列里的 future。如果在某个 future 的 poll 里调用了std::sync::Mutex::lock(),而锁恰好被另一个任务持有,这个工作线程会直接进入阻塞状态。线程一睡,睡在这个线程上的所有其他就绪任务全部遭殃——明明只是等一把锁,却把整条线程上的任务都拖住了。
单线程 tokio 运行时下更残酷:Task1 持锁,Task2 等着拿锁并阻塞了唯一的工作线程,结果 Task1 永远没机会运行,锁永远不会释放。这不是理论推演,是真实存在的挂死现场。多线程运行时虽然不至于必然死锁,但一个工作线程阻塞后,它身上挂着的成批 ready 任务延迟高涨,CPU 占用却上不去。异步编程模型有一条隐性契约:在 await 点让出线程,执行权交回运行时。阻塞锁直接撕毁这条契约。
1.2 锁守卫跨.await后不再是Send,编译期就拦住你
就算临界区短到从不会发生竞争,标准锁在 async 函数里也会遇到另一个坎:临界区里一旦出现.await,往往连编译都过不了。
use std::sync::Mutex; async fn process(shared: &Mutex<Vec<u8>>) { let mut guard = shared.lock().unwrap(); do_io().await; // 编译错误:future 不是 Send guard.push(1); }原因很简单:std::sync::MutexGuard不是Send,而多线程 tokio 运行时要求 future 是Send。这个限制乍看是编译期保护,实际上也间接说明标准锁根本不适合用来保护跨越 await 的共享状态。即便你绕过守卫约束,把锁占用的时间拉长到一次网路请求间隔,锁竞争时阻塞线程的问题只会更严重,其他任务还在裸奔等待。
1.3 异步锁的等待模型:把“等在门口”改成“先拿号去忙”
异步锁的核心建模完全不同:等待锁的任务不阻塞线程,而是注册一个等待者(Waker),返回Pending。运行时发现任务 Pending 后,转头去执行其他就绪任务;锁释放时,运行时根据 Waker 把等待任务重新放回就绪队列。
传统锁像在银行柜台前排队,拿不到号的人只能死站在窗口前;异步锁像叫号系统,拿号之后你可以离开座位去接电话、回邮件,轮到时系统通知你回来。问题从“线程级互斥”上升到“任务级互斥”:不再需要保留一个线程专门等待,而是把等待状态压缩进一个 Future。也正是这一点,让异步锁成为 Rust 异步并发基石里绕不开的组件。
2. 异步Mutex设计拆解:一个Future如何表达“锁可用”
理解异步锁最直接的方式,是拆开它看里面到底维护了什么。tokio::sync::Mutex是 tokio 生态里最常用的异步互斥锁,我以它为主来分析;在近几个版本里它实际上基于 tokio 的信号量原语实现,但概念结构更值得先关注。
2.1 核心结构:原子状态与等待队列
异步 Mutex 从概念上可以看作两个部分:一个原子状态,用来记录锁是否被持有;一个等待队列,用来记录哪些任务正在等待拿锁。
如果把状态压缩成一个原子整数,最低位可以表示“已锁定”,另一个标志位可以表示“是否有等待者”。等待队列里存的是被挂起 Future 对应的任务句柄(Waker),谁拿到锁,谁就有资格进入临界区。
获取锁的过程就是一次原子 CAS:如果状态显示没人持有,就把状态从 0 改成 1,立刻得到锁。如果 CAS 失败,说明锁正被占用,当前任务就把自己的 Waker 塞进等待队列,然后返回 Pending。释放锁的 drop 过程则反过来:把状态从 1 改成 0,并从等待队列里唤一个任务出来。
2.2 lock()的poll路径:快速尝试与挂起注册
lock().await本身返回一个 Future。这个 Future 被 poll 时,内部走两条路径:
- 快速路径:先做一次 CAS,状态从“未锁定”换成“已锁定”,成功就返回
Ready(guard),整个过程基本是几条原子指令。 - 慢速路径:CAS 失败,说明锁被占用。这时不会立刻挂起,而是先把当前任务的 Waker 挂入等待队列,然后必须再检查一次状态,防止在入队瞬间锁被释放导致错过唤醒。
- 二次检查仍然没有拿到锁,才返回
Pending,把执行权交回运行时。
很多自研锁最容易错在第二步。如果入队前锁刚好释放,解锁者会去唤醒当前队列里的“队首等待者”,而你这个任务还没入队,自然不在唤醒名单里——它会永远 Pending,这种 bug 在压力测试里表现为偶发挂死,极难排查。正确实现都会走“检查-入队-再检查”的顺序,或者用等价的原子逻辑避免对手条件。
2.3 解锁时如何精确唤醒等待者
锁守卫 drop 时,不是简单把状态改回 0。还需要从等待队列中弹出一个等待者,调用它的 Waker,把对应任务加入运行时的就绪队列。之后等待者再次被 poll,走一遍快速路径就能拿到锁。
为什么解锁现场不能“顺便把等待者直接跑完”?因为 tokio 是协作式调度,唤醒只是把任务投递到就绪队列,不会立即抢占当前线程。这样做能避免在解锁位置递归执行一堆竞争者代码,也防止优先级反转。
2.4 为什么不建议自己造异步锁的轮子
把上面逻辑实现得正确,远比听起来难。等待队列如果用std::sync::Mutex保护,二次检查的顺序很容易写错;用侵入式链表避免每次 lock 都堆内存分配,链表节点生命周期又要和 Future 绑定;原子操作内存序选错,可能造成拿到锁却看不到临界区最新数据的可见性 bug。再加上任务取消、多线程同时唤醒这些边界条件,复杂度完全失控。
我自己有过一次造轮子失败经历:自研的异步锁在压测下有 1% 概率挂死,最后换成 tokio 实现问题立刻消失。tokio 实际把 Mutex 建立在它的 Semaphore 原语之上——锁的占用就是一个 permit“放”在信号量里,等待队列由信号量统一管理。理解这层关系,就明白为什么 Mutex 的 acquire 语义、取消安全和 Semaphore 有那么多共通点。
3. 异步RwLock的读写博弈:宽容读者,但别饿死写者
tokio::sync::RwLock是异步读写锁,允许多个读者并发持有,但写者独占。它的状态设计和 Mutex 类似,只是同时要表达“读计数”和“写标志”。
3.1 同一份状态里怎么同时表达读和写
从概念上,RwLock 的内部状态可以编码成一个整数:低段位记录当前持有读锁的读者数量,单独一位记录写锁是否被占用。读锁获取的前提是“没有写锁持有者”;写锁获取的前提是“读计数为 0 且写标志为 0”。
每次读锁获取,都要对状态执行一个“读计数加一”的原子操作;每次读锁释放,执行“读计数减一”。写锁的获取则要求整个状态处于完全空闲状态。在 tokio 实现里,等待队列会区分读者等待者和写者等待者,但核心逻辑不变。
3.2 写优先策略:宁可让读者等一下,也不能让写者饿死
tokio RwLock 的默认行为是写优先(write-preferring):一旦有一个写者在等待,后面来的读者会被排队,不再允许直接插队。这个设计非常关键。
想想读多写少的经典场景:配置热更新,读请求每秒数万,写请求每隔几分钟来一次。如果允许读者不断插队,写者可能永远等不到一个空窗期,这就是写者饿死。配置本来要更新,结果旧配置一直生效,直接毁掉整个功能。写优先策略牺牲了一点读者延迟,换来了写者的有界等待。在我的实际压测里,有写者等待时新读者最多排在队列末端,延迟增加几十微秒,整体对系统几乎没有影响。
3.3 读锁守卫跨await,与标准RwLock的差异
标准库std::sync::RwLockReadGuard的 Send 约束有时恰好允许它跨 await 编译,但等待锁的过程依然是阻塞的。一旦发生竞争,等待线程仍然会睡在锁的门口,把线程上的其他任务全部拖慢。所以 tokio RwLock 的价值从来不是“守卫是 Send”,而是“等待锁的过程本身是异步的”。
使用异步 RwLock 时要注意一个隐藏约束:锁内部的数据必须满足Send + Sync才能让守卫跨 await 安全传递。如果读锁保护的是一棵带有Cell或裸指针的树结构,守卫照样送不出去。我处理这类问题时,会尽量把临界区里的数据设计成不借助内部可变性就能安全共享的结构。
3.4 什么时候用RwLock而不是Mutex
简单规则:读多写少,且临界区不是几行内存拷贝的那种极致短操作。如果每次读操作只是拷贝一个整数,RwLock 的额外状态维护根本不划算,一个原子量或普通 Mutex 反而更快。如果临界区包含较大的数据遍历、序列化计算,或者可能触发网络等待,异步 RwLock 才明显占优。我曾在一个路由表系统里把tokio::sync::Mutex换成tokio::sync::RwLock,读并发上去两个量级,写操作稳定如初。
4. 实战选型清单:什么时候用异步锁,什么时候该用别的
异步锁不是银弹。下面的判断路径是我在项目里一直用的,能帮你快速决定要不要上异步锁。
4.1 一条判断路径:临界区里有没有.await
先问一句话:临界区里有没有.await?
- 没有
.await的短临界区:优先考虑std::sync::Mutex、原子类型。tokio Mutex 也可用,但通常更慢,没必要。 - 包含
.await的临界区:必须用异步锁,否则要么编译失败,要么运行期出现线程阻塞。 - 读多写少、等待时间可能较长:用
tokio::sync::RwLock。 - 只是计数器:
AtomicU64/AtomicUsize,彻底无锁。 - 高频更新的共享哈希表:先评估
std::sync::Mutex,并发再高就考虑 DashMap 或分片。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 临界区内含 .await | tokio::sync::Mutex / RwLock | 等待挂起而非阻塞 |
| 临界区纯同步且极短 | std::sync::Mutex / RwLock | 开销更低 |
| 读多写少且等待可长 | tokio::sync::RwLock | 并发读 + 写优先 |
| 高频计数 | 原子类型 | 无锁 |
| 大 HashMap 频繁更新 | DashMap / 分片 | 热点分散 |
4.2 适合异步锁的典型场景
第一个典型场景是连接池。从池子里拿一个数据库连接,如果池空了,任务需要等待其他任务归还连接。用tokio::sync::Mutex<Vec<PooledConn>>包住连接列表,等待过程让出线程,整个系统的吞吐不会因为“等连接”而塌掉。
第二个典型场景是跨 await 的长事务。比如一个上传任务,需要分多个步骤改一个共享状态机,中间夹杂网络 IO。用异步 Mutex 保护整个状态机,锁可以安全跨越多次 await,线程不会被锁阻塞。
第三个是配置热更新。进程内的配置表,读多写少,更新频率低。用tokio::sync::RwLock包住 Arc 配置快照,读者随时拿最新版本,写者偶尔进来替换,写优先保证更新最终生效。
4.3 那些被误用为“异步锁”的场景
很多情况下,根本不需要上锁。共享状态如果能把所有权转移,就优先转移:与其用锁保护一个 HashMap,不如把更新请求发给一个持有该 HashMap 的 actor 任务,消息传递本身天然隔离了并发访问。
只需要最新快照的场景,可以用Arc::load()拿一次快照,或者用arc-swap做无锁更新。锁可以完全消失。我见过不少项目把tokio::sync::Mutex当默认配置,锁竞争最后拖垮性能。锁只是并发控制的兜底手段,不是第一选择;先想消息传递和原子类型。
4.4 一个我常用的实操检查项
代码审查时按这个顺序过一遍:
- 能否彻底去掉共享状态?用消息传递 / Actor。
- 能否用原子类型?
- 是否只需要一个不可变快照?用 Arc / arc-swap。
- 临界区里是否真的需要 .await?
- 读写比例到底是多少?
这五个问题问完,大多数异步锁其实是多余的。剩下的才值得认真选型。
5. 异步锁使用中的边角和我的排障经验
选对了锁,只是开始。异步锁在真实项目里还有一批边角问题,每一个我都踩过。
5.1 取消安全:select! 里锁的获取被丢弃会不会丢锁
好消息是,tokio 的Mutex::lock()是取消安全的。如果持有lock().await的 Future 被 drop,比如select!选了别的分支,这个 Future 会从等待队列里安全移除,不会把锁状态搞坏。RwLock::read()和write()也类似。
你可以在select!里放心写:
tokio::select! { _ = mutex.lock() => { /* 拿到锁的分支 */ } _ = tokio::time::sleep(Duration::from_millis(100)) => { /* 超时分支 */ } }但取消安全只保证“等待锁”的过程不破坏状态。一旦你已经拿到锁,业务中被取消,守卫会在作用域结束时正常 drop,锁会释放;业务数据的一致性还得自己处理。
5.2 锁顺序与潜在死锁
异步锁解决了线程阻塞死锁,但解决不了任务间循环等待死锁。任务 A 持锁 1 等锁 2,任务 B 持锁 2 等锁 1,两个任务都挂起,锁永远不会释放。这种问题在异步环境比同步环境更难查,因为没有线程栈“卡死”的直接证据。
我的习惯是:多个锁必须按固定顺序获取,比如总是先拿缓存锁再拿计数锁;实在无法保证顺序,就给锁等待加超时:
let guard = tokio::time::timeout( Duration::from_millis(200), mutex.lock(), ).await;更优的解是减少多锁场景,把多个共享状态合并到一个结构体里,用一把锁保护,彻底消除锁顺序问题。
5.3 原子语义的Acquire/Release不能省
锁的正确性最终依赖内存序。获取锁的原子操作必须用 Acquire(或等价顺序),释放用 Release,保证临界区内的写操作对后续拿到锁的读者可见。如果全用 Relaxed,高并发下可能出现“拿到锁却看到旧数据”的诡异问题。
标准库和 tokio 已经把这些处理好了,使用者无需操心;但如果你想自己实现一把 Mini Mutex,这就是最容易埋雷的地方。配合侵入式队列、二次检查和取消删除,复杂度会迅速失控。
5.4 用 tokio-console 观察锁竞争
实际排障时,我推荐用 tokio-console 采集任务状态,重点看有多少任务停在“等待锁”的 Pending 状态。如果大量任务挂在同一个 Mutex / RwLock 的 Future 上,说明临界区长度或争用已经失控。
还有一个快速手测技巧:把异步锁临时换回std::sync::Mutex做 A/B 对比。如果换成同步锁性能反而变好,说明临界区根本没有长时间的等待,也没有跨 await,同步锁更合适;如果换成同步锁后延迟开始剧烈抖动、线程利用率飙升,说明异步锁确实是刚需。
最后分享一个实用小技巧。遇到和锁相关的延迟问题,我不会只看平均延迟,而是盯 p99 的抖动曲线。异步锁竞争的典型信号是延迟呈间歇性尖峰,而不是固定高延迟——因为任务被唤醒后还要和其他就绪任务抢占调度时间。用 tokio-console 看到大量任务挂在锁上时,我一般直接退一步重构临界区:拆分状态、引入分片,或干脆把共享状态移到 actor 任务里。异步锁是好东西,但它应该是并发设计里的最后一道防线,而不是解决一切问题的第一选择。