Tokio 源码深度解析

第19章 性能调优与典型陷阱

作者 杨艺韬 · 9,764 字

第19章 性能调优与典型陷阱

本章要点

  • coop budget = 128:每个 task 进入 poll 时拿 128 个”点”、每次 await 一个 Tokio 原语扣 1 点——扣完强制 yield。防止 hot task 饿死别人
  • LIFO slot 是把双刃剑:提升父子 task 局部性、但会阻止 work stealing——spawn 洪峰场景下反而拉低吞吐
  • 火焰图在 async 里看什么:关注 park/unpark 的占比poll 函数栈深度mpsc send/recv 的 wait 时长——和同步世界完全不同的 pattern
  • 10 大生产陷阱:std::sync::Mutex 跨 await、阻塞在 worker、无 backpressure 的 spawn、未 pin 的 stream、select! 的 cancel safety、JoinSet 泄漏、spawn_blocking 用来做 CPU 密集、waker 反复 register、大对象传递、单 runtime 跑混合负载
  • 排障流水线压测复现 → Metrics 定位象限 → flamegraph 找热点 → tokio-console 看 live → 针对修复

19.0 性能问题的分类

Tokio 服务的性能问题可以分成四类、每类对应不同的诊断和修复方法:(1)吞吐量问题(QPS 上不去)、(2)延迟问题(P99 / P999 太高)、(3)资源问题(CPU 或内存占用异常)、(4)稳定性问题(性能偶尔剧烈抖动)。本章按这四类展开——每类给出典型原因、诊断方法、修复模板。遇到真实性能问题时先问自己”这是哪一类”、再对照相关章节——比盲目调 runtime 参数高效得多。

这四类问题的先后顺序也有讲究——大多数情况下从(4)稳定性开始查稳定性问题往往会掩盖其他问题——如果性能忽上忽下你根本没法判断平均 QPS 是否合理。先解决稳定性、再看 QPS / 延迟 / 资源。这种”先稳定再优化”的顺序是老工程师的习惯——年轻人容易上来就盯 QPS 数字、最后发现先要去解决偶发抖动。

把 Tokio 下的性能问题分三大类——诊断手段完全不同

  1. 吞吐不够:QPS 上不去、CPU 没打满——多半是调度 overhead 过高存在同步锁竞争
  2. 延迟抖动:p50 很低但 p99 飙高——多半是某些 task 偶发堵住 workerGC-like scheduler pause
  3. 资源异常:内存稳定增长 / blocking 线程暴涨 / fd 泄漏——多半是task 泄漏错配线程池

这三类问题的排查顺序、观察指标、修复手段都不一样。最怕的就是”不分象限、见山拆山”——修了半天发现瞄错了目标。本章带你走一遍完整流程。

19.1 coop budget:你不知道的”每 128 次 await 一次让步”

coop budget 是 Tokio 内置的公平性机制——一个 task 连续 poll 128 次后会被强制让出 worker、避免 long-running task 霸占 worker 饿死其他 task。这个机制对用户几乎完全透明——但理解它能帮你回答一些诡异现象:“为什么我的 loop-heavy task 每 128 次会有微小抖动”、“为什么 recv 在空 channel 里返回 Pending 有时也计数”。这是 Tokio 为了公平性主动付出的一点点公平税——绝大多数场景下几乎看不到影响、但偶尔能帮你理解诡异的性能曲线。

这是 Tokio 隐藏很深的一个机制——你写了几年 Tokio 都不一定接触到它。但一旦你开始做性能调优、这个机制会浮出水面:为什么我的 benchmark 的 P99 周期性抖动?为什么 hot path 上每 100 多次 poll 有一个小停顿?为什么 coop_yield_count 这个 metric 在上涨?——这些问题都要从 coop budget 理解起。

打开 tokio/src/runtime/coop.rs

#[derive(Debug, Copy, Clone)]
pub(crate) struct Budget(Option<u8>);

const fn initial() -> Budget {
    Budget(Some(128))
}

pub(crate) fn has_budget_remaining() -> bool {
    context::budget(|cell| cell.get().has_remaining())
        .unwrap_or(true)
}

