MutexGuard 跨越 await 的问题?
解读
在国内 Rust 后端/中间件面试中,“MutexGuard 能不能安全地跨 await” 几乎是并发与异步章节的必考题。
面试官真正想听的是:
- 你是否理解 Send + Sync 对异步任务调度的影响;
- 你是否知道 MutexGuard 的 Drop 顺序 与 await 点 可能产生死锁或 UB;
- 你是否能给出 零成本、无运行时开销 的改写方案。
一句话:编译器报错只是表象,背后是对 Send 语义与调度器迁移的深层考察。
知识点
- MutexGuard 不是 Send:标准库中
impl<T: ?Sized> !Send for MutexGuard<'_, T>,因为 guard 里存了指向内核对象的内指针,跨线程后地址失效。 - 异步任务可能被调度到不同线程:每次 await 后,Future 可能被另一个线程 poll,若 guard 仍在栈上,新线程拿不到锁却拿到指针,触发 data race。
- 编译器报错信息:
future created by async block is not Send;帮助信息里会列出MutexGuard<?>不满足 Send。 - 死锁风险:即使通过
unsafe impl Send强行过关,guard 在旧线程释放,锁的唤醒队列可能通知错线程,造成永久阻塞。 - 正确范式:“锁只保护临界区,不保护异步等待”;用
std::sync::Mutex时,临界区里禁止任何 await;需要异步粒度锁,改用tokio::sync::Mutex或 RAII + drop 前置 技巧。
答案
场景代码(错误示范)
use std::sync::Mutex;
use tokio::time::{sleep, Duration};
async fn bad() {
let data = Mutex::new(vec![1, 2, 3]);
let guard = data.lock().unwrap();
sleep(Duration::from_millis(10)).await; // 编译错误:future 不是 Send
println!("{:?}", *guard);
}
根本原因
guard 的类型是 MutexGuard<'_, Vec<i32>>,它不是 Send,而 .await 可能导致任务跨线程迁移,编译器拒绝生成 Send 的 Future。
修正方案一:缩小临界区(零成本,推荐)
async fn good1() {
let data = std::sync::Mutex::new(vec![1, 2, 3]);
{
let mut v = data.lock().unwrap();
v.push(4);
// 临界区结束立即 drop
}
sleep(Duration::from_millis(10)).await;
let v = data.lock().unwrap();
println!("{:?}", *v);
}
修正方案二:使用异步锁(tokio 生态)
use tokio::sync::Mutex; // 注意:这是 tokio::sync::Mutex
async fn good2() {
let data = Mutex::new(vec![1, 2, 3]);
let mut v = data.lock().await;
v.push(4);
sleep(Duration::from_millis(10)).await; // 允许跨 await
println!("{:?}", *v);
}
结论
std::sync::Mutex 的 guard 绝不允许跨越 await;要么提前 drop,要么换 tokio::sync::Mutex;后者内部使用队列 + Waker 机制,锁本身支持异步等待,因此 guard 是 Send 的。
拓展思考
- 性能对比:tokio::sync::Mutex 每次 lock 需要一次原子队列入队,比 std 版本慢 2~3 倍;在高并发、临界区只涉及内存操作的场景,优先用 std 锁 + 缩小作用域。
- 读写分离:如果数据读多写少,可改用
tokio::sync::RwLock或parking_lot::RwLock,读锁 guard 是 Send(parking_lot 版本),可跨 await 读快照。 - !Send 类型通用法则:除 MutexGuard 外,
RefCell的Ref/RefMut、Rc等都不是 Send;在 async 块里一旦持有它们,整个 Future 都会变成 !Send;写库时若需强制 Send,可用static_assertions::assert_impl_send!做 CI 检查。 - 嵌入式场景:no_std + alloc 时无 tokio,可用 临界区 + 中断屏蔽 或 spin::Mutex;此时禁止任何 await,因为根本没有调度器,问题退化为“中断与主循环共享数据”的经典场景。