阶段三 · Tokio 核心

跨 await 的约束:Send 与 'static

一句话总结

多线程运行时会把任务在线程间搬运,所以 tokio::spawn 的 Future 必须是 Send + 'static.
最常见的报错是:跨一个 .await 持有了非 Send 的值(如 Rcstd::MutexGuard),编译器据此判定整个任务非 Send.理解了状态机,这个报错就不再神秘.

为什么偏偏在异步里撞上 Send

Send,Sync,'static 在多线程章节已经见过:Send 表示一个值可以安全地移动到另一个线程.线程编程里它出现得理所当然--确实把数据交给了新线程.

异步里它的出现一度让人意外:"没开线程,怎么也要 Send?"答案藏在上一篇的伏笔里--Tokio 默认的多线程调度器,会用工作窃取把任务从一个线程挪到另一个线程执行.任务在线程间移动,自然要求任务本身可跨线程移动,即 Send.

线程 1: 任务 A 跑到 .await 让出 ──┐
│ 工作窃取:空闲的线程 2 接走 A
线程 2: ◀───────────────────────┘
A 在这里被恢复继续跑
→ A 整体必须能从线程1安全搬到线程2 = Send

spawn 的完整约束

tokio::spawn 的签名(简化),约束写得明明白白:

pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
约束 含义 为什么需要
Send 任务可被移动到别的线程 多线程调度器会跨线程搬运任务
'static 任务不借用任何短命的外部数据 任务存活时长由运行时决定,可能比调用栈活得久

常见误解.'static 在这里的意思是"不含任何非 'static 的借用"--要么是拥有所有权的值,要么借用的是 'static 数据.

因为 spawn 出去的任务可能在当前函数返回后仍在运行,它不能持有指向当前栈帧的引用.所以 spawn 的闭包几乎总要 move,把数据所有权搬进任务.

核心规则:跨 await 的值决定任务是否 Send

这是本篇最该记住的一句话.回顾第 04 篇:跨 .await 存活的局部变量,会被编译器存进 Future 状态机结构体.因此:

任务这个状态机是否 Send,取决于它"存档"里的所有字段是否都 Send.
只要有一个跨 .await 存活的变量不是 Send,整个状态机就不是 Send,于是不能 spawn.
就像一个集装箱能否上船,取决于箱内每一件货物是否都允许海运--一件违禁品,整箱都走不了.

关键限定词是"跨 await 存活".如果一个非 Send 的值在 .await 之前就用完,被丢弃,它不会进入状态机,也就不影响任务的 Send 性.

典型报错一:跨 await 持有 Rc

Rc(非线程安全的引用计数)是经典的非 Send 类型.下面的代码无法 spawn:

use std::rc::Rc;

tokio::spawn(async {
let data = Rc::new(5);
some_async_work().await; // data 跨过了这个 await
println!("{data}"); // 之后还用 → data 必须存进状态机
});
// 编译错误:`Rc<i32>` cannot be sent between threads safely

因为 data.await 两侧都被使用,它被存进状态机,而 Rc 非 Send,任务整体非 Send.两种修法:

// 修法一:换成线程安全的 Arc
let data = std::sync::Arc::new(5);

// 修法二:让非 Send 的值不跨越 await(用完即弃)
tokio::spawn(async {
{
let data = Rc::new(5);
println!("{data}"); // 在 await 之前用完,作用域结束即丢弃
}
some_async_work().await; // 此时已无 Rc 存活
});

典型报错二:跨 await 持有 MutexGuard

更隐蔽,也更高频的一个:持有标准库 std::sync::Mutex 的锁守卫(MutexGuard)跨越 .await.

use std::sync::Mutex;

async fn bad(data: &Mutex<i32>) {
let mut guard = data.lock().unwrap();
*guard += 1;
some_async_work().await; // guard 还活着,跨过了 await
// guard 在这里才释放
}
// 编译错误:MutexGuard 非 Send,无法跨线程

这个报错其实在保护我们:持有锁的同时 .await 让出,锁会一直被占着,直到任务被重新调度--期间其他任务全部卡在抢这把锁上,极易造成性能塌方甚至死锁.编译器用 Send 约束逼我们避开它.正确做法是在 await 前释放锁:

async fn good(data: &Mutex<i32>) {
{
let mut guard = data.lock().unwrap();
*guard += 1;
} // 作用域结束,锁在此释放
some_async_work().await; // 此时不再持锁
}

确有此类需求(例如要在持锁期间做异步操作).这时应改用 Tokio 提供的异步锁 tokio::sync::Mutex,它的守卫是 Send 的,设计上允许跨 await 持有.

但它更慢,不能无脑替换 std Mutex.何时用哪个,正是下一篇的主题.

读懂这类报错的套路

遇到 "future cannot be sent between threads safely" 时,按这个套路排查:

报错:future is not Send

1. 看编译器提示的 "the following ... is not Send" -- 它会指出具体类型

2. 在代码里定位这个值,确认它是否"跨越了某个 .await"

3. 二选一修复:
a. 让它在 .await 前结束生命周期(加内层作用域 { }),或
b. 换成 Send 的等价物(Rc→Arc,std::Mutex→tokio::Mutex)

和 Go 的对比

Go 里把 sync.Mutex 锁着跨过一次"阻塞调用"(如 channel 接收),编译器毫不阻拦--出了死锁要靠运行时排查,靠经验.

Rust 把这类并发危险提前到编译期拦下.报错初看烦人,实则替我们挡掉了一类最难调试的线上事故.

快速回顾

  • 根源:多线程调度器跨线程搬运任务,故 spawn 要求 Future 满足 Send + 'static.
  • 'static 的真意:不持有非 'static 借用,通常靠 move 把所有权搬进任务,而非"活到程序结束".
  • 核心规则:任务是否 Send,取决于跨 await 存活的所有变量是否都 Send;await 前用完的值不影响.
  • 两大典型:跨 await 持有 Rc,持有 std::MutexGuard → 任务非 Send.
  • 修复:用内层作用域让其在 await 前释放,或换 Send 等价物(Arc,tokio::Mutex).
  • 价值:这是编译器在编译期替我们拦截"持锁过 await"等高危并发 bug.

动手练习

  1. 复现报错:在 tokio::spawn 的任务里跨 .await 持有一个 Rc,复现报错并读懂编译器指认的类型.
  2. 两种修复:用"内层作用域"和"换 Arc"两种方式分别修复上题.
  3. MutexGuard 报错:写一个跨 .await 持有 std::sync::MutexGuard 的函数,观察报错,再改成 await 前释放锁.
  4. 解释 'static:解释 'static 在 spawn 约束里的真实含义,并说明为什么 spawn 闭包常需 move.
  5. 一句话概括:为什么"任务的 Send 性"等于"其状态机所有字段的 Send 性"?