pub(crate) fn poll_proceed(cx: &mut Context<'_>)
    -> Poll<RestoreOnPending>
{
    // 扣 1 点、返回 Pending 如果耗尽
}

关键数字:128。每个 task 被 schedule 上 CPU 时、coop budget 初始化为 128。Tokio 内部的 channel::recv / IO::poll_read / Notify::notified 等原语——每个在 await 时会调 poll_proceed、扣 1 点。扣到 0 后——即便底层数据已经 Ready、也强行返回 Pending、让 task 让出 CPU、给别的 task 机会。

为什么需要这个机制

如果没有 coop budget——一个极端情况:一个 task 在 loop { rx.recv().await } 里无限循环从满 channel 里 recv——recv 总是立刻 Ready、task 永远不让出、worker 上其他 task 饿死。这种情况不需要代码错、也不需要死循环——只要”输入快到处理不完”就会发生。coop budget 强制每 128 次 poll 让出一次——即使 task 在合法循环里、worker 也会被让出来。这是对”合法代码但负载异常”场景的保护。

考虑一个反例:

let mut stream = some_mpsc_rx;
while let Some(msg) = stream.recv().await {
    process(msg);  // ← 假设 process 很快
}

如果 mpsc_rx 里塞了 100 万条消息、而 process 只是 1 微秒的事——这个 task 理论上可以一直 poll、不 yield(因为每次 recv 都 Ready)。结果就是这个 worker 被这一个 task 霸占、别的 task 饿死。

coop budget 强制这个循环每 128 次 await 就让出一次 CPU——哪怕 mpsc 里有数据。看起来是”故意慢下来”、实际是以微小的吞吐让步换取调度公平性

绕过 coop:unconstrained

Tokio 提供 tokio::task::unconstrained(future) API——把一个 Future 标记为”不受 coop 限制”、这个 future 不会被 coop 强制让出。几乎不应该用——绕过 coop 意味着你接受”饿死其他 task”的风险。唯一合理场景是你明确知道这个 Future 是 runtime 内部逻辑、不会干扰用户 task——这样的场景极少。看到代码里用了 unconstrained、要花时间读懂为什么——大概率是过早优化或误用。

极少数场景你真的想让某个 task 吃满一个 worker(比如专职做心跳的 task)——可以用 tokio::task::unconstrained(fut) 包一下、这个 future 及其子 future 里的 Tokio 原语不再扣 budget副作用:这个 task 会吃光 worker、你最好 spawn 它到一个专用 worker 或专用 runtime。

你能观察到 coop 吗

上一章讲过——RuntimeMetrics::budget_forced_yield_count()。这个数持续上升意味着你有hot task 在吃满 worker、coop 在救场如果它 = 0、说明要么你的 task 都很 chill、要么你根本没打开过这个指标。

19.2 LIFO slot:局部性之刀的双刃

LIFO slot 是一把双刃剑——大多数场景下它给你巨大性能收益(缓存热、调度开销省)、但在少数场景下它反而会成为 bottleneck。典型负面场景是”父 spawn 子之后不立即 await”——这种情况下 LIFO slot 里的 task 不一定比全局队列里的旧 task 更紧急、但 LIFO 优先会让 worker 反复拿到同一组近期 task、饿死全局队列的老 task。理解 LIFO 的双刃特性能让你在 profile 里识别这种反模式。

第 5 章讲过 LIFO slot 的原理、第 19 章讲它的陷阱——一种能力和它的反面在两章分开讨论、帮你建立立体认识。这也反映了本书的编排思路:机制和问题分开讲——不是一上来就”这个有什么坑”吓唬你、而是先把机制讲清、再把问题讲透。读者心里建立起来的理解更踏实。

第 5 章讲过——每个 worker 有一个 LIFO slot、最近 spawn 的 task 进 LIFO、下一个 poll 优先从 LIFO 拿。目的是:

父子 task 流水线优化。比如这段经典代码:

async fn handle(req: Request) -> Response {
    let (tx, rx) = oneshot::channel();
    tokio::spawn(async move {
        let data = heavy_work().await;
        tx.send(data).ok();
    });
    let data = rx.await?;  // ← 立刻等子 task
    make_response(data)
}

如果没有 LIFO slot、这个”spawn-then-await”pattern 里、子 task 会被扔到 run queue 尾部、等别的 task 都跑完再轮到它——引入不必要的延迟有了 LIFO slot、子 task 直接进 LIFO、下一 poll 立刻跑、和父 task 之间像同一个协程的两段

但它也会反噬

LIFO slot 的反噬场景常见于”单个 task 反复 spawn 大量子 task、但立刻不 await”——spawn 的子 task 进 LIFO、下次 worker 取 LIFO、这个子 task 立刻跑——好像没问题?但如果子 task 里又 spawn 了孙 task、每代都进 LIFO——worker 一直在 “最新 spawn 的 task” 上跑、全局队列里的老 task 根本没机会。这叫”LIFO starvation”——靠 coop budget 每 128 次强制让出才能打破。识别这种场景需要看 tokio-console 的 task age 分布——如果老 task 的 age 持续增长、就是 LIFO starvation

LIFO slot 里的 task 不能被其他 worker steal——因为 steal 走了就破坏了局部性。这意味着:当某个 worker 的 LIFO slot 里的 task 变成”长 poll”(比如它自己又 await 了、要等很久)、别的 task 在这个 worker 的 run queue 里排队、别的 worker 想 steal 也 steal 不到 LIFO 的

第 16 章讲的 block_in_place 所以要主动把 LIFO slot 里的 task 推回 run queue——就是怕这个反噬。

实务建议

实务中关于 LIFO 的建议很简单:默认开着、有问题再关。99% 场景 LIFO 是净正收益——只在做深度性能调优、profile 显示明确的 LIFO starvation 时、才考虑通过 Builder 关闭 LIFO slot。这是 Tokio 参数里一个”不要轻易动”的参数——默认值是经验优化结果、改不如不改。

  • 一般场景信任默认行为——LIFO 的局部性收益大于其代价;
  • 如果你做了很多”spawn 完立刻 await 父子 pattern”——LIFO 对你最有利;
  • 如果你做”spawn 完丢一边不管”的场景——LIFO 其实没用,而且有极端情况会拖慢 steal。目前 Tokio 没提供关 LIFO 的 API、只能接受。

19.3 火焰图在 async 里的读法

火焰图是性能调优的经典工具——但在 async 代码里读火焰图和同步代码很不同。关键区别async fn 被编译成状态机、火焰图里看到的 symbol 往往是 my_crate::MyFuture::poll 而不是 async fn 本身调用栈在 await 点会”断开(await 让出后 poll 是下次被调度时从头重新开始);spawn 的 task 和调用者的栈完全断开——火焰图里它们是独立的调用栈。理解这些特性能让你正确解读 async 火焰图、避免 “看不出问题在哪” 的挫败感。

推荐两个工具async-profiler 支持 Rust、能给出准确的 async 火焰图;pprof-rs + flamegraph 是纯 Rust 的方案、零依赖。两者都能解决”async 火焰图难读”的问题——你花一天学会其中一个、终身受益。

生产排障最常用的工具——perf + inferno-flamegraph 生成的火焰图。但 async 下的火焰图长得和同步不一样——以下几个特征要会认:

特征 1:poll 函数占比极高

Tokio 火焰图里看到大块的 Future::poll 时间——不一定是 poll 本身慢、而是 poll 内部做了大量工作。展开栈会看到具体的业务代码——那些才是真正的”慢点”。async fn 编译成 state machine 后、所有执行都在 poll 函数里——这是为什么 async 火焰图里 poll 占比高是常态、不是 bug。

你会看到一个巨大的 stack,里面 impl Future for xxx::pollimpl Future for yyy::poll 层层嵌套。这不是 bug——async/await 在编译期就是这样展开的——生成的状态机每层都有 poll。火焰图的高度比同步代码多 5-10 层是正常现象

特征 2:parkunpark 要看比例

火焰图里 park / unpark 的比例告诉你 runtime 的”忙闲比——park 占比高说明 worker 大部分时间在等事件(IO bound 或负载不足);park 占比低说明 worker 忙到几乎不休息(CPU 饱和)。理想状态是 park 50-70%——说明 worker 既有事做又不过载。长期 park < 20% 或 > 90% 都是异常信号——前者代表过载、后者代表资源浪费。

tokio::runtime::park::park——worker 在睡觉;park::unpark / wake——worker 被唤醒。park 占比高:worker 大多数时间在等——你的瓶颈不是 CPU、是 IO 或下游park 占比低:worker 在忙干活——此时你要看 poll 里的具体热点

特征 3:channel 的 send/recv 要看是不是”等锁”

channel send/recv 在火焰图里不应该大量出现——如果大量出现说明有争用问题。bounded channel 满时 send 挂起、空时 recv 挂起——这些是正常的 Pending。如果看到 send/recv 的栈里出现大量 spin_loop 或 park/unpark——说明有竞争或调度乒乓、要回去找原因。

mpsc 的 send 如果 buffer 满了会挂起、火焰图上会看到 AtomicWaker::register——这本身不耗 CPU、但如果持续出现说明buffer 小了consumer 慢了

特征 4:syscall 比例

火焰图里syscall 占比告诉你 runtime 和 OS 的交互频率——epoll_wait、read、write、clock_gettime——都是 syscall。合理比例是 5-15%——太低说明 worker 没做 IO(可能是纯 CPU 任务、不是 Tokio 典型场景);太高(>30%)说明 syscall 频率过高、可能要 batch 或用更高效的 IO 模型(io_uring)。

Linux 下 epoll_wait 是主要 syscall——占 5-20% 是正常远高于此(比如 40%)——说明你的 task 都很短、syscall overhead 比例偏高——考虑 batching。

特征 5:memcpy / drop 不要忽视

火焰图里大块的 memcpyDrop 调用是容易被忽视的性能杀手memcpy 通常来自大数据结构的拷贝(比如把一个大 struct 通过 channel 送、channel 会整体 clone);Drop 通常来自复杂结构的释放(比如嵌套深的 Vec<Vec<…>>)。解决办法:用 Arc 共享而不是 clone、用 Box 分配大对象而不是嵌入、batch 处理而不是逐个 drop

Rust 默认不 clone、但 async move 闭包捕获大对象会发生——火焰图里突然冒出一大片 memcpydrop_in_place——说明你在到处搬大结构体。改用 Arc 共享、或 Box 包起来

19.4 10 大生产陷阱

本节总结 Tokio 真实生产里最常遇到的 10 种性能陷阱——每一种都有具体症状、诊断方法、修复模式。这 10 种陷阱覆盖了 90% 以上的 Tokio 性能事故——熟悉它们你能在问题出现时快速定位:blocking 污染 async、跨 await 持 Mutex、unbounded channel 内存爆、hot loop 不 yield、未 await 的 JoinHandle、Arc clone 过度、过细粒度 task 开销、过大 task payload copy、tracing 配置错误、spawn 风暴。每种陷阱在下面详讲。

陷阱 1:std::sync::Mutex 跨 await

这是 Tokio 服务性能事故第一名症状:偶发 thread 挂住、QPS 突然跌到接近 0;诊断:tokio-console 看到某个 task 的 busy 时间被其他 task 的等锁吃光;修复:要么用 tokio::sync::Mutex(支持跨 await)要么缩短临界区到微秒级(不跨 await)。这个陷阱在 code review 里是必查项——看到 std Mutex 就要问”持锁期间跨 await 吗”。

// ⚠️ 反面
let data = SHARED.lock().unwrap();
some_async_fn(&data).await;  // ← lock 跨 await

后果:lock guard 被 drop 在 await 之后——期间这个 Mutex 阻止别的 task 取锁如果这个 task 之后被 schedule 到别的 worker、死锁风险(同一个 std Mutex 在不同线程 lock 是合法的但非 reentrant)。

:用 tokio::sync::Mutex(它的 lock 本身是 async、可以 await)、或把同步访问缩小到不跨 await{ let data = SHARED.lock(); ... } 短作用域)。

