← 文章 / 编程开发
Hacker News 8小时前 · 2026-09-15 10:04:29 · 3 阅读

构建高性能 Tokio 应用的原则

我刚从 RustConf 回来。在 Unconf 期间,我们就异步应用的调试与基准测试展开了一场富有成效的讨论,并分享了诸多有价值的见解。在此,我尝试结合这些见解以及我个人的经验,罗列部分内容。这是初稿,我希望它能成为一份记录最佳实践的活文档。欢迎提交 issue 或 发起 PR。我计划在未来几天内添加一个示例应用,用于演示这些问题以及 dial9 trace 的形态。

—— Russell

编写能在 Tokio 运行时上高效运行的代码,没有几条放之四海而皆准的硬性规则;许多问题的答案都是“视情况而定”。负载的性能取决于当时运行时上正在运行的其他内容。这正是许多问题只在生产环境中才暴露的原因!编写高性能的异步应用,需要在公平性与批处理、竞争性与隔离性之间取得平衡。

本文列出了若干通用原则,并尽可能涵盖例外情况。文章假设读者对 Tokio 的工作窃取运行时具备基本了解;相关的高层概述收录于附录中。

通用原则

首先,判断是否真的存在问题

如果你仔细排查 Tokio 应用中的性能隐患,总能找出一堆。在我见过的大多数真实应用里,代码让出控制权等待下一个 `.await` 点之间的轮询间隔,都远远超过 Alice Ryhl 在优秀博文 What is Blocking? 中建议的 10-100 微秒阈值。这些问题是否真的影响你关心的应用指标或行为,很难说(参见:长轮询有时其实没问题)。关键在于从你真正想优化的实际指标出发倒推需求。例如,某些应用即使存在长轮询也完全无害,强行“修复”它们对面向用户的指标不会有可测量的改善。

在我遇到的绝大多数问题中,根源都出在应用代码本身,通常分布在多个分布式组件的交互中(而非 Tokio 本身)。dial9 对 Tokio 的洞察非常深入;它发现 Tokio 存在问题的频率,至少和它明确证明 Tokio 没有 问题(这能让人放心地去其他地方找原因)的频率一样高。当然,有时问题确实出在 Tokio 上。

在 Tokio 的监控指标中,最有用的是最近新增的调度延迟直方图。调度延迟是指任务准备好运行(例如 socket 收到数据)到 Tokio 实际轮询该 future 之间经过的时间。虽然这个指标无法直接告诉你原因,但调度延迟是 Tokio 与你的代码交互不当最常见症状。

为延迟拆分,为吞吐量批量处理

更频繁让出以优化延迟

要在大量请求中实现低延迟,就需要连接间的公平性。

以 Redis(或其他支持请求流水线的应用)为例。朴素的实现会在连接中还有数据时持续读取。当多个请求被流水线化发送时,整批请求(或大部分)会一次性进入内存缓冲区,之后再逐帧读取时,每次都直接返回 Poll::Ready,无需回到网络层。这既造成单次 poll 时间过长,也导致客户端之间不公平。

对吞吐量的影响通常不大:处理的请求数量是一样的。但延迟会剧烈恶化,因为一整批请求可能排在另一批后面等待。在这个例子中,每处理完一个请求就显式让出(yield),可以把延迟降低约 10 倍。更好的做法是:只在连续多次立即就绪的读取之后才让出。

async fn handle_conn(&mut self) -> crate::Result<()> {
    while !self.shutdown.is_shutdown() {
        // 如果连接有缓冲数据,这里可能连续返回
        // Poll::Ready,不会让出给 runtime。
        let frame = tokio::select! {
            res = self.connection.read_frame() => res?,
            _ = self.shutdown.recv() => {
                return Ok(());
            }
        };

        execute_command(&self.db, &mut self.connection, frame).await?;

        // 提升公平性:
        // tokio::task::yield_now().await;
    }
}
Two mini-Redis pipeline latency distributions showing that adaptive yielding reduces p50 latency from 0.967 to 0.105 milliseconds and p99 latency from 2.548 to 0.320 milliseconds

连续四次立即就绪的读取后再让出,既没有完全放弃批处理,又显著提升了流水线请求的公平性。

如何判断是否存在这个问题?

  • P99 远高于 P50。
  • 单次 poll 的耗时超过了其中实际工作量所需的时间。
  • 一次 poll 里包含了大量 span。

批量处理以摊薄开销

