dial9:一个强悍的 Tokio 调试工具!!!

4 阅读9分钟

作者:Russell Cohen
发布时间:2026 年 3 月 18 日

原文地址 tokio.rs/blog/2026-0…

这是一篇 Russell Cohen 的客座文章。
当 Russell 第一次向我展示 dial9 时,我立刻觉得:Tokio 社区一定得知道这个工具。于是我邀请他写下这篇文章,并请他在 TokioConf 上做现场演示。
—— Carl


很多工具的诞生,并不是因为“想做个工具”,而是因为问题实在太难查了

dial9 就是这样出现的。

有人来找我帮忙:他们正在把一个新的 Rust 组件接入现有服务,但碰到了一个非常诡异的性能问题。更麻烦的是,这个问题只能在生产环境里复现

原因也很现实:这个服务会同时连接成千上万个远端主机,这种规模在测试环境里根本模拟不出来。

更奇怪的是,系统在 CPU 使用率不到 90% 时一直表现正常;可一旦超过这个点,性能就会突然崩掉。可问题在于——CPU 明明还没有被完全打满

按理说,这不该发生。

他们已经收集了 Tokio runtime 的各种指标,但这些数据并不能解释问题:

  • worker 看起来像是空闲的
  • 队列却又是满的
  • 指标之间彼此矛盾

我们当然可以提出一些猜测,但如果看不到“到底发生了什么”的完整时间线,就始终只能停留在猜测阶段。

于是,我们需要一个东西:

  • 能把运行时里的关键事件完整记录下来
  • 足够轻量,能直接跑在生产环境
  • 还能把 Tokio、应用日志、内核行为放到同一条时间线上一起看

这就是 dial9。

等它真正跑起来以后,问题几乎立刻就暴露了:应用存在频繁的 10ms 以上内核调度延迟。而如果你的目标延迟本来就只有 5–10ms,那这已经足以把系统打穿了。


dial9 是什么?

简单来说,dial9 是一个面向 Tokio 的运行时遥测工具

很多时候,我们能拿到的只是聚合指标,比如:

  • 当前有多少任务在运行?
  • poll 的 p99 延迟是多少?
  • 队列长度有多长?

这些指标当然有价值,但它们只能告诉你“结果”,很难告诉你“过程”。

而 dial9 做的事情不一样:

它不是只统计数字,而是把底层运行时事件——例如单次 poll、park、wake——按日志一样记录下来

更关键的是,它还会把下面几类信息放到一起:

  • Tokio runtime 的内部事件
  • 你的应用自己的 span 和日志
  • Linux 内核事件

也就是说,你看到的不再只是“Tokio 有点慢”,而是:

  • 你的应用到底做了什么
  • Tokio 是怎么调度这些任务的
  • 操作系统又在什么时候把线程挂起、唤醒、延后调度了

这就像给异步运行时装上了一个飞行记录仪

dial9 已经发布到 crates.io,现在就可以试用。


为什么它有用?

很多团队在大规模使用 Tokio 时,确实会碰到一些“看起来像是 runtime 有问题”的场景。

但麻烦在于,仅靠聚合指标去推断根因,往往非常依赖经验。你得足够熟悉 Tokio 的内部机制,也得理解操作系统调度、I/O 唤醒、锁竞争等行为,才能从一堆零散指标里拼出真正的问题。

而 dial9 的价值就在于:

它不是让你猜,而是让你直接看到。

下面是几个真实案例。


1)看见内核调度延迟

先说一个很容易被忽略、但杀伤力极大的问题:内核调度延迟(kernel scheduling delay)

它指的是:

线程已经“可以运行”了,但内核没有立刻让它上 CPU,中间存在一段空档。

也就是说,线程明明 ready 了,却没被马上执行。

dial9 会在 worker park / unpark 时读取内核元数据,因此它可以非常精确地看出:

  • runtime 是什么时候尝试唤醒某个 worker 的
  • 内核又是什么时候真正让这个 worker 开始执行的

主机越忙,这种调度延迟就越容易出现。

在前面提到的那个 AWS 服务里,我们就看到了大量 10ms 以上的调度延迟。有一段生产环境里的真实 trace 显示:runtime 尝试唤醒 worker 47,但内核直到 18ms 之后才真正调度它执行。

这意味着,在这 18ms 内,所有流量都只能由一个 worker 苦苦支撑。

如果你的系统目标延迟本身就在几毫秒量级,那这种问题几乎是灾难性的。


2)找出 fd_table 争用

另一个案例发生在某个生产服务的启动阶段。

这个团队遇到的问题是:服务启动时 p99 延迟非常糟糕

用 dial9 一看,原因非常明确:当系统在短时间内并发打开大量连接时,任务会在 fd_table 扩容期间被取消调度。

