Tokio 源码深度解析
第19章 性能调优与典型陷阱
本章要点
- coop budget = 128:每个 task 进入 poll 时拿 128 个"点"、每次
await一个 Tokio 原语扣 1 点——扣完强制 yield。防止 hot task 饿死别人 - LIFO slot 是把双刃剑:提升父子 task 局部性、但 slot 里的 task 偷不走——task 的 poll 时间偏长时反而可能拉低总吞吐、拉高调度等待
- 火焰图在 async 里看什么:关注 park/unpark 的占比、poll 函数栈深度、mpsc send/recv 有没有争用——和同步世界完全不同的 pattern
- 10 大生产陷阱:std::sync::Mutex 跨 await、阻塞在 worker、无 backpressure 的 spawn、未 pin 的 stream、select! 的 cancel safety、JoinSet 泄漏、spawn_blocking 用来做 CPU 密集、poll 里无条件 wake 自己、大对象传递、单 runtime 跑混合负载
- 排障流水线:压测复现 → Metrics 定位象限 → flamegraph 找热点 → tokio-console 看 live → 针对修复
19.0 性能问题的分类
Tokio 服务的性能问题可以分成四类、每类对应不同的诊断和修复方法:(1)吞吐量问题(QPS 上不去)、(2)延迟问题(P99 / P999 太高)、(3)资源问题(CPU 或内存占用异常)、(4)稳定性问题(性能偶尔剧烈抖动)。本章按这四类展开——每类给出典型原因、诊断方法、修复模板。遇到真实性能问题时先问自己"这是哪一类"、再对照相关章节——比盲目调 runtime 参数高效得多。
这四类问题的先后顺序也有讲究——大多数情况下从(4)稳定性开始查:稳定性问题往往会掩盖其他问题——如果性能忽上忽下你根本没法判断平均 QPS 是否合理。先解决稳定性、再看 QPS / 延迟 / 资源。这种"先稳定再优化"的顺序是老工程师的习惯——年轻人容易上来就盯 QPS 数字、最后发现先要去解决偶发抖动。
上面四类里、前三类的诊断手段完全不同、值得单独拆开看:
- 吞吐不够:QPS 上不去、CPU 没打满——多半是调度 overhead 过高或存在同步锁竞争;
- 延迟抖动:p50 很低但 p99 飙高——多半是某些 task 偶发堵住 worker或GC-like scheduler pause;
- 资源异常:内存稳定增长 / blocking 线程暴涨 / fd 泄漏——多半是task 泄漏或错配线程池。
这三类问题的排查顺序、观察指标、修复手段都不一样。最怕的就是"不分象限、见山拆山"——修了半天发现瞄错了目标。本章带你走一遍完整流程。
19.1 coop budget:每次 poll 128 点预算,耗尽即让步
coop budget 是 Tokio 内置的公平性机制——每次 poll 开始时给 task 发 128 点预算,task 在这一次 poll 里每做一次 Tokio 资源操作(channel recv、IO read、sleep、Semaphore::acquire 等)就扣 1 点,扣光就强制让出 worker、避免 long-running task 霸占 worker 饿死其他 task。这个机制对用户几乎完全透明——但理解它能帮你回答一些诡异现象:"为什么我的 loop-heavy task 每 128 次会有微小抖动"、"为什么空转的 recv 怎么扣都扣不完预算"(因为没取到数据就不算数——见下文的 RestoreOnPending)。这是 Tokio 为了公平性主动付出的一点点公平税——绝大多数场景下几乎看不到影响、但偶尔能帮你理解诡异的性能曲线。
这是 Tokio 隐藏很深的一个机制——你写了几年 Tokio 都不一定接触到它。但一旦你开始做性能调优、这个机制会浮出水面:为什么我的 benchmark 的 P99 周期性抖动?为什么 hot path 上每 100 多次 await 有一个小停顿?为什么 budget_forced_yield_count 这个 metric 在上涨?——这些问题都要从 coop budget 理解起。
打开 tokio/src/runtime/coop.rs(本章锚定 1.40.0;这个文件在 1.51.1 已迁到 tokio/src/task/coop/mod.rs,预算仍是 128,并把 poll_proceed、has_budget_remaining 等开放成了 tokio::task::coop 下的公开 API)。下面是节选,poll_proceed 的函数体用注释概括:
#[derive(Debug, Copy, Clone)]
pub(crate) struct Budget(Option<u8>);
impl Budget {
/// Budget assigned to a task on each poll.
const fn initial() -> Budget {
Budget(Some(128))
}
/// Returns an unconstrained budget. Operations will not be limited.
pub(super) const fn unconstrained() -> Budget {
Budget(None)
}
}
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 点;扣不动了就 cx.waker().wake_by_ref() 再返回 Pending
}
关键数字:128。源码注释写的是 "Budget assigned to a task on each poll"——每一次 poll 开始时重新发 128 点。扣到 0 后——即便底层数据已经 Ready、poll_proceed 也会先 cx.waker().wake_by_ref()、再返回 Poll::Pending:task 让出当前这一轮 poll、同时立刻把自己重新排回队列——是"让一步"、不是"被挂起"。
哪些原语会扣点?别按感觉猜——1.40.0 里直接调 poll_proceed 的地方一共就十几处,最常撞上的是 mpsc(tokio/src/sync/mpsc/chan.rs:295)、I/O readiness(tokio/src/runtime/io/registration.rs:151)、sleep(tokio/src/time/sleep.rs:414)、Semaphore(tokio/src/sync/batch_semaphore.rs:598)、oneshot(tokio/src/sync/oneshot.rs:797)和 JoinHandle(tokio/src/runtime/task/join.rs:332)。Notify::notified 不在其中——Notify 的实现里没有任何 coop 调用点,用它写的自唤醒循环不会被 coop 打断。
而且扣掉的那 1 点不一定算数:poll_proceed 返回的是一个 RestoreOnPending 守卫,只有原语真正取得进展时才调 made_progress();没进展就返回 Poll::Pending、守卫在 drop 时把预算原样还回去(tokio/src/runtime/coop.rs:150)。所以"从空 channel recv 一次"这种白跑根本不扣预算——tokio/src/sync/mpsc/chan.rs:334 那一段只在拿到值或确认关闭时才 made_progress。预算只被"真做成了事"的操作消耗,这也是 128 这个数能当"每 poll 做 128 件事"来读的原因。
为什么需要这个机制
如果没有 coop budget——一个极端情况:一个 task 在 loop { rx.recv().await } 里无限循环从满 channel 里 recv——recv 总是立刻 Ready、task 永远不让出、worker 上其他 task 饿死。这种情况不需要代码错、也不需要死循环——只要"输入快到处理不完"就会发生。coop budget 强制这一次 poll 里做满 128 次资源操作就让出一次——即使 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 强制这个循环每成功 recv 128 次(每次扣 1 点预算)就让出一次 CPU——哪怕 mpsc 里有数据。看起来是"故意慢下来"、实际是以微小的吞吐让步换取调度公平性。
绕过 coop:unconstrained
Tokio 提供 tokio::task::unconstrained(future) API——把一个 Future 标记为"不受 coop 限制"、这个 future 及其子 future 里的 Tokio 原语不再扣 budget、不会被 coop 强制让出。几乎不应该用——官方文档的警告是:如果这个 future 自己从不让出,你的服务就暴露在饿死风险之下(tokio/src/task/unconstrained.rs:37-39)。Tokio 自己只在极少数确定不会长时间占用 worker 的地方用它,比如 JoinSet::try_join_next 收割已完成的 task 时(tokio/src/task/join_set.rs:320)。所以只有两种情况可以考虑它:你能确认这个 future 自己会定期让出;或者你确实要让它独占一个 worker——那就把它放到一个专用 runtime(或专用线程)上跑。看到代码里用了 unconstrained、要花时间读懂为什么——大概率是过早优化或误用。
你能观察到 coop 吗
第 17 章讲过——RuntimeMetrics::budget_forced_yield_count()(1.40.0 里它包在 cfg_unstable_metrics! 中、需要 RUSTFLAGS="--cfg tokio_unstable" 才编得出来)。计数点就在 poll_proceed 里 budget 恰好扣到 0 的那一次。这个数持续上升意味着你有hot task 在吃满 worker、coop 在救场。如果它 = 0、说明要么你的 task 都很 chill、要么你根本没打开过这个指标。
19.2 LIFO slot:局部性之刀的双刃
LIFO slot 是一把双刃剑——大多数场景下它给你巨大性能收益(缓存热、调度开销省)、但在少数场景下它反而会成为 bottleneck。典型负面场景是"父 spawn 子之后不立即 await"——这种情况下 LIFO slot 里的 task 不一定更紧急,而 LIFO slot 里的 task 偷不走:它所在的 worker 正忙时,别的 worker 空着也帮不上忙,这些 task 的调度等待(scheduled 时间)就可能变高。理解 LIFO 的双刃特性能让你在 profile 里识别这种反模式。
第 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",而 LIFO slot 里的 task 又偷不走,排在后面的 task 调度等待被拉长。Tokio 对连续续跑 LIFO 有两道闸:一是 coop budget——run_task 里续跑 LIFO 的循环每轮都查 coop::has_budget_remaining()、budget 没了就把 LIFO 里的 task push_back_or_overflow 回本地队列;二是 tokio/src/runtime/scheduler/multi_thread/worker.rs:241 的 const MAX_LIFO_POLLS_PER_TICK: usize = 3(判定在同文件 :653)——同一轮里连续走 LIFO 最多 3 次、超过就把 lifo_enabled 置 false(源码注释点名这是为了对付 A 唤醒 B、B 又唤醒 A 的 ping-pong 负载)。识别这种场景要看 task 的调度等待时间(从被唤醒到真正被 poll)——如果老 task 的调度等待持续拉长、而 runtime 并不忙,就要怀疑 LIFO;disable_lifo_slot 的文档给的判据正是"task 的 scheduled 时间高、但 runtime 利用率不高",并建议用 tokio-metrics 采这组数据(tokio/src/runtime/builder.rs:995-997)。
LIFO slot 里的 task 不能被其他 worker steal——因为 steal 走了就破坏了局部性。这意味着:当某个 worker 正在 poll 的 task 迟迟不返回(比如它阻塞了)、这个 worker 的 run queue 里排队的 task 还能被别的 worker 偷走、LIFO slot 里等着的那个却偷不走,只能干等。
第 16 章讲的 block_in_place 所以要主动把 LIFO slot 里的 task 推回 run queue(tokio/src/runtime/scheduler/multi_thread/worker.rs:417,那段注释的理由就是:LIFO slot 里的 task 是唯一偷不走的,所以让核之前先把它挪回 run queue)——就是怕这个反噬。
实务建议
实务中关于 LIFO 的建议很简单:默认开着、有问题再关。绝大多数场景 LIFO 是净正收益——只在做深度性能调优、数据显示 task 的调度等待明显偏高而 runtime 并不忙时、才考虑用 Builder::disable_lifo_slot()(unstable)关闭 LIFO slot。这是 Tokio 参数里一个"不要轻易动"的参数——默认值是经验优化结果、改不如不改。
- 一般场景信任默认行为——LIFO 的局部性收益大于其代价;
- 如果你做了很多"spawn 完立刻 await 父子 pattern"——LIFO 对你最有利;
- 如果你做"spawn 完丢一边不管"的场景——LIFO 其实没用,而且有极端情况会拖慢 steal。这时可以用
Builder::disable_lifo_slot()(1.21.0 引入、PR #4936)把它关掉——它包在cfg_unstable!里、需要--cfg tokio_unstable;Tokio 自己的文档也说这是"LIFO slot 不可被 steal"的权宜之计,见 issue #4941。
19.3 火焰图在 async 里的读法
火焰图是性能调优的经典工具——但在 async 代码里读火焰图和同步代码很不同。关键区别:async fn 被编译成状态机、火焰图里 async fn 的函数体往往显示成 my_crate::handler::{{closure}} 这种闭包形式的符号、而不是 handler 本身;调用栈在 await 点会"断开"(await 让出后 poll 是下次被调度时从头重新开始);spawn 的 task 和调用者的栈完全断开——火焰图里它们是独立的调用栈。理解这些特性能让你正确解读 async 火焰图、避免 "看不出问题在哪" 的挫败感。
推荐两条路:perf + Brendan Gregg 的 FlameGraph 脚本(或 inferno / cargo-flamegraph)是通用方案;pprof-rs 是纯 Rust 的进程内采样方案、不依赖外部 perf 权限。两者都能缓解"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::poll、impl Future for yyy::poll 层层嵌套。这不是 bug——async/await 在编译期就是这样展开的——生成的状态机每层都有 poll。火焰图比同步代码明显更深是正常现象——深多少取决于你嵌套了几层 async fn 和 combinator。
特征 2:park 和 unpark 要看比例
先分清采样口径:perf record -F 默认只采 on-CPU 样本,worker 真正睡在 epoll_wait / futex 里时不占 CPU、根本不会被采到——要看"等"的比例,得用 off-CPU 或按墙钟采样的火焰图;on-CPU 图里 park 的宽度只反映进出 park 本身的开销。在墙钟 / off-CPU 口径下,park / unpark 的比例告诉你 runtime 的"忙闲比"——park 占比高说明 worker 大部分时间在等事件(IO bound 或负载不足);park 占比低说明 worker 忙到几乎不休息(CPU 饱和)。这个比例没有放之四海皆准的"标准值"——它只有和你自己服务的历史基线对比时才有意义:同样的流量下 park 占比突然掉下去,说明 worker 开始被 CPU 工作填满了。
tokio::runtime::scheduler::multi_thread::park::Parker::park——多线程 runtime 的 worker 在睡觉(多线程 runtime 上 block_on 所在的那条线程走的是 tokio::runtime::park);Unparker::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——说明有竞争或调度乒乓、要回去找原因。
bounded mpsc 的 send 如果 buffer 满了会挂起、火焰图上会看到它在 batch_semaphore 的 Acquire 上 poll(AtomicWaker 是 recv 端用的,即 tokio/src/sync/mpsc/chan.rs:57 的 rx_waker)——这本身不耗多少 CPU、但如果持续出现说明buffer 小了或consumer 慢了。
特征 4:syscall 比例
火焰图里syscall 占比告诉你 runtime 和 OS 的交互频率——epoll_wait、read、write、clock_gettime——都是 syscall。没有通用的"合理比例"——太低说明 worker 几乎没做 IO(可能是纯 CPU 任务、不是 Tokio 典型场景);明显高于你自己服务的历史基线、说明 syscall 频率过高、可能要 batch 或换更高效的 IO 模型(io_uring)。
Linux 下 epoll_wait 通常是占比最高的 syscall。如果 syscall 整体占比明显高于你的历史基线——说明单个 task 干的活太少、syscall overhead 摊不动——考虑 batching。
特征 5:memcpy / drop 不要忽视
火焰图里大块的 memcpy 或 Drop 调用是容易被忽视的性能杀手。memcpy 通常来自大数据结构的拷贝(比如把一个大 struct 按值 send 进 channel、就是一次整体 memcpy);Drop 通常来自复杂结构的释放(比如嵌套深的 Vec<Vec<...>>)。解决办法:用 Arc 共享而不是 clone、用 Box 分配大对象而不是嵌入、batch 处理而不是逐个 drop。
Rust 默认不 clone、但 async move 闭包捕获大对象会发生——火焰图里突然冒出一大片 memcpy 或 drop_in_place——说明你在到处搬大结构体。改用 Arc 共享、或 Box 包起来。
19.4 10 大生产陷阱
本节总结 Tokio 真实生产里最常遇到的 10 种性能陷阱——每一种都有具体症状、诊断方法、修复模式。这 10 种陷阱覆盖了真实生产里绝大多数 Tokio 性能事故——熟悉它们你能在问题出现时快速定位:std::sync::Mutex 跨 await、阻塞调用直接跑在 worker 上、无 backpressure 的 spawn 风暴、未 pin 的 stream、select! 分支不 cancel-safe、JoinSet 只 spawn 不 join、拿 spawn_blocking 跑 CPU 密集、poll 里无条件 wake 自己、大对象按值走 channel、单 runtime 跑混合负载。每种陷阱在下面详讲。
陷阱 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
后果:guard 在整个 .await 期间一直被持有——别的 task 想拿同一把锁只能用阻塞式的 lock()、把整个 worker 线程堵住、这条 worker 上排队的其他 task 跟着一起卡。另外 std::sync::MutexGuard 是 !Send(library/std/src/sync/poison/mutex.rs:289 一行 impl<T: ?Sized> !Send for MutexGuard<'_, T> {})——guard 跨 .await 会让生成的 Future 变成 !Send、多线程 runtime 上 tokio::spawn 直接编译不过;真能编过的写法(current_thread / LocalSet / block_on)里更糟:同一线程上另一个 task 去 lock 同一把锁,会把唯一的线程堵死、持锁的 task 再也轮不到 poll、锁永远不释放——直接死锁(同一个 task 自己再 lock 也一样,std Mutex 不可重入)。
修:用 tokio::sync::Mutex(它的 lock 本身是 async、可以 await)、或把同步访问缩小到不跨 await({ let data = SHARED.lock().unwrap(); ... } 短作用域)。
陷阱 2:阻塞调用直接在 worker 上跑
第 16 章反复讲过的陷阱——症状:worker busy_ratio 异常高、某类请求 P99 飙升;诊断:tokio-console 看到某个 task 的 poll duration 长达几百毫秒;修复:把 blocking 调用包进 spawn_blocking 或用 async 版本。真实项目里相当大一部分 "Tokio 慢" 的报障最后都能追到这个——找到了就改、改了就好。
第 16 章讲过。症状:延迟抖动、CPU 利用率上不去、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
Stream::poll_next 的 receiver 是 Pin<&mut Self>,而 tokio_stream::StreamExt::next 是 fn next(&mut self) -> Next<'_, Self> where Self: Unpin(tokio-stream/src/stream_ext.rs:144)——所以 stream 不是 Unpin 时、编译器会强制你先 pin! 或 Box::pin。pin! 在栈上 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! 取消、状态可能半更新——比如分支里先 map.insert(k, v)、再 .await 一次写库——写库这一步被取消,内存里的 map 已经改了、库里却没有。
修:select! 的每个分支只能是 cancel-safe future。Tokio 的原语大多在文档里写明了是否 cancel-safe(mpsc::Receiver::recv 是,AsyncReadExt::read_exact 不是)、用前要查;业务 async fn 默认不 safe。复杂业务要 select 等、用 tokio::spawn + JoinHandle await 代替直接 select——select! 放弃这个分支时只是 drop 掉 JoinHandle、task 本身照常跑完(drop JoinHandle 不会 abort task),所以不会产生半更新。
陷阱 6:JoinSet 只 spawn 不 join
如果你用 JoinSet spawn 但从来不 join_next——task 完成后的 output 和 error 会一直堆在 JoinSet 里、形成内存泄漏。症状:慢速的内存增长、eventually OOM;修复:要么 regularly drain(循环 while joinset.join_next().await)、要么用 try_join_next 定期非阻塞地收割已完成的 task(abort_all 只 abort、不会把 task 移出 JoinSet,见 tokio/src/task/join_set.rs:452-453)。
第 15 章讲过。只 spawn 不 join_next / try_join_next,完成的 task 的 output 就一直挂在 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:poll 里无条件 wake 自己
自己写 Future 时的常见陷阱——poll 里无条件调 cx.waker().wake_by_ref(),等于每次返回 Pending 都立刻把自己重新排回队列、形成 busy loop。症状:worker_poll_count 暴涨、CPU 打满而毫无进展;修复:只在"确实可能有进展"时才 wake,waker 的保存交给 AtomicWaker 这类现成工具(它自带替换旧 waker 的语义)。自己写 Future 时 waker 管理是最容易出错的地方——能用现成 utility 就别自己写。
自己写 Future 时经常犯——poll 里无条件调 cx.waker().wake_by_ref()(或 cx.waker().clone().wake())然后返回 Pending——相当于立刻把自己 schedule 回 run queue、形成 busy loop。
症状:worker_poll_count 涨得飞快、CPU 100%。
修:只在"真正能有进展时才 wake"——比如 channel 里有新数据、IO ready。poll 返回 Pending 但不 wake = 挂起等 external trigger、这才是对的。
陷阱 9:大对象通过 channel/select! 传递
直接 send 大对象通过 channel 会触发 memcpy——高并发下这个 memcpy 累积起来开销可观。正确姿势:发 Arc<BigObject> 而不是 BigObject——Arc clone 只是一次对 refcount 的原子加,开销与对象大小无关;memcpy 则随对象大小线性增长。差距有多大取决于对象多大:几 KB 时是常数 vs 线性的区别,到 MB 级才谈得上数量级。这种 "用 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 里定位到少数几个;(2)runtime 组件缩到"哪个子系统"——IO / Scheduler / Blocking 3 选 1;(3)task 级细节锁定"哪个 task"——具体到某个 task id;(4)hot path 找出"哪行代码"——最终定位到几十行代码里的一个问题。如果你尝试跳过某一步、后续会很难走——每一步都是下一步的过滤器。
| 观察到的症状 | 第一工具 | 第二工具 | 优先排查方向 |
|---|---|---|---|
| p99 抖动但 CPU 不高 | metrics | tokio-console | task 长时间不被 poll、锁等待 |
| CPU 打满且吞吐不上升 | flamegraph | perf / eBPF | hot loop、序列化、拷贝、drop 风暴 |
| alive tasks 持续上涨 | runtime metrics | trace span | spawn 泄漏、JoinSet 未回收 |
| channel 延迟升高 | tracing | flamegraph | 队列无背压、send/recv 等锁 |
| blocking pool 排队 | metrics | thread dump | 阻塞 IO 或 CPU 任务放错池 |
综合前面所有章节、我给你一套"遇到问题该走的路径":
Step 1:建压测基线
没有基线不谈调优。用 wrk / k6 / vegeta 压测、记录 QPS / p50 / p99 / p999。
关键:压测时间要够长——至少 5 分钟、观察是否有周期性抖动;并发梯度要覆盖——从 1 并发到 10x 预期并发、找 knee point。
Step 2:打开 Metrics + tracing
第 17 章讲过。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的 spawn 位置(console 不给调用栈)。
Step 5:针对性修复 + 对比验证
修完一个问题再测——别一次改 5 个地方。每次修改后重跑压测、对比基线。如果某次修改没效果、立刻回滚——因为意味着你改错了靶子。
最忌讳:一边改一边猜、最后代码一团糟性能没变。
19.6 一个完整的案例(数字为示意)
看一个优化案例——一个 Web 服务做证书签发(CPU + 网络)、原 QPS 3k、p99 800ms。下面所有数字都是示意:没有记录可复现的测量环境,只用来说明"改动方向和收益大致的量级",不是基准、也不构成对 Tokio 的性能承诺。
第一轮:打开 Metrics 发现 blocking_queue_depth > 100——有大量 spawn_blocking。定位到代码是签名用了 RSA-2048、每次都是毫秒级的纯 CPU 计算——典型陷阱 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 周——每一步都有数据支撑、每一次修改都验证。这就是"按流水线走"和"瞎改"的区别。
从这个案例提炼两个经验:(1)不要只改一个问题就停下——真实系统的性能问题往往是"几个小问题叠加"、单独看都不起眼、合起来才拖住系统;把排障中发现的问题都记下来逐个修、每修一个复测一次,才知道哪个贡献了多少;(2)压测场景要尽量接近生产——很多问题只在特定 request mix 下出现、压测用单一请求类型可能完全看不到。
19.7 性能调优里另外几个少人讲但重要的细节
这一节讲其他专栏和文档里很少提到的几个调优细节——这些都是真实生产里见过的、值得记住的。其一:先搞清楚默认值是怎么来的再决定要不要动 worker 数——默认是 std::thread::available_parallelism()(tokio/src/loom/std/mod.rs:103),而它在 Linux 上已经会读 cgroup 的 CPU quota 并向下取整钳住(library/std/src/sys/thread/unix.rs:165 调 cgroups::quota(),实现见同文件 :795),所以"容器里起了一堆 worker"这个老问题在现代 std 上大多已经不成立(cpuset 限制则通过同处的 sched_getaffinity 反映出来);真正还需要手动设的是 quota 不足一核(.max(1) 兜底成 1)、或想给同机多个 runtime 分配额度的场景——手动设可以用 Builder::worker_threads 或 TOKIO_WORKER_THREADS 环境变量(同上 tokio/src/loom/std/mod.rs:89-91);其二:留意 async fn 状态机的体积——跨 await 存活的局部变量都会进 Task 的那次堆分配,几千个 task 累积起来是可观的内存(和本地队列容量无关:队列里存的是指针);其三:async trait 的动态分派开销——先用数量级判断值不值得动:一次间接调用是纳秒量级(多一次指针解引用,外加可能的分支预测失败与丢掉内联),而一次真落到内核的 I/O 系统调用在百纳秒到微秒量级,差两三个数量级(量级估算)。所以「每次 poll_read 都真的去读 socket」的路径上换掉 dyn AsyncRead 基本量不出差别(《Rust 编译器与运行时揭秘》专栏 §8.10.3 讲的就是这条:方法体本身够慢时,间接调用开销可忽略)。值得换成具体类型的是另一种 hot path——poll_read 大多数时候命中用户态缓冲区、根本不进内核就返回(BufReader 命中、帧解析器反复做小量读),且每秒调用次数在百万量级:这时单次调用本身就在纳秒量级,丢掉内联才真的显出来。先 profile 出 poll_read 自身的耗时占比再动手,别凭"这是 hot path"就换;其四:allocator 的选择很重要——高并发多线程分配场景下换 jemalloc / mimalloc 常有肉眼可见的收益,但收益多少必须在你自己的负载上测。
细节 1:tokio::spawn 本身不是零成本
很多人以为 tokio::spawn(async {...}) 是白菜价。实际上一次 spawn 包含:
- 分配 Task struct(Header + Core + Trailer)——一次堆分配,大小随 future 而定:
spawn(async {})恰好一次 128 字节,一个跨 await 持有 4KB 数组的 future 是一次 4224 字节(tokio 1.40.0 + rustc 1.96.0、x86_64 Linux、release 构建,用计数 allocator 实测); - 注册到
OwnedTasks(tokio/src/runtime/task/list.rs:58,内部是分片的ShardedList——tokio/src/util/sharded_list.rs:16的Box<[Mutex<LinkedList<L, T>>]>)——一次分片加锁 + 一次链表插入; - schedule 到 run queue(本地 queue 或全局 inject)——几个原子操作;
- 创建 JoinHandle——几个字节但要配 Atomic refcount。
合起来是"一次堆分配 + 一次分片加锁 + 几个原子操作"的量级——对一次毫秒级的 IO 请求可以忽略、对微秒级的高频微任务就未必。具体开销随机器、allocator、争用程度变化很大,要在你自己的环境上用 cargo bench 量,不要照抄任何二手的纳秒数字。结论只有一句:不要为了"分担负载"无脑 spawn——某段代码本身就极短的话,spawn 出去很可能更慢。
细节 2:async fn 的状态机大小不容忽视
编译器把 async fn 展开成状态机——这个状态机的 size 由"跨 await 需要保留的 local 变量"决定(不同 await 点各自保留的变量可以复用同一块空间,并非简单相加)。如果你在 async fn 里声明了一个 4KB 的数组(比如临时 buffer),即便它只在 await 之前用、状态机也可能保留它跨越 await(取决于编译器的存活分析:它被借用过、作用域又延伸到 await 之后,就会被保留——rustc 1.96.0 实测这种写法 size_of_val 为 4098 字节,把数组收进 await 之前的 {} 块后只剩 2 字节)——你 spawn 的每个 task 都平白多占 4KB。
几千个 task 累积起来就是 MB 级别的意外内存占用。诊断:用 std::mem::size_of_val(&future) 看具体 Future 多大。修:把临时大对象局部化到不跨 await 的块里、或者用 Box 把它放堆上。
细节 3:Arc 的 refcount 原子操作在高并发下也不便宜
Arc::clone() 只是一次 fetch_add(1, Relaxed)——单看指令确实便宜。但如果热路径里每个请求都 clone 同一个 Arc 几十次、并发又高——大量原子加打在同一条 cache line 上、cache line bouncing 会让实际开销远高于指令本身。放大多少取决于核数和 CPU 拓扑,必须自己 benchmark——但"把热路径上的重复 clone 消掉"这个方向在任何机器上都成立。
修:在函数入口 clone 一次、后面全用引用;或者把大 Arc 拆成多个 small Arc 分散 cacheline;极端场景上hazard pointer 或 epoch-based 并发原语。
19.8 和其他专栏的呼应
性能调优是跨语言跨系统的共通话题——Brendan Gregg 的 Systems Performance、Scott Oaks 的 Java Performance: The Definitive Guide——都讲了类似原则:先测量、再优化、先算法后底层、关注 P99 而不是 mean、别过度优化。Tokio 场景下的性能调优是这些原则在 Rust async 生态的具体落地。如果你已经有其他语言的性能调优经验、本章的方法论对你来说应该熟悉——只是术语和工具换成了 Tokio 版本。
一个跨语言的共通经验:大多数"慢"问题不是因为"用错 runtime 原语"、而是因为"在错误层次做了优化"——比如把业务逻辑里 O(n²) 的算法用 Tokio runtime 调优是救不回来的。永远要先分析"哪一层是瓶颈"——业务逻辑?数据结构?runtime 原语?OS 交互?——再在那一层下手。这个方法论跨语言通用。
《Vue 3 设计与实现》专栏第 18 章讲过 Vue 的 性能工程实践——静态提升、PatchFlag 精确追踪、v-memo 等技巧。本质都是"减少不必要的重复工作"——Tokio 的 coop budget 是主动打断重复工作、Vue 的 memo 是缓存避免重复工作——两种方向、同一个目标。
《Rust 编译器与运行时揭秘》专栏第 17 章讲过 rustc 的 增量编译——把编译切成细粒度的查询、按依赖追踪只重做必要的部分。粒度选择的道理在 Tokio 里同样成立——task 切得太细、调度开销压过收益,切得太粗、又丢掉并发和局部性——粒度 × 并行度 × 局部性的 trade-off。
《vLLM 推理内核深度解析》专栏里的 continuous batching 与 PD 分离——其中 PD 分离的思路就是"把 prefill 和 decode 分到不同调度路径"。这就是一个 Tokio-style 的"分 runtime"在 LLM 场景的实例化——把不同特性的工作分桶调度、每桶内部 homogeneous。
19.9 一个要警惕的反模式:性能调优走火入魔
性能调优很容易上瘾——你改了一个参数、benchmark 快了 2%、接着想再找下一个 2%、最后你花了两周时间调出了 10% 的改进。但同样的两周时间用于优化算法或数据结构、可能带来 100% 的改进——调 runtime 参数是"微优化"、优化上层算法才是"宏优化"。永远先优化上层、最后才优化 runtime——这是所有经验工程师的共识。这一节讲的是"什么时候停止调 Tokio 参数、回去改业务代码"。
写完这些技巧、我要反过来提醒你——不是所有 Tokio 项目都需要精细调优。
问自己三个问题:
- 你有明确的 SLA 吗?(比如 p99 < 100ms、QPS > 10k)如果没有——"跑得动就行"、别优化;
- 优化的边际收益值得吗?从 p99 200ms 降到 150ms 值得一周工作;从 50ms 降到 45ms 多半不值;
- 优化会不会引入新 bug?Unsafe、spinloop、过度 shard——每一个"炫技"都可能在某个边界 case 崩。
最好的 Tokio 代码通常是"看起来最朴素"的——符合 idiomatic、不过度 spawn、不乱用 block_on、锁粒度合理——性能自然就够。反过来——你看到某段代码里充斥着 unsafe、unconstrained、满屏的 Ordering::Relaxed——多半是过度优化的标志、下一个接手的人会恨你。
追求"够快"而不是"最快"——这是资深工程师的自律。能识别"此处该精细调优"和"此处不该"——比会任何具体调优技巧更值钱。
附加阅读:几条被广泛引用的 Tokio 性能实践结论
整理一下社区里反复讨论、已经形成共识的几条结论、供你当"不用再争论的既定事实"记下:
- 默认 worker_threads =
available_parallelism()(tokio/src/loom/std/mod.rs:103,Linux 上已按 cgroup quota 钳过)——一般场景下手动改它很少带来稳定收益,改之前先拿数据;要改也优先用TOKIO_WORKER_THREADS环境变量,免得把部署差异写死进代码; thread_stack_size默认根本不设(Builder里它是Option<usize>、默认None)——直接沿用std::thread的默认栈大小;除非你用到很深的递归 async、否则不要改小(改小了遇到深栈会 stack overflow、故障排查极烦);enable_all()vs 精挑细选——绝大多数场景直接enable_all(),只在"确定不会用 time"时才省掉enable_time、省下一点点 overhead;LocalSet::spawn_local的价值不是"更快"——Send是编译期约束、不产生任何运行时开销——而是让你能 spawn!Send的 future;不要为了"性能"把代码改成 LocalSet;watch::channel做 config 下发、shutdown 信号时优先用它——它只保留最新值、不排队积累消息,晚订阅的 receiver 也能立刻读到当前值(broadcast 做不到);但别把它当成"最便宜的 channel"——选 channel 要按语义选,见第 20 章 20.6。
这些都是趟过坑的集体智慧——遵守它们能让你省掉大量"性能优化走弯路"的时间。
19.10 一个常被误解的微妙点:Tokio 不是为"超低延迟"设计的
Tokio 的定位是"高吞吐通用 async runtime"——不是"超低延迟交易系统 runtime"。它的 work-stealing、协作式 coop 让步、微任务调度都会带来不可忽略的 tail latency。如果你的场景需要亚毫秒级甚至微秒级的 P99(金融交易、高频数据处理),Tokio 可能不是最佳选择——glommio、monoio 等 thread-per-core runtime 更适合。认清 Tokio 的定位能避免把它用在错误场景上。
交代一个容易踩的认知陷阱——"spawn → 调度 → wake → 再次 poll"这个往返本身就有一个固定成本,它是 Tokio 延迟的下限(具体量级请在你自己的机器上测,别信任何二手数字)。如果你的目标是极致的确定性低延迟(比如 HFT 交易、高频信号处理),Tokio 不是首选——那种场景的选择通常是单线程 spin loop + lock-free queue + 用户态 driver(绕过内核、甚至绕过操作系统调度器)。
Tokio 把自己定位成"可靠、快速、易用"的通用异步运行时——它优化的是高并发下的整体吞吐和可维护性,而不是单次操作的延迟下限。对绝大多数网络服务、RPC 服务、实时通信、数据管道来说,这个取舍是对的。但不要把它用在它不该被用的地方——每个工具都有自己的"主场"、搞清楚主场你才能知道什么时候该换工具。
另一面也要说:Tokio 的延迟虽然不能和裸金属比、但它的"可预测性"很好——同等机器、同等代码、每次跑出来的延迟分布相当稳定。这在生产运维里比"偶尔极快但偶尔极慢"更有价值——SLA 是按 p99 算的、不是按 p0 算的。稳定可预测的延迟,远远好过"有时候极快、但偶尔极慢"。
19.11 一份调优检查表:生产 Tokio 服务上线前 12 条自检
在 Tokio 服务上线前、过一下这 12 条自检能挡掉大部分常见的生产事故: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 和上线评审时用的检查表。不是所有项都必须满足——但每一项不满足的地方你都要能说出为什么:
- 没有
std::sync::Mutex的 guard 被持有跨 await——用 clippy 的await_holding_lock可以扫出来(但它只认非 async 感知的 guard,tokio::sync::MutexGuard不在范围内,见第 12 章 12.9); - 没有同步 IO / 同步 syscall 在 async fn 里——除非在
spawn_blocking里; - 每个 spawn 出去的 task 都有"被跟踪"的办法——JoinHandle 被 await、或者 JoinSet 管着、或者是明确的 fire-and-forget;
- 所有 channel 都有 bounded 版本 + backpressure——不要用
unbounded_channel(tokio-1.40.0/tokio/src/sync/mpsc/unbounded.rs:95)除非能证明"流量天然有界"; - select! 分支都是 cancel-safe 的——自己写的 async fn 要仔细审、业务逻辑涉及 partial update 时用
tokio::spawn + JoinHandle代替; - Arc 不在热路径被大量 clone——函数入口一次 clone、后续传引用;
- 大对象用
Box<T>或Arc<T>传 channel——不要按值传几 MB 的结构体; - Runtime 的 thread_name 已设置——panic 时堆栈能认出来是哪个 runtime;
- panic handler 有 metric / log——spawn 边界包了
catch_unwind,或在current_threadruntime 上用Builder::unhandled_panic(UnhandledPanic::ShutdownRuntime)(tokio-1.40.0/tokio/src/runtime/builder.rs:971;1.40.0 里它整个包在cfg_unstable!里,且ShutdownRuntime只支持current_thread——函数第一句就是if !matches!(self.kind, Kind::CurrentThread) && matches!(behavior, UnhandledPanic::ShutdownRuntime) { panic!(..) },在多线程 Builder 上调用会直接 panic); - tracing 的 span 覆盖关键路径——请求入口、DB 查询、下游调用都有
#[instrument]; - 生产 Metrics 至少导出:
num_alive_tasks()(tokio-1.40.0/tokio/src/runtime/metrics/runtime.rs:69,注意不叫alive_tasks)/blocking_queue_depth()(tokio-1.40.0/tokio/src/runtime/metrics/runtime.rs:975)/ 逐 worker 的worker_total_busy_duration(worker)(tokio-1.40.0/tokio/src/runtime/metrics/runtime.rs:556,返回Duration)/ 自己埋的 panic_counter——其中blocking_queue_depth和worker_total_busy_duration在cfg_unstable_metrics!里,要--cfg tokio_unstable; - 有 graceful shutdown 路径 + 超时——
Runtime::shutdown_timeout(self, duration: Duration)(tokio-1.40.0/tokio/src/runtime/runtime.rs:435,按值消费 Runtime)或显式JoinSet::shutdown().await(tokio-1.40.0/tokio/src/task/join_set.rs:372)。
把这 12 条贴在显示器边上、每次上线前过一遍——你会发现很多生产事故都是这里某一条没做。"性能调优"最终不是黑科技,而是工程纪律。
19.12 本章小结
性能是 Tokio 使用的"最后一公里"——前 18 章讲的是机制、第 19 章讲的是"怎么把这些机制用好"。一个懂机制但不会调优的工程师能写出 "能跑" 的 Tokio 服务;懂机制又会调优的工程师能写出"快速、稳定、省资源" 的 Tokio 服务——这个差距决定了你是 Tokio 的入门者还是熟练工。
5 个 take-home:
- 算法先于 runtime 调优——改上层代码的收益通常远大于调 runtime 参数、先优化上层。
- blocking 是性能 #1 杀手——绝大多数 Tokio 性能事故都和 blocking 沾边。
- 10 大陷阱熟记在心——code review 时第一反应就能识别。
- tokio-console 是不可或缺的工具——一次设置、终身受益。
- Tokio 不是万能的——超低延迟场景另选其他 runtime。
带走三件事:
- Tokio 自带的公平性机制(coop budget = 128)和性能取舍(LIFO slot)每时每刻在工作——理解它们才能写出 Tokio 习惯的代码、不和 runtime 对着干
- 性能问题要分象限:吞吐 / 延迟抖动 / 资源异常——诊断手段不同、修复路径不同。按流水线走:基线 → Metrics → 火焰图 → console → 修复 → 验证
- 10 大生产陷阱覆盖绝大多数真实 bug——背下来、code review 时一条条对——比任何花哨调优都值钱。性能调优是纪律、不是灵感
下一章(最后一章)进入设计模式与架构决策——把前 19 章的所有知识收束成一些可复用的设计决策框架:什么时候该 spawn、什么时候该直接 await、channel vs Mutex 如何选、retry / timeout / cancel 如何组合——让你写出"看起来就是对的" Tokio 代码。
延伸阅读
- Tokio 源码:
tokio/src/runtime/coop.rs - Tokio 源码:
tokio/src/runtime/scheduler/multi_thread/worker.rs(LIFO slot) - Brendan Gregg 的 flame graph 原文
- 《Vue 3 设计与实现》专栏第 18 章:性能工程与最佳实践
- 《Rust 编译器与运行时揭秘》专栏第 17 章:增量编译
- 《vLLM 推理内核深度解析》专栏:continuous batching