公平性并非免费。每个运行时事件能处理的有效工作越多——无论是任务切换、轮询、跨 worker 迁移还是线程切换——应用的效率就越高。

tokio::fs 就是一个典型例子。我甚至常说“tokio::fs 是有害的”。在没有io_uring 的情况下,Tokio 会在阻塞线程池中执行每个文件系统操作。每次调用spawn_blocking都有成本,且每个运行时都共享同一个阻塞池。

如果你确定要执行一系列文件系统操作——或其他阻塞任务——请将它们批量打包成尽可能大的合理阻塞单元。在某些场景下,使用专用的 OS 线程会是更好的选择。

这条原则适用于所有与 Tokio 交互的场景。如果你知道会将任务发送到全局队列,批量处理也能摊销这种协调开销。

即使是像创建任务这样快速的操作也并非零成本!虽然创建任务很便宜,但如果一次性创建数百或数千个任务,每一个都意味着运行时需单独处理的工作。这会带来更高的调度延迟风险、更多的轮询次数以及整体更高的开销。在创建任务时,请评估实际调度的工作量:将一个 10 微秒的工作单元独立打包成任务往往弊大于利。你可以使用 dial9 或 tokio-metrics 等工具来追踪任务生命周期。

如何判断是否存在此类问题?

  • 在火焰图中,spawn_blocking 等 Tokio API 消耗了显著时间。
  • 紧密循环中执行了大量细粒度的文件系统或阻塞操作。
  • 将相同工作分组到更大单元时,吞吐量有所提升。

警惕全局资源

Tokio 运行时在 worker(专门用于轮询就绪任务的线程)上调度工作。Worker 可以随核心数扩展,但部分运行时资源仍需共享协调。

阻塞线程池目前是1 全局资源。当速率足够高时,向阻塞队列推入任务会成为瓶颈,spawn_blocking 也可能在火焰图中显现。我在 32 核主机上观察到,每秒约 5 万个阻塞任务时会出现性能劣化,具体表现因环境而异。spawn_blocking 并非解决所有阻塞或高 CPU 负载代码的万能药。对于短暂且有界的任务,让 Tokio 的工作线程和工作窃取机制处理可能更快,但老规矩,“具体情况具体分析”。

Tokio 还有一个全局任务队列。当本地工作线程队列溢出(通常较罕见)或任务由运行时工作线程之外调度(在某些应用中很常见)时,任务会进入该队列。例如,发送端运行在非 Tokio 线程上的 channel。

如何判断是否存在此问题?

  • 运行时级操作(如 spawn_blocking)在火焰图中占比显著。
  • 全局队列持续保持较深状态。在健康的应用中,它通常应保持接近空的状态;而在负载饱和的应用中,队列可能需要很长时间才能排空。

对互斥锁要格外谨慎

让工作线程在竞争激烈的互斥锁上阻塞,是导致整个运行时停滞的最简单方式之一。

例如,存储在互斥锁或读写锁后的指标注册表特别容易受此问题影响。如果刷新操作在执行耗时工作时持有锁,每个 Tokio 工作线程最终都可能调度一个尝试记录指标的任务,并阻塞在同一把锁上。此时工作窃取将失效,因为所有工作线程都卡住了!

在异步应用中,临界区应尽可能短(例如仅单次 hashmap 更新)。RWLock 几乎从来不是正确的原语,即使读路径也会造成原子操作的竞争。不要持有锁进行刷新、I/O 操作或等待其他 future。

tokio::sync::Mutex 只是用一个问题换取了另一个:Tokio Mutex 加锁开销更大,容易引发如 FutureLock 之类的细微问题,且仅适用于临界区持续数毫秒以上的场景。

如何判断是否存在此问题?

  • P99 延迟出现可预测间隔的峰值,例如每分钟一次,当后台任务运行时。
  • 在 dial9 中,大量任务突然阻塞、离开 CPU,持续了相当可观的时间。
dial9 trace showing all four Tokio workers blocked by mutex contention, followed by kernel scheduling delays and a sudden drop in active tasks

一个被争抢的阻塞式 mutex 让全部四个 runtime worker 同时停摆。

限制并行度——通常如此

Tokio 很乐意创建远超系统其余部分承受能力的任务量。某个工作负载无限制地 fan out 任务,结果意外地向 S3 打开 3000 个并发连接——这种情况非常常见。

解法很朴素:限制并发。花哨的自适应算法有时也适用,但一个 Semaphore 往往就足够了。

把 Tokio worker 与其他线程隔离开