这里的 fd_table,负责追踪当前打开的文件描述符。
问题在于,它扩容时需要拿一把锁,而这把锁会卡住所有试图打开新连接的 worker。

结果就是:

  • 大量 worker 一起卡住
  • poll 时间被拉长到 100ms 以上
  • 整个应用的启动过程都被拖慢

这类问题最大的特点是:聚合指标里你通常只能看到“慢了”,却很难知道“到底是谁卡住了谁”。

而 dial9 能直接把这条链路展示出来。


3)任务其实一直在 worker 之间“漂移”

如果你以为一个 Tokio 任务通常会稳定地待在某个 worker 上执行,那现实可能会让你有点意外。

dial9 能看到任务的完整生命周期,以及每一次 poll 的执行位置,所以你可以很直观地观察到:同一个任务会频繁地在不同 worker 之间跳来跳去。

这在原理上并不难理解。

由于 Tokio 的 I/O driver 工作方式,当一个任务在 socket 上等待之后,下一次由哪个 worker 把它捡起来继续执行,往往近似随机。再加上 work stealing 的存在,任务在 worker 间迁移就会变得非常频繁。

但即便知道原理,真正看到 trace 时,这种“漂移”的程度依然会让人吃惊。

在一段 trace 中,一个被高亮显示的任务,在 2ms 内竟然先后跑到了 5 个不同的 worker 上。

这也解释了为什么很多数据密集型应用会考虑采用“每核一个 runtime”的架构:
因为它可以减少任务跨核迁移带来的 cache line bouncing(缓存行抖动),从而降低性能损耗。


4)甚至还能用 dial9 查出 dial9 自己的问题

最有意思的,是最后这个案例:
我们居然是用 dial9,查出了 dial9 自己内部的性能问题。

当时我在给 dial9 增加一个“任务转储(task dump)”能力。思路很简单:每当某个 future 返回 Poll::Pending 时,就抓一份 backtrace,这样开发者就能知道它到底是在哪个调用路径上挂起的。

听起来很合理。

但问题来了:功能一打开,开销立刻从 5% 飙到 50%。而且 worker 越多,情况越糟。

把 trace 导进 dial9 之后,问题一下就清楚了:

在写这篇文章时,backtrace::trace 内部会获取一把全局锁

这意味着,每个 worker 只要想抓 backtrace,就都得去争抢同一个 mutex。

我原本以为 frame-pointer unwinding 理论上不需要这种全局协调——从技术上说也确实如此——但这个库出于一些实现上的复杂原因,还是会拿这把锁。

而 dial9 恰好可以记录这样一种事件:

线程因为等待某个资源(例如 mutex)而被内核取消调度,并在那一刻抓取调用栈。

于是 trace 里几乎是一眼可见:每一次 poll 还没结束,就因为等待这把锁而被挂起了。

问题完全坐实。

目前我们还在继续推进 task dump 功能。
不过在那之前,得先把基于 frame pointer 的栈展开和**backtrace 的 lazy symbolizing(延迟符号化)**推进到 Tokio 里。


怎么开始用?

好消息是:dial9 现在就可以上手。

已经有团队在生产环境中使用它了。
当然,和所有新软件一样,生产使用时依然建议谨慎评估。

第一步:添加依赖

cargo add dial9-tokio-telemetry

对应的 Cargo.toml 配置如下:

[dependencies]
dial9-tokio-telemetry = "0.1"

第二步:用 TracedRuntime 包装 Tokio runtime

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(())
}

就这么简单。

trace 文件会输出到 /tmp/my_traces/ 目录下。你可以直接用 trace viewer 打开,把 .bin 文件拖进去就行。项目里也提供了 demo trace,方便先体验一下效果。

此外,dial9 还支持把 trace 直接写入 S3。


性能开销大吗?

根据文章中的描述,dial9 的额外开销通常低于 5%

同时,RotatingWriter 会自动控制磁盘占用,因此你可以把它持续挂在生产环境里,而不用太担心 trace 文件无限膨胀。

当然,是否适合长期开启,最终还是要结合你的业务场景和机器资源做评估。


最后

dial9 最打动人的地方,不只是“它能看到更多数据”,而是它把过去很多只能靠经验和猜测去判断的问题,变成了可以直接观察、直接验证的事实

对于 Tokio 这样的异步运行时来说,这种能力非常珍贵。

如果你正在排查:

  • 高并发下的延迟抖动
  • worker 看起来空闲但队列却堆积
  • 启动期莫名其妙的长尾延迟
  • 任务在不同线程间迁移带来的性能损耗
  • runtime、应用、内核之间难以解释的互动问题

那 dial9 很值得一试。

最后,也向所有推动 dial9 成为现实的人致敬,尤其是 Jess Izen、Mark Rousskov,以及 AWS 那些最早把它跑进生产环境的团队。

TokioConf 见。