把 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 时:

  1. 调度器从队列取出任务,在当前 worker 上调用 poll。
  2. 若返回 Ready,任务结束,JoinHandle 一侧被唤醒。
  3. 若返回 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. 排障:卡顿、饿死、取消无效、内存涨

按症状选层,不要先改业务算法:

  1. 所有请求一起慢,CPU 一条线程 100%。 某个任务在 worker 上跑同步死循环或重计算。火焰图会指到你的代码而不是 epoll_wait。切开,yield_now 或 spawn_blocking。
  2. 所有请求一起慢,CPU 接近 0。 在等锁、等下游、等 std 锁跨 await 的经典死锁。tokio-console 看谁握着 Mutex。也可能是有界 channel 打满形成环形等待:A 等 B 的槽,B 等 A 的处理——画依赖。
  3. 尾延迟尖刺,平均还行。 合作预算耗尽前的长 poll、计时器风暴、GC 式的集中 drop(一次取消一万个任务)、阻塞池偶尔排队。看 p99 和 blocking_queue_depth 是否同跳。
  4. abort 之后任务还在跑。 叶子在同步代码或 FFI 里。传 CancellationToken 进叶子,syscall 用带超时的版本,或把叶子改成可 poll 的异步。
  5. RSS 随流量涨不回。 无界 spawn/channel、任务没结束(没人 poll 也没人 drop)、JoinHandle 没人要但也没结束、泄漏的 Arc 循环。先看任务数和 channel 长度,再看 heap。
  6. 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 其实是诚实的。