陷阱 2:阻塞调用直接在 worker 上跑

第 16 章反复讲过的陷阱——症状:worker busy_ratio 异常高、某类请求 P99 飙升;诊断:tokio-console 看到某个 task 的 poll duration 长达几百毫秒;修复:把 blocking 调用包进 spawn_blocking 或用 async 版本。真实项目里 80% 的 “Tokio 慢” 问题都能追到这个——找到了就改、改了就好。

上一章讲过(第 16 章)。症状:延迟抖动、CPU 利用率只有 1/worker_threads、flamegraph 上显示 syscall 或 CPU 密集函数占一整块。

spawn_blocking 或 Rayon。

陷阱 3:无 backpressure 的 tokio::spawn

来一个请求 spawn 一个 task”——听起来合理、但没有上限就是 spawn 风暴。症状:高并发下内存快速增长、最终 OOM;诊断:metrics 看到 active task 数不断上涨;修复:用 Semaphore 限制 spawn 并发数、或者用固定大小的 worker 池 + channel 接入。“无限 spawn” 在真实生产里是服务自杀 的常见方式之一。

loop {
    let job = rx.recv().await?;
    tokio::spawn(process(job));  // ← 谁 limit?
}

后果:producer 比 consumer 快、spawn 出去的 task 一直堆积、内存暴涨。

