作者:Russell Cohen
发布时间:2026 年 3 月 18 日
这是一篇 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 现在就可以上手。
已经有团队在生产环境中使用它了。
当然,和所有新软件一样,生产使用时依然建议谨慎评估。
第一步:添加依赖
bash
cargo add dial9-tokio-telemetry
对应的 Cargo.toml 配置如下:
toml
[dependencies]
dial9-tokio-telemetry = "0.1"
第二步:用 TracedRuntime 包装 Tokio runtime
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(())
}
就这么简单。
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 见。