把 Tokio 理解成「async/await 语法糖」会在生产里立刻失效。async fn 不是线程,是一台可被暂停的状态机;runtime 也不是线程池那么简单,是 work-stealing 调度器 + IO 驱动 + 计时器 + 阻塞池 的合体。你在 worker 上跑 std::fs::read 或 std::thread::sleep,等于把别人的 epoll 完成通知也一起停掉。语法能跑通,吞吐和尾延迟会先告诉你错了。
这篇文章只讲多线程 runtime 这一层:任务怎么排队、怎么被偷、IO 怎么把 Waker 挂上、取消在哪一个 await 点生效、阻塞代码该去哪。读完你应该能独立回答三个问题:这段同步代码该不该 spawn_blocking、JoinHandle::abort 为什么杀不死正在 sleep 的代码、无界 channel 为什么会在流量尖峰时先把内存打爆而不是把 CPU 打满。
1. 先画边界:任务不是线程,await 不是系统调用
线程是操作系统的调度单位,有栈、有时间片、可被抢占。Tokio 任务是堆上的状态机,只在 .await 点主动让出。两套调度叠在一起:OS 调度 worker 线程,runtime 调度任务到 worker。
推论立刻变得具体:
- 没有
.await的死循环会独占一个 worker。协作式调度没有时间片来救你。 .await不等于进入内核。已经 Ready 的 future 会在当前 worker 上一直 poll,直到 Pending 或完成。spawn不是「开一条线程」。它是把状态机装箱、挂到调度器上。创建成本是一次分配加几次原子,不是clone一个 OS 栈。
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let h = tokio::spawn(async {
1 + 1 // 这个任务可能从未让出,在某个 worker 上一次 poll 完
});
let _ = h.await;
}
current_thread runtime 只有一条线程跑所有任务,适合嵌入、测试、和 !Send 的 spawn_local。生产网关、RPC、代理默认是 multi_thread。选错的症状:单线程上一个阻塞 syscall 让全部 HTTP 停摆;或者 spawn_local 在多线程 runtime 上根本没有 LocalSet。
2. Worker、本地队列、注入队列、偷窃
多线程 runtime 默认:每个 worker 一条 OS 线程、一个本地队列。spawn 出的新任务通常进当前 worker 的本地队列(LIFO:刚 spawn 的孩子很可能马上被同一条线程跑,缓存友好)。另外有全局注入队列,处理从非 worker 线程 spawn 来的任务,以及窃取时的公共来源。
当一个 worker 的本地队列空了,它去别的 worker 的队列 偷一半(FIFO 从队列另一端)。这就是 work-stealing:让空闲 CPU 去填满,而不是让任务钉死在出生的那条线程上。本地队列容量是有限的(实现里是固定槽位,满了会溢出到注入队列)。突发 spawn 一万个子任务,多出来的不会「无限贴在本核上」,会进入可被立刻偷走的公共路径。这是好事(别的核能帮忙),也是坏事(缓存局部性被主动丢掉)。扇出之前用信号量把 in-flight 钉在几百而不是几万,既是背压,也是在保护队列形态。
几条生产推论:
- 不要假设任务亲和性。 这次 poll 在 worker 2,下次可能在 worker 5。线程局部的
!Send缓存、thread_local!连接、Rc,都不能进spawn。 - 不要假设公平。 LIFO 本地队列对「spawn 一棵很深的任务树」很友好,对「先来的请求要先服务」不保证。尾延迟敏感的路径要自己做队列和并发上限。
- 偷的是任务,不是线程。 一个卡住的任务不会被偷走——它正占着那个 worker 跑同步代码。能被偷的是还在别人队列里、还没 poll 的那些。
tokio::task::yield_now().await 把当前任务重新入队,让出这一次 poll。CPU 密集但可切分的循环(校验和、JSON 大数组)应当隔一段 yield,否则协作式调度退化成「谁先跑谁占满」。runtime 还有合作预算(coop budget):一次 poll 里连续 Ready 太多次会被强制让出,避免饿死旁路任务。这不是你可以依赖去写热循环的借口,它是安全带。预算耗尽时的让出对调用方不可见——你的 Stream 会突然 Pending 一次,Waker 马上再叫醒你。自己实现 Future 时如果假设「只要底层 Ready 我就一直 Ready」,会和合作预算打架,表现为莫名其妙的调度间隙。正确态度:把每一次 poll 当成可能是最后一次连续执行,状态必须落在可恢复的边界上。
3. 一次 poll 到底发生了什么
Future 的接口是 poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<T>。Context 里是 Waker。任务被 poll 时:
- 调度器从队列取出任务,在当前 worker 上调用
poll。 - 若返回
Ready,任务结束,JoinHandle一侧被唤醒。 - 若返回
Pending,任务必须保证:以后有人会调用waker.wake()。否则它会永远睡死。这就是「忘记唤醒」的死锁。
IO 资源(TcpStream、AsyncFd)把 Waker 注册到 mio/epoll/kqueue/iocp。内核说这个 fd 可读了,驱动找到对应 Waker,把任务重新注入调度器。计时器同理,只是源是时间轮而不是 socket。
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
async fn echo(mut s: TcpStream) -> std::io::Result<()> {
let mut buf = vec![0u8; 4096];
loop {
let n = s.read(&mut buf).await?; // Pending:Waker 挂在 fd 上
if n == 0 {
return Ok(());
}
s.write_all(&buf[..n]).await?;
}
}
read 未就绪时,这个 echo 任务不占 CPU,但占着任务槽和那块 buf。一万个空闲连接就是一万个状态机加一万块缓冲——这是内存账单,不是调度账单。缓冲策略见零拷贝那篇;这里只提醒:挂起的任务不是免费的。
自己写 Future 时,最常见的两种 bug:返回 Pending 却没 clone Waker;以及就绪了却没 wake,等下一次碰巧被 poll。tokio::sync 的 channel、Mutex、Notify 内部都在维护等待者列表。你用 poll_fn 手写协议状态机时,要把「谁会唤醒我」写进注释,最好写进类型。
任务在堆上的形状大致是:一个引用计数的 Header(状态位、Waker、vtable)加上 Future 本体。spawn 一次分配把它们放在一起。Header 上的状态机负责「正在跑 / 已唤醒 / 已完成 / 已取消」。JoinHandle 是另一份引用;drop handle 且任务还在跑,任务变成 detached,panic 只能进 runtime 的全局 hook。这就是为什么「我 spawn 了但没 await」时,错误会从你以为的调用栈消失。生产里要么把 handle 收进 JoinSet,要么明确接受 detached 并装 tokio::spawn 的 panic 监控。
4. IO 驱动、计时器、阻塞池是三条不同的路
把 runtime 拆开,生产问题几乎都能归到其中一条路走错:
IO 驱动。 一条(或按配置)事件循环,等 epoll_wait。就绪的 fd 对应的任务被 wake。所有 AsyncRead/AsyncWrite 最终依赖它。在异步代码里调用 std::net::TcpStream::read,绕过了驱动,既阻塞 worker,又让 Tokio 的 TcpStream 和标准库的 fd 语义搅在一起。
计时器。 sleep、timeout、interval 走时间轮。大量短定时器是一笔 CPU;超时必须包在真正的 IO future 上才有取消意义。timeout(dur, std::future::ready(x)) 毫无意义。timeout(dur, blocking_call()) 更糟:blocking 听不到超时。
阻塞池。 spawn_blocking 把闭包送到专门的线程池(默认有上限,可配置)。适合:一次 CPU 很重但你不想自己切 yield 的活、同步文件系统、会阻塞的第三方 FFI。不适合:每个请求都 spawn_blocking 去跑 50μs 的活——队列和线程切换比活本身贵。
async fn read_dump(path: &str) -> std::io::Result<Vec<u8>> {
let path = path.to_owned();
tokio::task::spawn_blocking(move || std::fs::read(path)).await?
}
async fn hash_cpu(bytes: bytes::Bytes) -> u64 {
tokio::task::spawn_blocking(move || {
// 假设这是毫秒级以上的 CPU
bytes.iter().fold(0u64, |a, b| a.wrapping_mul(16777619) ^ *b as u64)
})
.await
.expect("blocking pool closed")
}
文件系统:tokio::fs 内部就是 spawn_blocking 包 std::fs。小文件、启动时读配置,可以。热路径上每请求读盘,先问是不是该走缓冲、mmap、或根本不该在这个进程里读。
block_in_place 更危险:它在当前 worker 上跑同步代码,同时把 runtime 的其它任务「挪」到别的 worker,避免这条线程停死 IO。代价是重新平衡、可能死锁(持着 runtime 锁再 block_in_place)、在 current_thread 上行为不同。生产默认用 spawn_blocking,把 block_in_place 留给「不得不在这段栈上跑同步回调」的嵌套场景,并且写清楚为什么。
Runtime 的构造本身也是产品参数,不是样板:
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.max_blocking_threads(32)
.thread_name("svc-worker")
.enable_io()
.enable_time()
.build()
.unwrap();
忘记 enable_io 或 enable_time,TcpStream / sleep 会在运行时 panic,而不是编译期拒绝。测试里用 Builder::new_current_thread().enable_time() 再 Runtime::block_on,不要在单元测试里拉起和线上一样的 16 worker——那会把测试变成调度竞态抽奖。Handle::current() 依赖「当前线程已 enter 某个 runtime」。在普通 std 线程里调 tokio::spawn 会 panic。从阻塞池、从 thread::spawn 的回调里再进 Tokio,要把 Handle 显式 clone 过去,用 handle.spawn / handle.enter()。不要靠「碰巧在 worker 上」这种隐式上下文。
5. 取消:下一个 await 点,不是 SIGKILL
Rust 的取消默认是 drop future。tokio::select! 某个分支完成,其它分支的 future 被 drop;timeout 到期,内部 future 被 drop;JoinHandle::abort 在任务上设取消标志,下一次 poll 时注入 panic(JoinError::is_cancelled)。
听不到取消的代码:
std::thread::sleep、同步read、重 CPU 循环、不 poll 的 FFI。abort 只能等到它自己回到 await。- 已经
spawn出去的子任务。父 future 被 drop 不会级联取消孩子,除非你把JoinHandle留下来 abort,或显式传CancellationToken。 spawn_blocking里的闭包。阻塞池没有 await 点。需要取消必须自己在闭包里看原子标志或CancellationToken。
use tokio_util::sync::CancellationToken;
async fn worker(token: CancellationToken, mut rx: tokio::sync::mpsc::Receiver<u32>) {
loop {
tokio::select! {
_ = token.cancelled() => return,
msg = rx.recv() => {
let Some(msg) = msg else { return };
if let Err(_) = do_one(msg).await {
return;
}
}
}
}
}
select! 默认对每个分支的 future 在每次循环 重新构造。计时器写成 sleep(d) 而不是先 let sleep = sleep(d); tokio::pin!(sleep);,会导致每次别的分支就绪都把计时器重置。这是生产里超时「怎么也等不到」或「永远立即超时」的常见原因。
Drop 时的清理也要可取消意识:AsyncDrop 还不是稳定现实,析构里不能 await。连接要在 drop 里做的是同步或 spawn 一个独立的关闭任务,并且那个关闭任务自己有超时。不要在 Drop 里 Handle::current().block_on(...)——死锁和 panic 都常见。
超时和取消要配在会让出的 future 上。tokio::time::timeout(d, spawn_blocking(|| std::fs::read(p)).await?) 看起来设了超时,其实 spawn_blocking 的闭包听不到;timeout 只会让你在 await JoinHandle 时放弃等待,闭包继续在阻塞池里跑完,还占着池里的槽。要可取消的磁盘或 FFI,闭包内部自己看 AtomicBool 或用带超时的 syscall。否则超时只是「调用方不等了」,资源仍在烧。
取消安全(cancel safety):select! 取消某分支时,该分支已经完成的副作用还在不在。mpsc::recv 是取消安全的(消息不会丢在半路);某些 read_exact 风格的操作不是——取消时可能已经从 socket 啃了几个字节。协议解析要自己把「半包」留在缓冲里,而不是假设每次 select! 回去缓冲是干净的。这和零拷贝缓冲策略是同一张图。
6. 任务树、JoinSet、结构化并发
随手 spawn 是非结构化并发:任务飞出去,错误和取消要自己追。生产默认应能从根砍掉整棵树。
JoinSet 把一组孩子收在一起:join_next 收割、abort_all 全取消、drop JoinSet 也会 abort 剩余任务(具体语义以你用的版本为准,写测试钉死)。请求级的「扇出三个下游,谁先回来用谁」用 select!;「扇出 N 个必须都结束或都取消」用 JoinSet。
use tokio::task::JoinSet;
async fn fanout(ids: Vec<u64>) -> Vec<u64> {
let mut set = JoinSet::new();
for id in ids {
set.spawn(async move { fetch(id).await.unwrap_or(0) });
}
let mut out = Vec::new();
while let Some(res) = set.join_next().await {
if let Ok(v) = res {
out.push(v);
}
}
out
}
注意 unwrap_or(0) 把错误吞了——扇出路径上要决定:一个失败是否取消其余。需要 fail-fast 就在第一次 Err 时 abort_all 并返回。需要部分成功就记录错误计数,不要静默。
JoinHandle 本身不 await 会让任务「脱离」:它继续跑,panic 变成「任务 panicked, no JoinHandle」。生产里要么 await,要么显式 detach 语义并配监督任务。监督者自己要有预算:无限重启加紧密集是放大故障,不是恢复。
7. 背压从调度器这一层就开始
调度器能吞下的任务数只受内存限制。spawn 没有内置上限。流量尖峰时无界 spawn + 无界 mpsc 的形状是:上游继续 accept,任务和消息堆在堆上,延迟先崩,然后 OOM。CPU 可能看起来还不忙——都在等下游。
有界才是默认:
- accept 循环和处理之间用有界 channel,满了就不要再
accept,让内核 TCP backlog 承担短抖动。 - 每个请求内部的扇出有并发上限(信号量或
JoinSet大小)。 buffer_unordered(n)的n是飞在天上的 future 数,不是「尽量快」。
use tokio::sync::{mpsc, Semaphore};
use std::sync::Arc;
async fn accept_loop(mut listener: tokio::net::TcpListener) {
let (tx, rx) = mpsc::channel::<tokio::net::TcpStream>(1024);
tokio::spawn(run_workers(rx));
let sem = Arc::new(Semaphore::new(512));
loop {
let permit = sem.clone().acquire_owned().await.unwrap();
match listener.accept().await {
Ok((s, _)) => {
if tx.try_send(s).is_err() {
drop(permit);
// 队列满:丢掉或停 accept,计一次 metric
} else {
tokio::spawn(async move { drop(permit); });
}
}
Err(_) => drop(permit),
}
}
}
上面是示意:信号量和有界队列要和 worker 收包对齐,不要两套账。真正的 accept 循环通常是「获得 permit 再 accept」或「accept 完 try_send,失败则立刻关掉连接并打点」。关键是 满了必须有行为:拒绝、减速、丢弃,并度量。静默 spawn 到内存里不是行为。
无界 mpsc 的唯一正当理由是:生产者慢、消费者快、且你能证明峰值深度有界(例如「最多 N 个 in-flight 请求,每个一消息」)。证明不了就当它会 OOM。
8. 度量:worker 在干什么,比 QPS 先看
QPS 和平均延迟掩盖调度问题。至少要能回答:
- worker 忙还是在 park? park 多说明 IO 在等或没活;busy 且延迟高,可能是 CPU 或阻塞。Tokio 的
runtime.metrics()(需要tokio_unstable或对应 feature)能看worker_park_count、injection_queue_depth、blocking_queue_depth。 - 阻塞池队列深度。 持续增长说明
spawn_blocking太多或闭包太慢。线程数加到上限之后就是排队,表现和 worker 被堵类似,只是 IO 还在跑。 - 任务数、channel 长度。 任务数随流量线性涨且不回落,是泄漏或取消没传到叶子。channel 长期顶满,是下游慢,不是调度器慢。
- 单任务 poll 时间。 tracing 的 spans 打在业务
await周围。某 span 数毫秒且里面没有await,就是同步代码偷跑进了 worker。
本地复现:用 tokio-console 看任务谁在等锁、谁没被 poll。它吃的是 tracing 仪器,生产要抽样,不要每个任务全开。任务名(task::Builder::new().name("http-worker"))在 console 里值回票价,匿名 spawn 一万个长得一样,你只能数数。
IO 驱动本身也会成为瓶颈:单线程 epoll_wait 要把就绪事件分发给任务。连接数到十万、每条都是短包,wake 风暴会打在驱动和注入队列上。这时要看的不是业务 CPU,是 epoll 的 syscall 频率和 injection_queue_depth。分流(多 runtime、SO_REUSEPORT、或把连接按核钉住)是这一层的招,不是再加几个 worker 能自动好的。worker 加多了,偷和注入更热闹,驱动仍是一条。
压测时区分三种假象:current_thread 上测多连接;在 Debug 未优化下测调度;用 std::net 客户端把双方都堵在同步栈上。Release、多线程、真实的 keep-alive 连接数,三者缺一都不是你线上的调度器。
测试时间:tokio::time::pause()(test-util)让 sleep 变成逻辑时间,单测不必真等三秒。没开 test-util 就在 CI 里 sleep,测试既慢又抖。反过来,生产代码路径里不要依赖 pause;用依赖注入的 Clock 只在你需要「业务时间」的地方,runtime 的计时器仍走墙钟。混淆两者会出现「单测全绿、线上超时全错」——因为单测从未走过真正的时间轮。
9. 取舍:多线程、单线程、每个核一个 runtime
多线程 work-stealing(默认)。 吞吐好,Send 约束硬,偷任务带来的跨核缓存失效可测但通常可接受。适合 IO 为主、任务短、数量大。
current_thread。 没有偷,!Send 可用 spawn_local,延迟更可预测,一个阻塞点死全场。适合:嵌入其它框架的事件循环、单测、有大量 Rc 的单线程状态机。不要用它撑 C10k 然后奇怪为什么一处 fs::read 全局卡死。
每个 CPU 一个单线程 runtime,用 SO_REUSEPORT 把连接分核。 买到的是缓存局部和「这条连接的任务永不离开这颗核」。代价是负载不均、实现复杂、共享状态要走跨 runtime 的 channel。这是代理类产品在 profiler 证明跨核偷窃是热点之后的招,不是起步默认。
worker 数量。 默认等于 CPU 数。纯 IO 服务加线程很少能加吞吐,反而增加偷和上下文切换。CPU 密集该离开 worker,而不是加 worker。阻塞池大小按「同时阻塞的 syscall 数」估,按队列深度调,不要和 worker 数绑死。
LocalSet。 在多线程 runtime 的某条线程上跑 !Send 任务。必须保证这些任务不会被偷到别的线程——LocalSet 就是这个保证。用它夹第三方只提供 !Send 的回调。不要把整个请求链路放进 LocalSet 只为了偷懒不改 Rc。
还有一条容易忽略的边界:在 runtime 里再 Runtime::new().block_on()。嵌套 runtime 会制造两套 IO 驱动、两套计时器,死锁模式完全不同。正确做法是拿 Handle,或把这段代码改成 async 由外层驱动。库 crate 尤其不要在 Drop 或同步函数里偷偷 block_on——调用方可能已经在 Tokio 里,你在内部再造一个,等于在别人的房子里再打一口井。
10. 排障:卡顿、饿死、取消无效、内存涨
按症状选层,不要先改业务算法:
- 所有请求一起慢,CPU 一条线程 100%。 某个任务在 worker 上跑同步死循环或重计算。火焰图会指到你的代码而不是
epoll_wait。切开,yield_now或spawn_blocking。 - 所有请求一起慢,CPU 接近 0。 在等锁、等下游、等
std锁跨 await 的经典死锁。tokio-console看谁握着Mutex。也可能是有界 channel 打满形成环形等待:A 等 B 的槽,B 等 A 的处理——画依赖。 - 尾延迟尖刺,平均还行。 合作预算耗尽前的长 poll、计时器风暴、GC 式的集中 drop(一次取消一万个任务)、阻塞池偶尔排队。看 p99 和
blocking_queue_depth是否同跳。 - abort 之后任务还在跑。 叶子在同步代码或 FFI 里。传
CancellationToken进叶子,syscall 用带超时的版本,或把叶子改成可 poll 的异步。 - RSS 随流量涨不回。 无界 spawn/channel、任务没结束(没人 poll 也没人 drop)、
JoinHandle没人要但也没结束、泄漏的Arc循环。先看任务数和 channel 长度,再看 heap。 spawn_blocking之后变慢。 池满。要么减少阻塞调用,要么提高上限并接受线程数,要么把活移出进程。
一个具体反例:在 async 里用 std::sync::Mutex,锁里 await 下游 HTTP。某请求拿到锁后挂起,worker 继续跑别的任务;那些任务也来抢这把锁,阻塞 worker——std::sync::Mutex 的抢锁是同步的。结果是 runtime 的一部分线程睡在内核锁上,IO 驱动还在,表现像「随机卡死」。修法:锁内不做 IO;必须跨 await 的互斥用 tokio::sync::Mutex,并接受它更贵;最好还是把状态改成每个任务一份。
11. 和所有权叠在一起的那几条硬约束
调度器把任务搬走,所以所有权篇里的 Send + 'static 在这里变成运行时事实。几条不要分开记:
- 跨
await的数据必须拥有或Arc,因为下一次 poll 可能在另一条线程、另一个栈。 - 锁守卫跨
await要么编不过(!Send),要么编过了但把互斥范围拉成「整个下游 RTT」。 spawn出去的孩子不共享父栈。要共享就显式Arc,要取消就显式 token 或JoinSet。- 阻塞 syscall 不经过 poll,取消、超时、偷窃对它无效。
把这四条当代码评审清单,比争论「要不要用 actix 还是 axum」更能避免事故。框架只是在这些约束上面再铺一层 HTTP。
生产里还有几条调度器不会写进报错、但会写进延迟直方图的约束。join!(a, b) 是并发 poll 两个 future,不是开两条线程;两者都 Ready 之前不会往下走,取消时两个一起 drop。try_join! 在第一个错误处返回,另一个被取消——这是 fail-fast,要确认 a 的副作用可接受被丢掉。select! 带 biased; 时按书写顺序优先,公平性被你显式放弃,适合「取消 token 永远优先于业务 recv」。不带 biased 时内部有轻微轮转,不要依赖「哪个分支更可能赢」。
Notify / watch 用来发信号,不是用来传数据。notify_waiters 叫醒当前所有等待者,之后才注册的人看不到这次通知——这是「丢失的唤醒」,和 Future 忘了存 Waker 同类。状态变化要用 watch 的版本值或自己在 Mutex 里留一个条件标志,等待方被叫醒后重新读,不能假设「被叫醒 = 条件已成立」。经典的 condvar 用法在异步里同样适用:锁里改条件,解锁后 notify,等待方循环检查。
多 runtime 同进程:测试框架、嵌入式脚本、某些插件会再拉起一个 Tokio。Handle::current() 拿到的是 enter 栈顶的那个,不一定是你以为的那个。跨 runtime 的 spawn 会把任务投到另一套 IO 驱动上,timer 和 fd 不能混用。明确把 Handle 当依赖注入,初始化时存进结构体,所有 spawn 走 self.rt.spawn。隐式 current 是单 runtime 进程的便利,不是库代码的合同。
阻塞池的线程不是无限的。默认上限大约 512,但真正能同时跑的受机器和 max_blocking_threads 限制。池满之后 spawn_blocking 的任务在队列里等,调用方的 await 表现为延迟,火焰图上看不到你的闭包——它还没开始。度量 blocking_queue_depth 就是为了这个盲区。把每请求 JSON 解析丢进阻塞池,请求一多,池和队列先于 CPU 饱和。JSON 解析若是百微秒级,留在 worker 上并偶尔 yield_now 更合适;毫秒级以上或会调用阻塞 FFI,再进池。阈值用测量,不要用感觉。
IO 就绪风暴和任务惊群是调度器在高连接数下的另一种病。一万个 socket 同时可读(例如上游恢复、心跳对齐),驱动会在一轮 epoll_wait 里拿出大量事件,把对应任务全部注入。短时间里注入队列深度尖刺,worker 来回切任务,缓存被冲掉,尾延迟跳一档。应用层对策是:不要让所有连接的超时对准同一时刻(加抖动);读循环一次不要贪太多帧再让出;用令牌桶限制从就绪变为「正在处理」的连接数。这不是调度器的 bug,是你把自然并发赤裸裸地交给了注入队列。
select! 和 JoinHandle 混用时,被丢掉的分支如果是 handle.await,任务继续跑,只是你不再收结果。这是隐式 detach。想取消,abort 或 drop 一个会 abort 的 JoinSet。想保留,就把 handle 存起来。评审里看到 select! { _ = timeout => ..., r = handle => ... } 必须问超时之后那个任务的命运。命运没有写明的 select! 是资源泄漏的温床。
本地开发用 RUST_LOG=tokio=trace 会把你淹没。更有效的是在可疑任务上打 tracing::instrument,看 span 的持续时间是否远大于内部 await 之和——差值就是同步代码或等待锁。差值大且 CPU 低,是等;差值大且 CPU 高,是算。两种修法相反。
信号和优雅退出也走调度器。ctrl_c().await 只是一个 future,真正的退出要把取消传到每一棵任务树:停止 accept、排空 in-flight、等一个截止时间、然后 abort 剩余。截止时间内阻塞在 std 锁或 FFI 上的任务听不到。所以优雅退出的预算里要包含「听不到取消的叶子」的最坏 syscall 时间,否则你会在 SIGTERM 之后被 kube 的强制 SIGKILL 打死,半写的文件和未发送的响应就留在那里。把退出当成一次全图取消来测,不要只测 ctrl_c 能返回。
优雅退出测的是整棵任务树,不是 ctrl_c 这个 future 能不能返回。流量还在进的时候发取消,断言 accept 停、在途请求在截止前结束、没有任务在取消之后还碰已经 drop 的状态。截止必须短于编排系统的强制杀死窗口。听不到取消的叶子要么换成带超时的 syscall,要么接受被杀并由上层重试把状态修回。
12. 小结
Tokio 多线程 runtime 的原理是协作式 poll + work-stealing。正确性靠三条工程约束:await 点可取消、阻塞进专用池、队列有界。任务能被偷,所以 Send + 'static;任务不会被抢占,所以 worker 上不能跑死循环;取消是 drop/下一次 poll,所以叶子必须能被 poll 到。
把 QPS 当调度健康度,会在无界队列里自我感觉良好直到 OOM。先看 worker 在 park 还是在跑同步代码,再看阻塞池和 channel 深度。调度器是管道的节拍器,不是无限 CPU。下一篇把任务里搬来搬去的那块字节拿出来:什么叫零拷贝,什么时候 memcpy 其实是诚实的。