:用 Semaphore::acquire_owned() 做 concurrency limit、或用 JoinSet 控制并发数。

陷阱 4:未 pin! 的 stream

tokio_stream 的 Stream::next 要求 self 是 Pin<&mut Self>——如果你的 stream 不是 Pin 的、编译器会强制你加 pin!Box::pinpin! 在栈上 pin、零开销;Box::pin 在堆上分配 + pin、每次都有开销。hot loop 里反复 Box::pin 一个 stream 是常见的性能陷阱——改用 tokio::pin!futures::pin_mut! 能节省大量 heap 分配。

let stream = some_stream();
while let Some(x) = stream.next().await {  // ← 编译可能报错、或者需要 Unpin
    ...
}

Stream 默认不是 Unpin——需要 tokio::pin!(stream)Box::pin(stream)陷阱在于有些 stream 实现了 Unpin、你写的时候没报错、换一个 stream 实现就挂了。

:养成习惯——任何循环消费 stream 的地方、都写 tokio::pin!(stream)

陷阱 5:select! 分支的 cancel safety

第 14 章讲过的陷阱——select! 的每个分支都可能被 cancel、如果 Future 不是 cancel-safe 会丢数据或状态不一致。症状:某些消息偶尔丢失、状态不一致;诊断:逐一审查 select! 的每个分支、确认都是 cancel-safe;修复:把非 cancel-safe Future 放到 loop 外面或包一层。

第 14 章详细讲过。某个分支里的 .await 如果被 select! 取消、状态可能半更新——比如 HashMap::entry(k).or_insert(v).await 中间被打断、entry 创建了但 value 没赋。

select! 的每个分支只能是 cancel-safe future。Tokio 原生都 safe、业务 async fn 默认不 safe。复杂业务要 select 等、用 tokio::spawn + JoinHandle await 代替直接 select——JoinHandle cancel 是把 task abort、不会产生半更新。

陷阱 6:JoinSet 只 spawn 不 join

如果你用 JoinSet spawn 但从来不 join_next——task 完成后的 output 和 error 会一直堆在 JoinSet 里、形成内存泄漏。症状:慢速的内存增长、eventually OOM;修复:要么 regularly drain(循环 while joinset.join_next().await)、要么用 AbortHandle + abort_all 定期清理。

第 15 章讲过。for 循环里 set.try_join_next() 处理不过来 → JoinSet 积压。

:循环退出前 while let Some(_) = set.join_next().await {}、或设 set.len() 上限。

陷阱 7:spawn_blocking 跑 CPU 密集

spawn_blocking 设计初衷是处理 IO 阻塞、不是 CPU 密集。但很多人误把它当成”并行计算”用——spawn 100 个 CPU 密集 task、每个占满一个 blocking thread——CPU 只有 8 核、100 个 thread 疯狂 context switch、性能反而比 rayon 差得多。CPU 密集永远用 rayon(线程数 = CPU 核心数、真正并行)、不要用 spawn_blocking。

第 16 章讲过。症状:blocking pool 512 线程打满、p99 暴涨。

:CPU 密集用 Rayon、或独立 CPU runtime。

陷阱 8:waker 反复 register

自己写 Future 时的常见陷阱——每次 poll 都 cx.waker().clone() 并存到某处、但旧的 waker 没清理。症状:内存持续增长、每次 poll 都新增一个 waker;修复:用 AtomicWaker 等自带去重的工具、或者每次 poll 前清理旧 waker。自己写 Future 实现时 waker 管理是最容易出错的地方——能用现成 utility 就别自己写。

