2024年,我深入研究了使用 Tokio 编写的异步 Rust 服务器软件的调试与优化。这是一段充满挑战的旅程,尤其是当处理那些最初以同步方式编写、后来逐步转换为异步的代码时,面对异步 Rust 的复杂性更是难上加难。我必须承认,与其从零开始一个新项目,不如在一个功能基本完成的项目上工作更有趣。在后者中,你不能仅仅依赖最佳实践和设计模式,而是需要深入理解某些代码为何运行缓慢、为何存在问题或为何难以修改,以便你的改动足够小,能被其他开发者接受。
尽管已有许多优秀的博客文章讨论如何使用 Tokio 编写程序,或从理论层面探讨异步 Rust 的各个方面,但我希望分享一份更实用的指南,帮助那些正在考虑使用异步 Rust 或需要在服务器应用中实现它的开发者。这篇文章基于实际经验和生态系统的最新发展,旨在提供有效的策略和见解,而非替代 Tokio 和异步 Rust 的官方文档。
为什么选择异步/Tokio?Tokio 是异步 Rust 生态系统的一部分,提供了一个用于异步任务的执行器(executor),已经成为大多数应用程序的默认选择。对于那些需要频繁等待 I/O 操作、网络请求或外部资源的软件,异步编程可以显著提升性能和资源利用率。
另一种选择是使用类似“超级循环”(superloop)的方式,手动处理所有消息发送与接收逻辑、事件处理、内存分配和状态切换。然而,这种循环很容易变成一个庞大而缓慢的逻辑混乱,难以维护。不过,对于非常简单的应用,这种方式可能已经足够。
什么是 Future?异步 Rust 提供了 Future 抽象,它表示一个将在某个时间点可用的值。其定义如下:
trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;}pub enum Poll<T> { Ready(T), Pending,}程序必须调用 poll 方法以推动 Future 的进展。Future 要么返回 Ready(value),要么返回 Pending并安排自己再次被调用。程序可以通过循环调用 poll 或使用 Waker 来在资源准备好时通知程序。
示例流程:
程序创建了 10 个 Future,用于获取 10 个不同城市的当前天气。程序依次轮询这些 Future,得到 Pending,因为它们需要天气服务的响应。一旦收到响应,操作系统内核会调用关联的 Waker,通知程序相关的 Future 已准备好继续。程序再次轮询相关的 Future,并获得结果值。Future的特点:
零成本抽象:相比操作系统线程,使用 Future 不会产生额外开销。无栈(stackless):Future 不携带额外的内存,它可以使用执行它的栈,并通过引用访问变量。协作式(cooperative):一旦程序调用 poll,所有控制权都会转移给 Future,由它决定是停止、返回结果还是继续运行。强类型(strongly typed):例如,在多线程上下文中,如果内存管理不安全,编译器会标记错误,从而帮助消除竞争条件和死锁。什么是 Tokio?还记得前面提到的“超级循环”吗?Tokio 就是那个超级循环,但它已经为你做好了所有准备!它提供了一个高效的运行时,可以以高吞吐量调度 Future 的执行。
根据 Tokio 官方网站 的描述,它还支持多线程,并配备了工作窃取(work-stealing)线程池。工作窃取意味着如果某个线程没有任务可做,它会从其他线程中拉取任务。
Tokio 并没有消除 Future 的特性,例如“协作式”,反而强化了这一点。
使用 Tokio 的示例应用:
use tokio;#[tokio::main]async fn main() { let handle = tokio::spawn(async { // 并发任务逻辑 println!("Running an async task!"); }); handle.await.unwrap();}在上述代码中,tokio::spawn 会创建一个 Tokio 任务来运行提供的 Future。
异步 Rust 的局限性尽管异步 Rust 功能强大,但它并非万能。开发者需要理解其行为的细微差别:
多线程应用仍然困难
编写多线程软件本质上仍然具有挑战性。尽管 Tokio 提供了出色的安全特性,但你仍需要使用同步原语,并仔细设计并发逻辑以避免死锁。
协作式事件循环
Tokio 实现了协作式事件循环,这意味着 Tokio 任务需要主动让出控制权,否则整个运行时可能会被阻塞。
目前没有工具可以识别不合作的函数,编译器也无法提供帮助。因此,在异步代码中使用任何同步库时必须格外小心。一些设计不佳的异步库也可能带来问题。
相比之下,Go 的运行时允许你随意抛出任何函数,并在任何时候取消它,而不会阻塞其他任务。但 Go 为此付出了巨大的代价,因为它依赖垃圾回收器(Garbage Collector)。
阻塞操作的危险
执行不合作的 Future 不仅会降低软件性能,还可能导致程序冻结。例如,当使用 tokio::main 宏时,阻塞操作可能会导致整个运行时停滞。
理解什么是阻塞函数至关重要。推荐阅读 Tokio 核心维护者的这篇文章,详细解释了“阻塞”的含义。简而言之,任何延迟超过 10–100 微秒的操作都可以被认为是阻塞的。
函数着色问题
异步引入了函数的“着色”问题——异步函数无法直接从同步上下文中调用,除非使用 .await 或运行时操作。例如:
async fn async_function() { // 这是一个“着色”的异步函数}fn sync_function() { // 无法直接调用 async_function async_function(); // 编译错误!}这迫使开发者思考整个应用程序的状态机,以及特定函数在其中的位置。有些人会选择将所有函数都变成异步函数,并将程序的入口点设为异步函数。然而,这种做法并不理想,因为它显著增加了整个程序意外冻结的风险。
结论毫无疑问,异步 Rust 是编写网络应用(包括服务器、节点等)的强大工具。然而,它也有许多局限性,因此在做出设计决策时(例如是否使用多线程 Tokio 功能)需要慎重讨论。
以下是一些建议:
异步 Rust 应该是同步 Rust 的小幅增量:明确程序中哪些部分需要异步 Rust。可以让一个线程处理网络 I/O,然后通过通道与运行同步代码的线程通信。合理选择运行时模型:如果整个程序运行在异步上下文中是合理的,优先考虑单线程运行时。对于多线程 Tokio 运行时的使用需格外谨慎。审查库的 API:仔细检查程序使用的所有库,判断它们的 API 是否会阻塞。如果可能阻塞,将其放入阻塞线程池或操作系统线程中运行。切记不要阻塞事件循环!隔离阻塞操作:考虑为阻塞操作使用单独的操作系统线程,并通过通道与其他任务通信。这样可以避免使用锁和智能指针共享可变缓冲区。处理死锁:如果程序中使用了大量锁,考虑实现恢复机制以应对死锁。对于服务器应用而言,快速崩溃比长时间冻结更好,尤其是在 Kubernetes 等平台上运行时。设置监控:搭建一个指标服务器,收集并展示 Tokio 的运行状况,例如任务饥饿、阻塞任务数量、轮询时间等。