阶段三 · Tokio 核心

异步同步原语

一句话总结

异步里有两套锁:std::sync::Mutex(同步锁)和 tokio::sync::Mutex(异步锁).
默认仍用 std 同步锁--它更快;只有当必须跨 .await 持有锁时,才换 Tokio 异步锁.选错的代价是性能下降甚至死锁.

从上一篇的遗留问题接上

第 07 篇我们看到:跨 .await 持有 std::sync::MutexGuard 会因非 Send 而编译失败.当时给的首选解法是"在 await 前释放锁".但有时业务上确实绕不开--需要在持锁期间执行异步操作.这一篇就解决"那到底该用哪种锁".

两种 Mutex 的本质区别

两者都保护共享数据,区别在抢不到锁时怎么办:

std::sync::Mutex tokio::sync::Mutex
抢不到锁时 阻塞当前线程 让出当前任务(其他任务继续)
加锁方法 lock()(同步) lock().await(异步)
守卫能否跨 await 不能(非 Send) 能(Send)
开销 较高(涉及任务调度)

同步锁抢不到时,线程"原地干等"--但在异步世界,一个线程要养着成百上千个任务,让它干等等于全员陪绑.
异步锁抢不到时,只让"当前这个任务"靠边等,线程立刻去服务别人.
这正是同步与异步在第 02 篇就确立的那对本质差异,在锁上的再现.

反直觉的默认:优先用 std 同步锁

很多人以为"写异步代码就该全用异步锁",这是个误区.Tokio 官方文档明确建议:多数情况下应当用 std::sync::Mutex.理由是:

  • 临界区(持锁期间的代码)通常极短--改个计数,推个元素,纳秒级完成.
  • 这么短的持锁时间,锁竞争概率低,同步锁阻塞线程的代价可以忽略.
  • 同步锁没有任务调度开销,比异步锁快得多.
use std::sync::{Arc, Mutex};

let counter = Arc::new(Mutex::new(0));

let c = counter.clone();
tokio::spawn(async move {
{
let mut n = c.lock().unwrap();
*n += 1;
} // 锁在此释放,后面才有 await
do_something().await;
});

判断准则

只在临界区内做纯内存操作,不跨 .await → 用 std::sync::Mutex.
这覆盖了实践中的绝大多数共享状态场景.

何时才该用 tokio::sync::Mutex

异步锁的唯一正当理由:必须在持锁期间执行 .await.例如,持有锁去访问一个受保护的异步资源:

use tokio::sync::Mutex;
use std::sync::Arc;

let conn = Arc::new(Mutex::new(connect_db().await));

let c = conn.clone();
tokio::spawn(async move {
let mut guard = c.lock().await; // 异步加锁
guard.execute("UPDATE ...").await; // 持锁期间做异步操作
}); // guard 在此释放

这里持锁跨越了 .await,用 std 锁会编译失败;tokio::sync::Mutex 的守卫是 Send 的,正是为此设计.

即便用异步锁,持锁过 await 也要克制

异步锁让我们""跨 await 持锁,但不代表""长时间持有.持锁期间所有想加锁的任务都在排队,临界区里的 .await 越久(尤其别在里面做慢 IO),并发度塌得越狠.

能缩短就缩短,能不跨就不跨.

RwLock 与其他原语

同样的"同步/异步两套"格局也适用于读写锁,以及一些异步特有的协调原语:

原语 用途 选择要点
RwLock 读多写少的共享数据 同 Mutex:不跨 await 用 std,跨则用 tokio
tokio::sync::Semaphore 限制并发数(如最多 10 个并发请求) 异步专用,无同步对应
tokio::sync::Notify 任务间的"等待/通知"信号 异步专用

Semaphore:一个会反复用到的工具

限流场景极常见--比如"对下游服务最多保持 10 个并发请求".Semaphore 发放固定数量的许可(permit),任务获取许可才能继续,用完归还.

这类"控制并发度"的需求,Go 里常用带缓冲 channel 模拟,Tokio 直接提供了语义更清晰的 Semaphore.

选择决策图

需要保护共享数据?


持锁期间需要 .await 吗?
├── 否(只改内存,临界区极短)──▶ std::sync::Mutex / RwLock(默认,更快)

└── 是(持锁期间要做异步操作)──▶ tokio::sync::Mutex / RwLock
(并尽量缩短持锁时间)

需要限制并发数量? ──▶ tokio::sync::Semaphore
需要任务间收发信号? ──▶ tokio::sync::Notify

快速回顾

  • 两套锁的差异:抢不到锁时,std 锁阻塞线程,tokio 锁让出任务;tokio 守卫 Send,可跨 await,但更慢.
  • 默认用 std:临界区只做短暂内存操作,不跨 await 时,std::sync::Mutex 是更快的正确选择.
  • 用 tokio 锁的唯一理由:必须在持锁期间 .await.
  • 持锁过 await 要克制:即便用异步锁,也尽量缩短临界区,避免并发塌方.
  • 其他原语:RwLock 同样分两套;Semaphore 限并发数,Notify 收发信号,均为异步专用.

动手练习

  1. std Mutex 共享计数:用 Arc<std::sync::Mutex<i32>> 让 100 个 spawn 任务各自 +1,最后打印总和,确认为 100.
  2. 复现报错:把上题的锁竞争部分故意写成跨 .await 持有,观察编译错误,并说明编译器在保护什么.
  3. 异步锁场景:构造一个"持锁期间必须 await"的场景,改用 tokio::sync::Mutex 让它编译通过.
  4. Semaphore 限流:用 tokio::sync::Semaphore 限制"同时最多 3 个任务进入某段代码",验证效果.
  5. 一句话总结:总结选锁的决策流程.