本文基于 Tokio 官方博客 Introducing dial9: a flight recorder for Tokio 整理撰写,原作者 Russell Cohen。
一个只能在生产环境复现的性能谜题
有人找到 Russell Cohen 求助:他们正在将一个新的 Rust 组件集成到线上服务中,性能问题却只能在生产环境里出现------因为这个服务需要同时与数千台主机建立连接,这种规模根本无法在本地模拟。
症状很奇怪:CPU 使用率超过 90% 之后,性能急剧下降,但 CPU 明明还有余量。
他们已经有 Tokio 的运行时监控指标,但那些数据毫无头绪:Worker 看起来是空闲的,任务队列却是满的。大家有各种猜测,却没有足够的证据。
缺少的,是一条完整的事件时间线。
于是 dial9 诞生了。接入之后,答案一目了然:应用频繁出现 10ms 以上的内核调度延迟。而这个服务的目标延迟只有 5--10ms,这显然是致命的。
什么是 dial9
dial9 是一个专为 Tokio 设计的运行时遥测工具。
普通的聚合指标(如"当前有多少个任务?p99 轮询耗时是多少?")只能告诉你系统的状态快照。dial9 做的事情更底层------它把运行时的每一次 poll、park、wake 都记录成一条日志,而不是一堆计数器的累加。
更重要的是,它把三类信息融合在一起:
- Tokio 运行时事件(任务的创建、调度、完成等)
- 你自己的应用日志与 Span
- Linux 内核事件(线程调度、锁等待、上下文切换等)
这三者放在同一条时间轴上,让你能看到应用、运行时、操作系统三层之间究竟发生了什么。
dial9 已发布到 crates.io,也提供了一个在线 Demo 可以直接体验。
dial9 能发现哪些问题
内核调度延迟
内核调度延迟是指:线程已经处于"就绪"状态,但内核并没有立刻把 CPU 分配给它,中间存在一段等待时间。主机越繁忙,这个延迟越严重。
dial9 在 Worker 的 park/unpark 时刻读取内核元数据,可以精确地看到:内核"应该"唤醒 Worker 的时间点,与"实际"唤醒的时间点之间的差值。
在文章开头提到的那个 AWS 服务里,dial9 在生产数据中发现了频繁的 10ms 以上的调度延迟。具体来说:运行时尝试唤醒 Worker 47,但内核整整 18ms 之后才真正调度它。在这 18ms 里,所有流量只能由一个 Worker 独自处理。
这种问题用聚合指标几乎不可能定位,因为你只能看到 p99 数字很高,却不知道高在哪一步。
fd_table 锁争用
另一个生产案例:某服务在启动阶段 p99 延迟极高。
dial9 的追踪结果显示,在大量并发建立连接时,任务会在 fd_table 扩容期间被内核挂起。fd_table 用于追踪进程打开的文件描述符,每次扩容都需要持有一把锁,而这把锁会阻塞所有尝试打开新连接的 Worker。锁争用导致单次 poll 超过 100ms,整个应用的响应全部被拖慢。
仅凭聚合指标,这类问题几乎无从发现------你只知道 p99 很糟糕,却不知道时间消耗在了文件描述符表这种底层细节上。
任务在 Worker 之间频繁跳跃
由于 Tokio 的 I/O 驱动机制,一个任务在等待 socket 之后,下一个处理它的 Worker 在实践中几乎是随机的。
Russell 本来知道这个理论,但看到 dial9 的可视化结果时,还是颇为震惊:在 2ms 的时间跨度内,单个任务跳跃了 5 个不同的 Worker。
这个现象解释了为什么数据密集型应用往往能从"每核一个运行时"的架构中受益------任务频繁跨核迁移会带来 CPU 缓存行失效(cache line bouncing),而减少迁移可以显著提升缓存命中率。
backtrace::trace 的全局锁------用 dial9 发现了 dial9 自身的问题
这是一个有趣的自我审视案例。
Russell 尝试给 dial9 加入 Task Dump 功能:每当 Future 返回 Poll::Pending 时,自动捕获一次调用栈,记录它在哪里让出了执行权。功能本身很有价值,但加上之后,overhead 从 5% 直接跳到了 50%,而且 Worker 数量越多越严重。
把追踪数据加载进 dial9 之后,原因显而易见:backtrace::trace 在调用时会持有一把全局锁 。每个 Worker 尝试捕获调用栈时,都要抢同一把 mutex。每次 poll 在完成之前都被内核挂起,等待这把锁。
这是一个典型的"理论上不需要协调,实践中库的实现却加了全局锁"的案例。相关讨论可见 backtrace-rs#309。
目前 Task Dump 功能仍在开发中,需要先在 Tokio 中落地帧指针展开(frame-pointer unwinding)和延迟符号化(lazy symbolizing)。
快速上手
dial9 已经可以在生产环境中使用,接入成本很低。
第一步:添加依赖
bash
cargo add dial9-tokio-telemetry
或在 Cargo.toml 中直接添加:
toml
[dependencies]
dial9-tokio-telemetry = "0.1"
第二步:用 TracedRuntime 包裹你的运行时
rust
use dial9_tokio_telemetry::telemetry::{RotatingWriter, TracedRuntime};
fn main() -> std::io::Result<()> {
let writer = RotatingWriter::new(
"/tmp/my_traces/trace.bin",
20 * 1024 * 1024, // 每个文件超过 20 MiB 时轮转
100 * 1024 * 1024, // 磁盘上最多保留 100 MiB
)?;
let mut builder = tokio::runtime::Builder::new_multi_thread();
builder.worker_threads(4).enable_all();
let (runtime, _guard) = TracedRuntime::build_and_start(builder, writer)?;
runtime.block_on(async {
// 你的异步代码
});
Ok(())
}
第三步:查看追踪数据
追踪文件会写入 /tmp/my_traces/。打开在线查看器,把 .bin 文件拖进去即可。dial9 也支持直接将追踪数据写入 S3。
性能开销 :通常低于 5%。RotatingWriter 会自动限制磁盘占用,因此可以放心地在生产环境长期运行。
总结
聚合指标在日常监控中不可或缺,但它有一个根本性的局限:它告诉你"出了问题",却很难告诉你"为什么出问题"。当问题藏在任务调度的时序里、藏在内核与运行时的交互边界上时,光靠 p99 和计数器,调试往往只能靠猜。
dial9 的思路是把飞行记录仪的概念引入 Tokio:把所有运行时事件、应用日志、内核事件串成一条时间线,让你看见那些本来隐藏在统计数字背后的因果关系。
对于在生产环境运行 Tokio 的团队来说,这是一个值得认真关注的工具。
相关链接
- GitHub:https://github.com/dial9-rs/dial9-tokio-telemetry
- crates.io:https://crates.io/crates/dial9-tokio-telemetry
- 文档:https://docs.rs/dial9-tokio-telemetry/latest/dial9_tokio_telemetry/
- 在线 Demo:https://dial9-tokio-telemetry.russell-r-cohen.workers.dev/?trace=demo-trace.bin
- 原文:https://tokio.rs/blog/2026-03-18-dial9