Skip to content

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

"让 Tokio 跑快不难,不让它跑慢才难。" —— 笔者

本章要点

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

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

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

打开 tokio/src/runtime/coop.rs

rust
#[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 机会。

为什么需要这个机制

考虑一个反例:

rust
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

极少数场景你真的想让某个 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:局部性之刀的双刃

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

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

rust
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 不能被其他 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 的局部性收益大于其代价;
  • 如果你做了很多"spawn 完立刻 await 父子 pattern"——LIFO 对你最有利;
  • 如果你做"spawn 完丢一边不管"的场景——LIFO 其实没用,而且有极端情况会拖慢 steal。目前 Tokio 没提供关 LIFO 的 API、只能接受。

19.3 火焰图在 async 里的读法

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

特征 1:poll 函数占比极高

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

特征 2:parkunpark 要看比例

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

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

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

特征 4:syscall 比例

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

特征 5:memcpy / drop 不要忽视

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

19.4 10 大生产陷阱

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

rust
// ⚠️ 反面
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 章)。症状:延迟抖动、CPU 利用率只有 1/worker_threads、flamegraph 上显示 syscall 或 CPU 密集函数占一整块。

spawn_blocking 或 Rayon。

陷阱 3:无 backpressure 的 tokio::spawn

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

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

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

陷阱 4:未 pin! 的 stream

rust
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 章详细讲过。某个分支里的 .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

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

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

陷阱 7:spawn_blocking 跑 CPU 密集

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

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

陷阱 8:waker 反复 register

自己写 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! 传递

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

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

陷阱 10:单 runtime 跑混合负载

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

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

19.5 一套完整的排障流水线

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

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:生成火焰图

bash
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

分享一个我亲历的优化案例——一个 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: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 和其他书的呼应

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 一个要警惕的反模式:性能调优走火入魔

写完这些技巧、我要反过来提醒你——不是所有 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 本身的延迟下限大约是几十微秒级(spawn + 调度 + wake + 再次 poll 整个往返)。如果你的目标是纳秒级延迟(比如 HFT 交易、高频信号处理),Tokio 不是首选——那种场景的选择通常是单线程 spin loop + lock-free queue + 用户态 driver(绕过内核、甚至绕过操作系统调度器)。

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

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

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

最后放一份我自己在做 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 本章小结

带走三件事:

  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 代码


延伸阅读

基于 VitePress 构建