Tokio 的设计依赖 worker 被快速唤醒。但如果操作系统负载很高,Tokio 尝试唤醒 worker 后,内核可能要过 10–20 ms 甚至更久才会调度它。如果你的 P99 延迟是以个位数毫秒来衡量的,这就是灾难。我在 Amazon 从 Java 到 Rust 的渐进式迁移中就见过这种情况:两个进程跑在同一台主机上,Rust 进程逐步接管更多工作。

结果发现,Java 进程干的活越少,Rust 进程就越快——哪怕它承接的工作更多。如果其他应用使用了大量线程,这种效应会更明显。

最基本的解法是用 cgroups 或相关 API,把 Tokio worker 和其他代码绑定到不同的 CPU 核心上。

其他 Rust 线程也会引发同样的问题。像 tracing_appender 使用的后台线程,有时会一口气跑超过 100 ms 都不让出 CPU。如果 Tokio 在此期间尝试唤醒某个 worker,这个 worker 可能要一直等到内核抢占那个线程才能被调度。

遇到这种情况,解法也一样:把非关键的后台工作绑定到独立的核上,把 Tokio worker 挪到其他核上。你很少需要把所有核都交给 Tokio,为其他工作保留一些核心往往能改善延迟。

如何确认存在此问题?

  • dial9 显示 worker-unpark 事件与 worker 实际运行之间存在内核调度延迟。

当你有更优方案时的技巧

本节介绍的模式通常不是最佳实践,但在某些场景下,它们恰恰是工作负载所需。

阻塞执行器有时是可以接受的

在理想的异步应用中,所有工作都以微小的突发形式进行,并频繁将控制权交还给 Tokio。但现实世界并非总是如此,微小的突发也不一定是最快的软件执行方式。批量处理工作可能更高效。

实际上,长时间轮询并不总是个问题。在负载较轻时,Tokio 的工作窃取机制可以弥补某个 worker 占用时间较长的情况。但在以下两种情况下,这种机制开始失效:

  1. Tokio 运行时负载很高,没有闲置的 worker 资源。
  2. 操作系统负载很高,导致 worker 的 unpark 操作经常延迟。

在这两种情况下,窃取工作都需要更长时间。如果工作不能被足够快速地窃取,核心的运行时维护任务——如驱动 I/O——可能无法足够频繁地执行,从而影响低延迟。

重要提示! 如果你正在使用 tokio::join!tokio::select! 等利用任务内并发的功能,上述建议不适用。在单个任务内部没有工作窃取;如果你阻塞了执行器,该任务上运行的其他代码将无法继续推进。这有时会导致意外的超时和较差的延迟。

使用多个运行时按优先级隔离工作负载

你还可以在运行时线程启动时设置操作系统级别的 nice 值。参见 dial9 的多运行时示例以及 Tokio 的on_thread_start 钩子。

TokioConf 上大多数演讲给人的总体印象是,大家最终都转向了至少包含两个运行时的解决方案。

自旋以保持控制

这是一种针对微秒级延迟的高级策略。我不建议一开始就使用它,但它确实有效。

每次你将控制权交还给 Tokio 调度器——或者 Tokio 让工作线程休眠并交给操作系统——都会增加任务再次唤醒时出现延迟的可能性。

对于对延迟极度敏感的工作,一个选项是在等待下一项有用任务时,不主动让出,而是预设一个短暂的自旋周期,比如 50 微秒。这会占用一个核心并可能影响邻近工作负载,因此对于大多数应用来说这是错误的选择。但在受控条件下,这可能是正确的权衡。

附录:Tokio 的四点心智模型

  • Rust 的 futures 在 await 点之间做增量进展。这些活跃段落称为 polls,得名于 Future::poll 方法。
  • 当 futures 未被轮询时,它们处于空闲状态,等待执行器再次运行。好的执行器只在有工作要做时才对 future 进行轮询。
  • Tokio 运行 N 个工作线程,通常每个可用核心一个。每个 worker 有一个本地队列。当队列溢出或无法将工作添加到本地队列时,任务进入全局队列
  • 当一个 worker 的队列积压时,另一个 worker 可以从它那里窃取工作——前提是运行时检测到不平衡且另一个 worker 有容量。
1

Tokio 1.52.0 曾短暂发布分片阻塞队列,但因回归问题可能导致 spawn_blocking 挂起,1.52.1 版本将其回滚。Tokio 随后通过 PR #8337 重新引入了分片队列,作为一个默认禁用的不稳定特性。

原始来源: Hacker News

评论 (0)