自己写 Future 时经常犯——每次 poll 都 cx.waker().wake_by_ref()cx.waker().clone().wake()——相当于立刻把自己 schedule 回 run queue、形成 busy loop。

症状worker_poll_count 几秒内涨千万、CPU 100%。

只在”真正能有进展时才 wake”——比如 channel 里有新数据、IO ready。poll 返回 Pending 但不 wake = 挂起等 external trigger、这才是对的。

陷阱 9:大对象通过 channel/select! 传递

直接 send 大对象(>1KB)通过 channel 会触发 memcpy——高并发下这个 memcpy 累积起来开销可观。正确姿势Arc<BigObject> 而不是 BigObject——Arc clone 几个字节的 refcount、比 memcpy 大对象快几个数量级。这种 “用 Arc 代替 copy”是 Tokio 生态里处理大数据的标准套路。

oneshot::channel::<LargeStruct> 发送大结构体——send 时 memcpy 整个结构体到 channel 的 slot。几 KB 没事、几 MB 就明显。

:发 Box<LargeStruct>Arc<LargeStruct>——传指针、不传

陷阱 10:单 runtime 跑混合负载

第 18 章讲过的场景——IO task 和 CPU task 在同一个 runtime 里、CPU task 拖慢 IO。症状:IO 相关 P99 被 CPU 任务间歇性拖高;修复:拆成两个 runtime。这是”你已经试过所有轻量方案都没用”时候的终极方案——不到那个点不要轻易用、但到了那个点就要果断用。

第 18 章讲过。症状:IO p99 受 CPU task 影响、blocking pool 或 worker pool 时好时坏。

:分双 runtime——IO runtime + CPU runtime。

19.5 一套完整的排障流水线

本节讲一个完整的 Tokio 服务排障流水线——从”用户报告服务慢”到”定位具体根因 + 修复”的完整流程。好的排障流程不是靠灵感——是一套可重复的方法论:(1)从对外指标确认问题范围(是整体慢还是特定接口?是全量还是特定用户?)、(2)定位到运行时组件(IO? Scheduler? Blocking pool?)、(3)用 tokio-console 看 task 级细节(4)找到 hot path(5)修复 + 验证。这套流水线熟练后、大多数性能事故能在几小时内解决。

这套方法论的关键是”逐步收敛”——每一步让问题范围缩小一个数量级。(1)对外指标看出”哪个 endpoint 慢——从几百个 endpoint 里定位到 1-2 个;(2)runtime 组件缩到”哪个子系统——IO / Scheduler / Blocking 3 选 1;(3)task 级细节锁定”哪个 task——具体到某个 task id;(4)hot path 找出”哪行代码——最终定位到几十行代码里的一个问题。如果你尝试跳过某一步、后续会很难走——每一步都是下一步的过滤器

观察到的症状第一工具第二工具优先排查方向
p99 抖动但 CPU 不高metricstokio-consoletask 长时间不被 poll、锁等待
CPU 打满且吞吐不上升flamegraphperf / eBPFhot loop、序列化、拷贝、drop 风暴
alive tasks 持续上涨runtime metricstrace spanspawn 泄漏、JoinSet 未回收
channel 延迟升高tracingflamegraph队列无背压、send/recv 等锁
blocking pool 排队metricsthread dump阻塞 IO 或 CPU 任务放错池

综合前面所有章节、我给你一套”遇到问题该走的路径”:

Step 1:建压测基线

没有基线不谈调优。用 wrk / k6 / vegeta 压测、记录 QPS / p50 / p99 / p999。

关键压测时间要够长——至少 5 分钟、观察是否有周期性抖动;并发梯度要覆盖——从 1 并发到 10x 预期并发、找 knee point。

Step 2:打开 Metrics + tracing

上一章讲过。num_alive_tasks 曲线涨?有 task 泄漏(陷阱 3 或 6)。worker_steal_count?负载分布不均。budget_forced_yield_count?hot task 霸占 worker。

Step 3:生成火焰图

sudo perf record -F 99 -g -p $PID -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > fg.svg

看火焰图里的”宽平顶”——你的主要 CPU 时间花在哪个函数上。如果是你自己的业务代码、好办,优化它;如果是 poll_future / mpsc::send / Mutex::lock 等运行时函数、参考前面陷阱。

Step 4:tokio-console 看 live 状态

“哪个 task 卡着不动”——tokio-console 一目了然。看 idle / busy ratio最久没 poll 的 task的 backtrace。

Step 5:针对性修复 + 对比验证

修完一个问题再测——别一次改 5 个地方。每次修改后重跑压测、对比基线。如果某次修改没效果、立刻回滚——因为意味着你改错了靶子。

最忌讳一边改一边猜、最后代码一团糟性能没变

19.6 一个完整的真实案例:QPS 从 3k 到 15k

一个真实调优案例——某 Tokio 服务在压测时 QPS 卡在 3k 上不去、CPU 只用了 20%、看起来有大量资源浪费。通过本章讲的排障流程逐步诊断——最终发现三个叠加问题:(1)某个 request handler 里用了 std::sync::Mutex 锁一个 hot table、跨 await 持有;(2)日志用了同步 println、高并发时阻塞 worker;(3)spawn 太细、每个 request spawn 10+ 微 task。修复这三个问题后、QPS 从 3k 升到 15k——5 倍提升、没换硬件。这个案例说明性能问题往往是”几个小问题叠加”、单独看都不起眼、合起来拖住系统

