Tokio 应用程序的快速优化原则
摘要
本文概述了在 Rust 中优化 Tokio 应用程序的最佳实践,涵盖了延迟与吞吐量以及调试策略等原则。
暂无内容
查看缓存全文
缓存时间: 2026/09/14 17:47
# 高效 Tokio 应用原则
来源:https://dial9-rs.github.io/blog/principles-for-fast-tokio-applications/
> 我正从 RustConf 返回。在 Unconf 会议上,我们就调试和基准测试异步应用进行了富有成效的讨论,分享了许多有趣的见解。我尝试在此列举其中一些,并结合我个人的部分经验。这是该文档的初稿,我希望它能成为一份持续更新的最佳实践指南。欢迎提交议题或发起 Pull Request(https://github.com/dial9-rs/dial9-rs.github.io/blob/main/content%2Fblog%2Fprinciples-for-fast-tokio-applications.md)。我计划在未来几天内添加一个示例应用,以演示这些问题以及 dial9 追踪的形态。— Russell
在 Tokio 运行时上编写高性能代码,几乎没有放之四海而皆准的规则;许多问题的答案都是“视情况而定”。工作负载的性能取决于当时运行时上运行的其他任务。这就是为什么许多问题只在生产环境中才会暴露!编写高性能的异步应用,需要在公平性与批处理、竞争与隔离之间找到平衡。
本文阐述了一些通用原则,并尽可能涵盖例外情况。它假设你对 Tokio 的工作窃取运行时有基本了解;附录中包含一个高级摘要(https://dial9-rs.github.io/blog/principles-for-fast-tokio-applications/#appendix-a-mental-model-for-tokio-in-four-bullet-points)。
## 通用原则
### 首先,确定你是否存在问题
如果你开始在 Tokio 应用中寻找红旗,你几乎总会找到它们。我见过的几乎每个真实应用都有轮询(在 `.await` 点之间,代码让出控制权返回给运行时的时间段)(https://dial9-rs.github.io/blog/principles-for-fast-tokio-applications/#appendix-a-mental-model-for-tokio-in-four-bullet-points)时间远长于 Alice Ryhl 在她的优秀文章《什么是阻塞?》(https://ryhl.io/blog/async-what-is-blocking/)中建议的 10-100 微秒。这些问题可能会影响也可能不会影响你实际关心的应用指标或行为(参见:有时长轮询没问题(https://dial9-rs.github.io/blog/principles-for-fast-tokio-applications/#blocking-the-executor-can-be-fine-sometimes))。重要的是要从你试图改进的实际指标反向推导。例如,一个应用可能存在完全无害的长轮询;“修复”它们不会对面向用户的指标产生可衡量的影响。
在我遇到的绝大多数问题中,问题出在应用代码本身,通常在于分布式系统多个组件之间的交互(而非 Tokio 本身)。dial9 为 Tokio 提供了很好的可观测性;它至少同样频繁地证明 Tokio *没有*问题(这给了用户信心去其他地方寻找)。当然,有时确实是 Tokio 的问题。
就 Tokio 指标而言,最有用的是最近新增的调度延迟直方图(https://docs.rs/tokio/latest/tokio/runtime/struct.RuntimeMetrics.html#method.schedule_latency_histogram_bucket_range)。调度延迟是指你的任务准备好运行(例如,因为套接字有数据)到 Tokio 实际轮询该 future 之间的时间。虽然这不能告诉你原因,但调度延迟是 Tokio 与你的代码交互不良最常见的症状。
### 为延迟而分割,为吞吐量而批处理
#### 更频繁地让出以优化延迟
要实现跨多个请求的低延迟,需要在连接之间保持公平。
考虑 Redis(或任何支持请求流水线的应用)。一个简单的实现在有更多数据可用时,会直接从连接上读取数据。当请求被流水线化时,整个流水线请求(或其大部分)最终会进入内存缓冲区。当你从中读取帧时,每个帧都会是 `Poll::Ready`(而无需返回网络)。这会同时造成长轮询和客户端之间的不公平。
对吞吐量的影响通常较小:处理的请求数量相同。然而,延迟会发生显著变化,因为一个完整的流水线可能需要等待另一个。在这种示例中,显式地在每个请求后让出,可以将延迟降低约 10 倍。通过仅在几次连续的立即就绪读取后让出,你可以做得更好。
```
async fn handle_conn(&mut self) -> crate::Result<()> {
while !self.shutdown.is_shutdown() {
// 如果连接有缓冲数据,这会重复返回
// Poll::Ready 而不回到运行时让出。
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;
}
}
```
两个 mini-Redis 流水线延迟分布图显示,自适应让出将 p50 延迟从 0.967 毫秒降低到 0.105 毫秒,p99 延迟从 2.548 毫秒降低到 0.320 毫秒 (https://dial9-rs.github.io/blog/principles-for-fast-tokio-applications/mini-redis-latency-distribution.png)。在四次连续的立即就绪读取后让出,使流水线请求更加公平,同时又不会完全放弃批处理。
**我如何知道我有这个问题?**
- P99 远大于 P50。
- 轮询所需时间比其内部工作应该需要的时间更长。
- 许多 span 落在单个轮询内。
#### 批处理工作以摊销开销
公平性并非没有代价。每个运行时事件(切换任务、轮询、在工作线程间移动或切换线程)能做的有用工作越多,你的应用就越高效。
也许最好的例子是 `tokio::fs`。我有时甚至会说 “`tokio::fs` 被认为是有害的”。在没有 `io_uring`(https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.enable_io_uring)的情况下,Tokio 在阻塞线程池上运行每个文件系统操作。每次调用 `spawn_blocking`(https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html)也有开销,并且每个运行时都有一个共享的阻塞池。
如果你知道将要执行一系列文件系统操作——或任何阻塞工作——将它们批处理成尽可能大的合理阻塞段。在某些情况下,专用的操作系统线程更合适。
这个原则适用于你与 Tokio 交互的任何地方。如果你知道将向全局队列(https://docs.rs/tokio/latest/tokio/runtime/index.html#multi-threaded-runtime-behavior-at-the-time-of-writing)发送工作,批处理也可以摊销该协调开销。
即使是生成任务这样快的操作也不是没有开销的!生成任务很廉价,但如果你生成数百或数千个任务,每个任务都代表运行时需要单独处理的工作。每个任务都增加了受调度延迟影响的机会,增加了运行时需要处理的单个轮询数量,通常也增加了总开销。当你生成任务时,考虑你实际调度了多少工作:将一个 10 微秒的工作单元生成到自己的任务上可能适得其反。像 dial9 或 tokio-metrics 这样的工具有助于跟踪任务的生命周期。
**我如何知道我有这个问题?**
- 在火焰图中,像 `spawn_blocking` 这样的 Tokio API 消耗了显著时间。
- 一个紧密循环执行许多单独的小文件系统或阻塞操作。
- 当相同的工作被分组成更大的单元时,吞吐量得到提高。
### 警惕全局资源
Tokio 运行时在工作线程上调度工作:这些是轮询就绪任务的专用线程。工作线程跨核心扩展,但一些运行时资源仍需共享协调。
阻塞池目前是(https://dial9-rs.github.io/blog/principles-for-fast-tokio-applications/#blocking-queue)一个全局资源。在足够高的速率下,向阻塞队列推送工作会成为瓶颈,`spawn_blocking` 可能会在火焰图中变得可见。我曾在 32 核主机上每秒大约 50,000 个阻塞任务时观察到负面性能影响;具体情况会有所不同。`spawn_blocking` 并不是解决每段阻塞或 CPU 密集型代码的魔法修复方案。对于短暂、有界的工作,让 Tokio 的工作线程和工作窃取机制来处理可能更快,但一如既往,“视情况而定”。
Tokio 还有一个全局任务队列。当本地工作线程队列溢出(通常很少发生),或从运行时工作线程外部调度工作时(在某些应用中可能很常见),任务会进入该队列。一个例子是发送方运行在非 Tokio 线程上的通道。
**我如何知道我有这个问题?**
- 像 `spawn_blocking` 这样的运行时范围操作在火焰图中很突出。
- 全局队列始终很深。在健康的应用中,它通常应该保持接近空;在饱和的应用中,它可能需要很长时间才能清空。
### 极其小心地使用互斥锁
使整个运行时停滞的最简单方法之一,就是在争用的互斥锁上阻塞一个工作线程。
诸如存储在互斥锁或读写锁后面的指标注册表之类的东西特别容易受此问题影响。如果一次刷新在执行昂贵工作时持有锁,那么每个 Tokio 工作线程最终都可能调度一个试图记录指标并在同一个锁上阻塞的任务。窃取变得不可能,因为每个工作线程都被卡住了!
保持异步应用中的临界区非常短(例如,单个哈希表更新)。`RWLock` 几乎永远不是正确的原语,因为即使在读取路径上,它们仍在原子操作上产生竞争。不要在刷新、执行 I/O 或等待另一个 future 时持有锁。
`tokio::sync::Mutex` 用一个问题换取了另一个:Tokio 互斥锁的获取开销大得多,容易出现诸如 FutureLock(https://rfd.shared.oxide.computer/rfd/0609)之类的微妙问题,并且只适合临界区持续多毫秒的情况。
**我如何知道我有这个问题?**
- P99 在可预测的时间间隔(例如每分钟一次,后台任务运行时)出现尖峰。
- 在 dial9 中,许多任务突然被阻塞并离开 CPU 持续非平凡的时间。
dial9 追踪显示所有四个 Tokio 工作线程被互斥锁争用阻塞,随后出现内核调度延迟和活动任务突然下降 (https://dial9-rs.github.io/blog/principles-for-fast-tokio-applications/lock-contention.png)。一个争用的阻塞互斥锁会同时停滞所有四个运行时工作线程。
### 限制并行度——通常如此
Tokio 可以轻松生成远超你系统其余部分所能处理的任务数量。意外地打开 3000 个到 S3 的并发连接,因为工作负载扇出数量不受限制的任务,这种情况非常常见。
答案很无聊:限制并发。花哨的自适应算法有时是合适的,但一个 `Semaphore`(https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html)通常就足够了。
### 将 Tokio 工作线程与其他线程隔离
Tokio 的设计依赖于工作线程快速唤醒。然而,如果操作系统负载很高,内核可能需要 10–20 毫秒——甚至更长时间——才能在 Tokio 尝试唤醒它之后调度一个工作线程。如果你测量到 P99 延迟在个位数毫秒级别,那将是灾难性的。我在 Amazon 从 Java 到 Rust 的增量迁移过程中观察到了这一点,当时两个进程运行在同一台主机上,Rust 进程逐渐承担了更多工作。
Java 进程做的工作越少,Rust 进程就变得越快,即使它处理了更多工作。当其他应用程序使用大量线程时,这种效果更明显。
最基本的解决方案是使用 `cgroups` 或相关 API 将 Tokio 工作线程和其他代码固定到不同的 CPU 核心上。
同样的问题也可能由其他 Rust 线程引起。后台线程,如 `tracing_appender` 使用的线程,有时可能执行超过 100 毫秒的工作而不让出 CPU。如果 Tokio 在此期间尝试唤醒一个工作线程,该工作线程可能会被延迟,直到内核抢占另一个线程。
如果你看到这种情况发生,解决方案是一样的:将非关键的后台工作固定到自己的核心,并将 Tokio 工作线程移动到其他核心。**你很少需要为 Tokio 使用所有核心,为其他工作保留核心往往会提高延迟。**
**我如何知道我有这个问题?**
- dial9 显示在工作线程取消暂停事件和工作线程实际运行之间存在内核调度延迟。
## 当你更了解时的技巧
本节中的模式通常不是正确的做法,但有时它们正是某个工作负载所需要的。
### 阻塞执行器有时没问题
在一个理想化的异步应用中,所有工作都会以微小的突发形式完成,并频繁让出给 Tokio。现实世界并非总是如此,微小的突发也不一定是运行软件最快的方式。批处理工作可能更高效。
在实践中,长轮询并不总是问题。在轻负载下,当一个工作线程占用时间比平时更长时,Tokio 的工作窃取机制可以补偿。这会在两种条件下开始崩溃:
1. Tokio 运行时负载很重,没有空闲的工作线程容量。
2. 操作系统负载很重,因此唤醒工作线程经常被延迟。
在这两种情况下,窃取工作需要更长时间。如果工作没有被足够快地窃取,核心运行时维护(如驱动 I/O)可能无法足够频繁地发生以维持低延迟。
**重要提示!** 这个建议不适用于你使用像 `tokio::join!` 和 `tokio::select!` 这样利用任务内并发功能的情况。在单个任务内,没有工作窃取;如果你阻塞了执行器,*在该任务上*运行的其他任何东西都无法取得进展。这有时表现为意外的超时和普遍的高延迟。
### 使用多个运行时按优先级隔离工作负载
最强的隔离来自于将工作分配给单独的运行时,并将这些运行时固定到专用核心。许多网络服务既有延迟敏感的工作,也有较低优先级的后台工作。将它们放在单独的运行时上,在两者之间创建了一个调度边界。
你还可以在运行时线程启动时设置操作系统级别的优先级。参见 dial9 的多运行时示例(https://github.com/dial9-rs/dial9/blob/main/dial9/examples/multi_runtime.rs)和 Tokio 的 `on_thread_start`(https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.on_thread_start)钩子。
在 TokioConf 上,大多数演讲的普遍印象是,人们最终都转向了至少使用两个运行时的解决方案。
### 自旋等待以保持控制
> 这是一种非常高级的追求微秒级延迟的策略。我不建议首先采用这种方法,但它确实可能有效。
每次你让出给 Tokio 调度器——或 Tokio 将工作线程挂起并让出给操作系统——你都会为该工作在再次唤醒时被延迟创造机会。
对于极度延迟敏感的工作,一个选择是故意自旋一段预设的短暂时间,比如 50 微秒,而不是在等待下一个有用工作片段时让出。这会占用一个核心并可能损害相邻的工作负载,因此对大多数应用可能是不正确的。然而,在仔细控制的条件下,它可能是正确的权衡。
## 附录:Tokio 的四点思维模型
- Rust future 在 await 点之间取得进展。这些活动部分称为轮询,源自 `Future::poll`(https://doc.rust-lang.org/stable/std/future/trait.Future.html#tymethod.poll)方法。
- 当 future 未被轮询时,它们处于空闲状态,等待执行器再次运行它们。一个好的执行器仅当 future 有工作要做时才轮询它。
相似文章
谁在运行你的 Rust Future?动手实践入门异步 Rust
这是一套动手实践教程系列,旨在弥合理解异步 Rust 内部机制(Future、poll、Pin、执行器)与使用 Tokio 部署实际异步代码之间的差距,面向熟悉 JavaScript 异步和基础 Rust 的开发者。
宣布 Topcoat: 一个使用 Rust 构建全栈响应式 Web 应用程序的框架
Tokio 宣布 Topcoat,一个模块化、开箱即用的 Rust 框架,用于构建全栈响应式 Web 应用程序,完全服务器端渲染,具有类似于 HTMX 的响应式指令。
Tokio 追求进度而非顺序:调度 100 万个任务
解释了为何 Tokio 的调度器在大规模下不保证任务顺序,使用一个真实世界的 100 万个任务示例,并强调了进度与顺序之间的区别。
TailTalk: 一个基于 Rust 和 Tokio 的现代异步用户空间 AppleTalk 协议栈
TailTalk 是一个使用 Rust 和 Tokio 实现的现代异步用户空间 AppleTalk 协议栈,旨在构建能够通过 EtherTalk 或 LocalTalk 网络与经典 Mac 通信的实现。
Rust语言的性能
本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。