从这个案例提炼两个经验(1)不要只改一个问题就停下——排障时把所有发现的问题记下来、一次修完再复测;修一个看一下效果、再修一个、反而定位不出哪个问题贡献多少;(2)压测场景要尽量接近生产——很多问题只在特定 request mix 下出现、压测用单一请求类型可能完全看不到**。这两点在真实项目里反复验证过有用。

分享一个我亲历的优化案例——一个 Web 服务做证书签发(CPU + 网络)、原 QPS 3k、p99 800ms。

第一轮:打开 Metrics 发现 blocking_queue_depth > 100——有大量 spawn_blocking。定位到代码是签名用了 RSA 2048、每次 40ms——典型陷阱 7。改成 Rayon 独立池。QPS 升到 7k、p99 降到 350ms。

第二轮:Flamegraph 发现 20% 时间在 Arc::clone / Arc::drop——业务代码到处 clone 一个 Config——类似陷阱 9 但方向相反(不是传值太大、是 Arc 引用过多)。改成 &Config 在 handler 里透传。QPS 升到 10k、p99 降到 250ms。

第三轮:tokio-console 发现某个专职 metrics 上报的 task一直在 busy loop——每次 tick 都立刻 wake 自己——陷阱 8。把 interval.tick() 改成正确用法。QPS 稳定 15k、p99 降到 120ms。

整个过程 1 周——每一步都有数据支撑、每一次修改都验证。这就是”按流水线走”和”瞎改”的区别

19.6½ 性能调优里另外几个少人讲但重要的细节

这一节讲其他书和文档里很少提到的几个调优细节——这些都是真实生产里见过的、值得记住的。细节 1设置合理的 worker 数——K8s 环境下 worker 数要和 CPU limit 对齐、不是机器总 CPU;细节 2big task 要分片——一个 Task 的 Future size 不要超过 16KB、太大会让本地队列频繁 overflow;细节 3async trait 的动态分派开销——如果 hot path 上有 dyn AsyncRead、考虑直接用具体类型;细节 4allocator 的选择很重要——高并发场景下 jemalloc / mimalloc 比系统 malloc 快几倍。

细节 1:tokio::spawn 本身不是零成本

很多人以为 tokio::spawn(async {...}) 是白菜价。实际上一次 spawn 包含:

  • 分配 Task struct(Header + Core + Trailer)——几百字节的堆分配、对应一次 malloc
  • 注册到 OwnedTasks(一把 Mutex 的 HashSet)——一次原子操作 + 可能的锁争用;
  • schedule 到 run queue(本地 queue 或全局 inject)——几个原子操作;
  • 创建 JoinHandle——几个字节但要配 Atomic refcount。

合起来 ~300-800 纳秒对一次 IO 请求(几毫秒)可忽略对高频微任务(几微秒)明显。所以不要为了”分担负载”无脑 spawn——如果某段代码本身只要 10 微秒、spawn 出去反而变慢。spawn 的甜点是”工作量 > 10 微秒”的任务

细节 2:async fn 的状态机大小不容忽视

编译器把 async fn 展开成状态机——这个状态机的 size 等于”跨 await 需要保留的所有 local 变量之和”。如果你在 async fn 里声明了一个 4KB 的数组(比如临时 buffer),即便它只在 await 之前用状态机也可能保留它跨越 await(取决于 NLL 分析)——你 spawn 的每个 task 都平白多占 4KB。

几千个 task 累积起来就是 MB 级别的意外内存占用诊断:用 std::mem::size_of_val(&future) 看具体 Future 多大。:把临时大对象局部化到不跨 await 的块里、或者用 Box 把它放堆上。

细节 3:Arc 的 refcount 原子操作在高并发下也不便宜

Arc::clone() 是一次 fetch_add(1, Relaxed)——单次 ~5 纳秒、看起来很便宜。但如果你的热路径里每个请求 clone 同一个 Arc 50 次、QPS 1 万——每秒 50 万次 CAS 打同一个 cacheline——cache bouncing 让 L1 miss 飙升、实际开销 50-100 纳秒/次、聚合起来秒级消耗 25-50 毫秒 CPU。

在函数入口 clone 一次、后面全用引用;或者把大 Arc 拆成多个 small Arc 分散 cacheline;极端场景上hazard pointerepoch-based 并发原语。

19.7 和其他书的呼应

性能调优是跨语言跨系统的共通话题——Brendan Gregg 的 Systems Performance、Martin Fowler 的 Refactoring for Performance、Java 生态的 Java Performance: The Definitive Guide——都讲了类似原则:先测量、再优化、先算法后底层、关注 P99 而不是 mean、别过度优化。Tokio 场景下的性能调优是这些原则在 Rust async 生态的具体落地。如果你已经有其他语言的性能调优经验、本章的方法论对你来说应该熟悉——只是术语和工具换成了 Tokio 版本。

一个跨语言的共通经验大多数””问题不是因为”用错 runtime 原语”、而是因为”在错误层次做了优化——比如把业务逻辑里 O(n²) 的算法用 Tokio runtime 调优是救不回来的。永远要先分析”哪一层是瓶颈”——业务逻辑?数据结构?runtime 原语?OS 交互?——再在那一层下手。这个方法论跨语言通用。

Vue 3 设计与实现》第 19 章讲过 Vue 的 reactivity 性能调优——批量 flushdependency tracking pruningmemo 等技巧。本质都是”减少不必要的重复工作”——Tokio 的 coop budget 是主动打断重复工作、Vue 的 memo 是缓存避免重复工作——两种方向、同一个目标。

Rust 编译器与运行时揭秘》第 20 章讲过 rustc 的 incremental compilation 和 parallelism——把任务切成合适粒度、用 rayon 并行、避免 cache missTokio 的 worker 调度本质是一个同样的问题在 async 层的翻版——粒度 × 并行度 × 局部性的 trade-off

vLLM 源码剖析里的 continuous batching 与 PD 分离——vLLM 的性能提升核心就是”把 prefill 和 decode 分到不同调度路径”。这就是一个 Tokio-style 的”分 runtime”在 LLM 场景的实例化——把不同特性的工作分桶调度、每桶内部 homogeneous。

19.8 一个要警惕的反模式:性能调优走火入魔

性能调优很容易上瘾——你改了一个参数、benchmark 快了 2%、接着想再找下一个 2%、最后你花了两周时间调出了 10% 的改进。但同样的两周时间用于优化算法或数据结构、可能带来 100% 的改进——调 runtime 参数是”微优化”、优化上层算法才是”宏优化”。永远先优化上层、最后才优化 runtime——这是所有经验工程师的共识。这一节讲的是”什么时候停止调 Tokio 参数、回去改业务代码”。

写完这些技巧、我要反过来提醒你——不是所有 Tokio 项目都需要精细调优

问自己三个问题

  1. 你有明确的 SLA 吗?(比如 p99 < 100ms、QPS > 10k)如果没有——“跑得动就行”、别优化;
  2. 优化的边际收益值得吗?从 p99 200ms 降到 150ms 值得一周工作;从 50ms 降到 45ms 多半不值;
  3. 优化会不会引入新 bug?Unsafe、spinloop、过度 shard——每一个”炫技”都可能在某个边界 case 崩。

最好的 Tokio 代码通常是”看起来最朴素”的——符合 idiomatic、不过度 spawn、不乱用 block_on、锁粒度合理——性能自然就够。反过来——你看到某段代码里充斥着 unsafeunconstrainedspawn_on 特定 worker、AtomicOrdering::Relaxed——多半是过度优化的标志、下一个接手的人会恨你。

追求”够快”而不是”最快”——这是资深工程师的自律。能识别”此处该精细调优”和”此处不该”——比会任何具体调优技巧更值钱。

附加阅读:几条被广泛引用的 Tokio 性能实践结论

整理一下社区里反复讨论、已经形成共识的几条结论、供你当”不用再争论的既定事实”记下:

  • 默认 worker_threads = CPU 核数——几乎从未有人证明”手动改它”在一般场景能带来稳定收益;
  • thread_stack_size 默认 2MB 够了——除非你用到很深的递归 async、否则不要改小(改小了遇到深栈会 stack overflow、故障排查极烦);
  • enable_all() vs 精挑细选——99% 场景直接 enable_all(),只在”确定不会用 time”时才省掉 enable_time 换来一点点 overhead;
  • LocalSet::spawn_local 在单线程 runtime 里比 spawn 快一点(省 Send bound 检查)、但需要整个链路都 Send-free——不值得为这点优化改代码;
  • watch::channel 是 4 种 channel 里最便宜的——每次只保留最新值、receiver 只 notify 一次——做 config 下发、shutdown 信号时优先用它。

这些都是趟过坑的集体智慧——遵守它们能让你省掉至少 80% 的”性能优化走弯路”时间。

19.8½ 一个常被误解的微妙点:Tokio 不是为”超低延迟”设计的

Tokio 的定位是”高吞吐通用 async runtime”——不是”超低延迟交易系统 runtime”。它的 work-stealing、抢占式 coop、微任务调度会带来不可忽略的 tail latency(通常 10-100 微秒量级)。如果你的场景需要亚毫秒级甚至微秒级的 P99(金融交易、高频数据处理),Tokio 可能不是最佳选择——glommio、monoio 等 thread-per-core runtime 更适合。认清 Tokio 的定位能避免把它用在错误场景上。

交代一个容易踩的认知陷阱——Tokio 本身的延迟下限大约是几十微秒级(spawn + 调度 + wake + 再次 poll 整个往返)。如果你的目标是纳秒级延迟(比如 HFT 交易、高频信号处理),Tokio 不是首选——那种场景的选择通常是单线程 spin loop + lock-free queue + 用户态 driver(绕过内核、甚至绕过操作系统调度器)。

Tokio 的设计目标是”微秒级延迟 + 百万级并发——对 99% 的网络服务、RPC 服务、实时通信、数据管道来说足够了。但不要把它用在它不该被用的地方——每个工具都有自己的”主场”、搞清楚主场你才能知道什么时候该换工具。

另一面也要说:Tokio 的延迟虽然不能和裸金属比、但它的”可预测性”非常强——同等机器、同等代码、每次跑出来的延迟分布几乎一致。这在生产运维里比”偶尔极快但偶尔极慢”更有价值——SLA 是按 p99 算的、不是按 p0 算的。稳定的微秒级,远远好过”有时候纳秒但偶尔毫秒”。

19.8¾ 一份调优检查表:生产 Tokio 服务上线前 12 条自检

在 Tokio 服务上线前、过一下这 12 条自检能避免 80% 的生产事故:RuntimeMetrics + Prometheus 接好、tracing 配 JSON + INFO level、bounded channel 而不是 unbounded、所有 spawn 可追踪(JoinSet / AbortHandle)、panic hook 配置、没有跨 await 持 Mutex、没有 async fn 里 block_on、所有 blocking 操作走 spawn_blocking、#[instrument] 加在所有 handler、设置合理 worker_threads / max_blocking_threads、CancellationToken 用于优雅 shutdown、基准测试覆盖核心路径。把这 12 条变成团队 code review 的 checklist。

最后放一份我自己在做 code review 和上线评审时用的检查表。不是所有项都必须满足——但每一项不满足的地方你都要能说出为什么

  1. 没有 std::sync::Mutex 的 guard 被持有跨 await——用 clippy 的 await_holding_lock 可以扫出来;
  2. 没有同步 IO / 同步 syscall 在 async fn 里——除非在 spawn_blocking 里;
  3. 每个 spawn 出去的 task 都有”被跟踪”的办法——JoinHandle 被 await、或者 JoinSet 管着、或者是明确的 fire-and-forget;
  4. 所有 channel 都有 bounded 版本 + backpressure——不要用 unbounded_channel 除非能证明”流量天然有界”;
  5. select! 分支都是 cancel-safe 的——自己写的 async fn 要仔细审、业务逻辑涉及 partial update 时用 tokio::spawn + JoinHandle 代替;
  6. Arc 不在热路径被大量 clone——函数入口一次 clone、后续传引用;
  7. 大对象用 Box<T>Arc<T> 传 channel——不要按值传几 MB 的结构体;
  8. Runtime 的 thread_name 已设置——panic 时堆栈能认出来是哪个 runtime;
  9. panic handler 有 metric / log——spawn 边界包了 catch_unwind 或使用 UnhandledPanic::ShutdownRuntime
  10. tracing 的 span 覆盖关键路径——请求入口、DB 查询、下游调用都有 #[instrument]
  11. 生产 Metrics 至少导出:alive_tasks / blocking_queue_depth / per-worker busy_duration / panic_counter;
  12. 有 graceful shutdown 路径 + 超时——shutdown_timeout 或显式 JoinSet::shutdown().await

把这 12 条贴在显示器边上、每次上线前过一遍——你会发现十有八九的生产事故都是这里某一条没做性能调优”最终不是黑科技、是工程纪律**。

19.9 本章小结

性能是 Tokio 使用的”最后一公里——前 18 章讲的是机制、第 19 章讲的是”怎么把这些机制用好”。一个懂机制但不会调优的工程师能写出 “能跑” 的 Tokio 服务;懂机制又会调优的工程师能写出”快速、稳定、省资源” 的 Tokio 服务——这个差距决定了你是 Tokio 的入门者还是熟练工。

5 个 take-home

  1. 算法先于 runtime 调优——改代码比改参数收益高 10-100 倍、先优化上层。
  2. blocking 是性能 #1 杀手——80% 的 Tokio 性能事故和 blocking 相关。
  3. 10 大陷阱熟记在心——code review 时第一反应就能识别。
  4. tokio-console 是不可或缺的工具——一次设置、终身受益。
  5. Tokio 不是万能的——超低延迟场景另选其他 runtime。

下一章(第 20 章)讲一些常用的 async 编程模式——实战里怎么用 Tokio 的原语拼成更大的 pattern。性能 + 模式 + 前面所有机制知识、读完全书你对 Tokio 就有完整的掌控感。

写在本书最末Tokio 不是一个容易掌握的工具——读到这里的你已经把整个 runtime 的机制、API、最佳实践、陷阱、调优看了一遍——这已经把你推到 Rust async 生态里极少数”真正懂 Tokio”的位置。带着这份掌控力去写 Rust async 代码、你写出的服务会有明显不同——更稳、更快、更易调试——这是本书最终想给你的礼物。

带走三件事:

  1. Tokio 自带的公平性机制(coop budget = 128)和性能取舍(LIFO slot)每时每刻在工作——理解它们才能写出 Tokio 习惯的代码、不和 runtime 对着干
  2. 性能问题要分象限:吞吐 / 延迟抖动 / 资源异常——诊断手段不同、修复路径不同。按流水线走:基线 → Metrics → 火焰图 → console → 修复 → 验证
  3. 10 大生产陷阱覆盖 90% 的真实 bug——背下来、code review 时一条条对——比任何花哨调优都值钱。性能调优是纪律、不是灵感

下一章(最后一章)进入设计模式与架构决策——把前 19 章的所有知识收束成一些可复用的设计决策框架:什么时候该 spawn、什么时候该直接 await、channel vs Mutex 如何选、retry / timeout / cancel 如何组合——让你写出”看起来就是对的” Tokio 代码


延伸阅读