2025 年 1 月 15 日,Kobzol 在其博客上发表了一篇关于 Rust 异步编程的文章,强调异步 Rust 的核心优势在于并发,而不仅仅是性能提升。以下是对该文章的翻译。
异步 Rust 的真正价值
异步/await 的主要好处在于它能够让我们简洁地表达复杂的并发逻辑,性能提升只是次要效果。我们应该主要基于它如何简化代码来评判异步,而不是看它是否让代码更快。
在大学教授 Rust 课程时,我向学生们展示了多种实现网络应用的方法,从使用线程的阻塞 I/O,到手动状态机的非阻塞 I/O,最终是异步/await。在解释异步 Rust 的动机时,我注意到在线讨论中常将性能作为推广异步 Rust 的主要理由,这引发了批评,认为性能提升不值得异步带来的问题。但我认为这种观点不全面,异步的主要动机几乎从来不是单纯的性能,而是优雅地表达和组合并发进程的能力,这才是异步/await机制的真正杀手锏。
为什么需要异步
Rust 最初实现异步/await支持是为了实现用户空间并发,以达到高性能。用户空间部分很重要,因为它与使用 OS 原语(线程)的并发相比,后者通常开销更大、性能更差。很多人认为减少开销和提高性能是异步有用的原因,这似乎成了在线讨论 Rust 异步时的主导话题。
有趣的是,无论是异步 Rust 的支持者(“使用异步获得飞一般的速度”)还是批评者(“除非你是 FAANG,否则不需要异步 Rust 的性能”“性能提升不值得它带来的问题”或“线程会一样快甚至更快”),都在强调性能方面。而我其实某种程度上同意异步批评者的观点。我也认为许多使用异步 Rust 的应用与使用线程和阻塞 I/O 相比,并没有(甚至根本不需要)获得显著的性能提升。那种“只要在代码中加入 await,它就会突然变得 X 倍快”的观念,至少在我看来,有时在推广异步 Rust 时似乎隐含其中,但并不现实。
需要澄清的是,任何潜在的性能提升都应来自使用非阻塞 I/O 和可中断函数(状态机),它们可以帮助重叠执行,从而实现更好的并发。异步/await 只是利用这些概念的便捷机制。因此,没有并发执行潜力的代码引入异步并不会提升性能。事实上,异步本身会引入(通常相对较小的)开销。
尽管我在超级计算机和高性能计算(HPC)的各种分布式系统中大量使用异步 Rust,而这一领域以高性能著称,但异步的实际性能影响从来不是我使用它的主要原因。对我来说,主要原因是原始动机中的用户空间并发部分。我经常想在我的应用中表达复杂的并发场景,无论是分布式系统、Web 服务还是 CLI 工具。同时执行两个操作并看哪个先完成;从两个数据流读取消息,同时向另一个数据流发送响应,同时定期做完全不同的事情;实现超时或心跳机制;一次性启动多个操作,使它们并行进行,并定期观察它们的进度。这类事情。
虽然其中一些用例与性能相关,甚至可能让我的程序更快,但我首先想表达的是这些并发逻辑。这种并发逻辑通常很难用阻塞操作(I/O)实现,所以我需要用非阻塞操作(I/O)代替。而异步允许我不用手动编写状态机和事件循环就能做到,后者极易出错。特别是,它让我能够轻松管理、表达和最重要的是组合并发进程,使用“看起来是顺序的”代码,这种代码相对容易理解和维护。所以,不是我担心没有异步我的并发代码会太慢,而是我常常不知道没有异步该如何合理地表达它!
用异步表达并发
我将以一个超时用例来展示我喜欢的异步用法。尽管它是并发程序中最基本的用例之一,但它使用了异步代码最重要的方面。以下是如何用异步 Rust 和 tokio 表达:
// 堆代码 duidaima.com
let future = do_something();
let result = tokio::time::timeout(Duration::from_secs(10), future).await;
这段代码看起来非常简单,之所以能做到这么简单,是因为 Rust 中 Future 的契约。每个 Future 都可以通过丢弃它(即,无需 Future 本身的合作就能停止)来取消,并且它的进度必须通过外部轮询来驱动(即,它不能自行取得进展)。这两个属性对于超时都很有用。我可以简单地停止轮询任何 Future 来暂停它,这给了我很大的控制权。我可以将单一的超时实现与任何类型的 Future 组合,而无需了解它是如何工作的,关键在于 Future 本身也不知道它正在被超时。我可以将 do_something 替换为任何其他 Future,它会以相同的方式工作。这允许并发操作松耦合(大部分),同时不暴露它们的实现细节。
我们可以通过(同步地)丢弃 Future(因此不再轮询它)来取消任何 Future,而无需了解其实现细节,这确保了超时过后,Future 不会继续意外地取得进展,这是我们通常想要避免的。异步的另一个巨大优势是 await 形成了非常明确的挂起点。特别是如果我使用单线程运行时(这是我偏好的),我可以确信在它们之间不会有其他代码运行,这使得思考潜在的竞态条件和确保不变量得以维护变得更加容易。
当然,如果你之前接触过异步 Rust,你就知道上述好处也带来了许多权衡。稍后我会讨论这些,但首先我想展示两个来自 HyperQueue(HQ)的实际用例,这是一个我参与工作的分布式 HPC 任务调度器。
在等待某事的同时执行周期性活动
在 HQ 的 UI 仪表盘中,我们需要从 TCP/IP 套接字读取新事件(例如,任务完成执行),并定期将获取的事件批量发送到应用程序的另一部分,然后在终端用户界面中显示它们。一个简化的版本(忽略错误处理和不相关概念)如下所示:
let client = create_socket();
let mut events = vec![];
let mut tick = tokio::time::interval(Duration::from_millis(500));
loop {
tokio::select! {
_ = tick.tick() => {
channel.send(std::mem::take(&mut events)).await;
}
event = client.recv() => {
events.push(event);
}
}
}
这种方式使用 select 在循环中可能会导致关于 Future 取消的问题(尽管在这种情况下没问题)。它可以以更好的方式实现,但我没有想让示例变得复杂。
这里我需要在等待接收下一个事件时执行周期性操作(发送事件批次)——我不想在能够发布当前事件批次到 TUI 之前就被卡在接收下一个事件上。在等待一组其他操作完成的同时执行周期性操作是一个非常常见的模式,我一直在使用,而使用异步,实现起来相当简单。
暂时暂停 Future
有时我们需要确保某段代码在给定时间段内不会执行。在 HyperQueue 服务器中,有一个事件流系统,将事件写入磁盘上的二进制文件。当客户端连接到服务器时,它应该接收迄今为止发生的所有事件的回放。因为服务器不会在内存中保留所有事件,所以它首先必须从磁盘上的文件回放所有之前的事件。只有这样,它才开始直接向客户端流式传输新事件。然而,存在一个潜在的竞态条件:如果在文件完全回放且客户端流式传输开始之前生成了新事件,客户端可能会错过它们。此外,我们不应该在读取文件的同时向事件文件写入。因此,我们需要在回放完成之前暂停向文件写入事件。简化形式如下:
loop {
tokio::select! {
event = events.recv() => {
write_event_to_file(event).await;
}
client = receive_new_client() => {
// 在这里,我们确信 select! 的第一个分支在回放完成之前不会执行,
// 因为我们只是不轮询它。
replay_events(&mut client).await;
}
}
}
在实际实现中,这与将事件缓冲区定期刷新到文件的操作相结合,对应于前面的“周期性活动”用例。使用异步时,这意味着 select! 表达式中的另一个分支。
Future 必须明确轮询这一事实在这里非常有用——如果我不轮询 Future,我可以确信它不会在我不想让它进展时取得进展。如果文件写入 Future 在单独的线程上运行(或者甚至使用 tokio::spawn 等启动的单独异步任务),我就无法以这种方式控制它的行为。我表达的一些并发模式用例(即不仅仅是“正常的阻塞样代码 + .await”)是建立在这些标准原语的组合之上的,比如 select(竞争)、join、执行周期性活动、超时或类似的东西。异步使得表达这种并发行为变得容易。
为什么不使用异步
现在是讨论不使用异步的时候了。在线上不难找到不使用异步 Rust 的理由,因为存在一种对它的负面趋势。有许多博客文章和文章都给异步 Rust 和异步/await 概念整体带来了坏名声(比如 1、2、3、4、5、6、7 或 8,还有经典的函数着色文章)。这些文章通常声称异步有很多问题,带来了不必要的复杂性,我们从中获得的性能提升不值得费力。
正如我之前所说,如果仅以神奇的性能提升来激励异步,那可能对许多用例来说都不是合适的。如果性能是异步 Rust 的主要好处,我可能也会保持怀疑,因为它确实带来了许多问题!Future 实现必须以避免阻塞的方式编写。Future 的实现细节实际上可能会泄露——例如,如果 Future 需要启动异步任务或执行一些基于时间的操作,它将不得不在特定运行时(通常是 tokio)的上下文中被轮询。如果它确实启动了异步任务,我们就失去了对该任务执行的精确控制,也可能失去了对其取消的控制(取决于启动它的 Future 如何处理结构化并发)。
此外,我们可以通过丢弃 Future 来取消它们,而无需让它们执行异步清理,这可能是个隐患,所以我们有时必须关心取消安全。Pin 使用起来令人困惑且难以掌握。select! 宏相当混乱。异步 Rust 目前还缺少许多部分,我经常为此感到烦恼,我希望能使用异步迭代器,对异步闭包和异步 Drop 的需求稍低一些。我想不再考虑取消安全,并且有一种像样的方式来调试异步堆栈跟踪和管理结构化并发。其中一些问题很难在不进行向后不兼容更改的情况下解决,而另一些问题尚未解决,仅仅是因为还没有人有时间将它们推动完成。虽然其中一些问题实际上与其他语言中的异步/await 共享,但它们在 Rust 中可能会感觉更加痛苦,这既是因为 Rust 独特的设计约束,也因为“正常的(同步)Rust”以相对较少的隐患而闻名,所以从同步 Rust 转到异步 Rust 可能会带来复杂性和烦恼的急剧增加。
话虽如此,我个人并不认为异步 Rust 中常被讨厌的方面都同样有问题,我认为如果我们改变使用它的方式,就可以消除其中一些提到的问题。具体来说,我通常不太关心 Send + Sync 问题,因为我几乎只使用单线程执行器(并且我同意它们应该是默认的)。它们不需要使用多线程锁定,这在性能方面通常是好事,它们不需要关心 Send 和 Sync,最重要的是,它们使得最终系统更容易推理,从而更容易避免高度并发代码中的竞态条件。另一方面,我认为函数着色对于 Rust 来说是正确的选择,尽管我在带有 GC 的语言中发现它不太吸引人,因为后者可以让异步编程更加神奇,从而可能避免着色。我还认为“运行时(例如 tokio)供应商锁定”不是什么大问题,要求避免这一点太过分了。
考虑到以上所有内容,这些都是很多问题和限制,有时会非常烦人!因此,很容易理解为什么异步 Rust 经常受到批评。但我认为在讨论异步时,也重要的是要承认异步带来的好处,以便我们能够恰当地权衡利弊。尽管我完全意识到异步的问题,我仍然大量使用它,并发现它非常有价值,因为它让我能够轻松实现和组合并发操作。它肯定不完美,目前还缺乏清晰表达某些模式的能力,并且需要考虑一些方面,这些方面可能并不总是可以由编译器检查,但我已经无法想象没有它来编写并发应用程序了。
请注意,根据我上面所写内容的一个推论是,如果你不需要表达复杂的并发模式,那么使用异步可能就是纯粹的开销(无论是代码复杂性还是实际性能方面)。当然,如果你想要使用的 crate 或依赖项是异步的,那么你可能别无选择——但这又是另一个博客文章的话题了。
所以,如果我要停止使用异步(这是上述几篇博客文章中的一个建议),我将需要使用一些替代方案。这就是我面临的问题——我看不出有什么可行的替代方案(在 Rust 中),也不明白上述博客文章中提到的替代方案如何能够以可维护的方式提供给我表达并发的能力。
替代方案
让我们来审视一下通常由批评异步 Rust 的文章所建议的一些替代方案:
使用非阻塞 I/O 与手动事件循环(1、2)。确实,非阻塞 I/O 正是我想要的,如果我编写自己的事件循环和自己的状态机,我可以绕过与异步/await 相关的大部分复杂性。这种方法还有避免广泛使用引用计数的好处,因为它允许将良好的旧引用传递给执行非阻塞 I/O 的函数。然而,编写这样的代码既冗长又极易出错!虽然它很可能不会在 Rust 中导致未定义行为和内存错误,不像在 C 或 C++ 中那样,但我真的无法想象用这种低级方法编写我多年来创建的所有异步应用程序。异步/await 的创建正是为了让我们不必处理这些,避免与状态机相关的错误,这些错误很容易犯!
对我来说,建议手动编写状态机和事件循环以避免异步的复杂性和问题,就像说应该使用 malloc 和 free 来避免 Rust 的粗糙边缘,或者使用 C 来避免 Rust 的复杂性一样。是的,nginx 使用非阻塞 I/O,并且是在没有 await 的情况下编写的,但这是否意味着我想以 nginx 的风格编写每一个异步应用程序呢?不,绝对不想,谢谢。有些人认为 Rust 是一种严格的低级系统编程语言,编写这样的代码应该是常态,但我觉得 Rust 可以为我们提供更多的东西,实际上可以将低级控制与高级便利性结合起来。所以,这对我来说不是一个可行的替代方案。
使用通过消息传递通信的分离进程,也就是 CSP。这是一个很好的建议,我确实在我的代码中大量使用类似 CSP 的演员。但这并不意味着我不需要表达其他形式的通信或并发!实际上,我经常将演员实现为通过通道通信的单独异步任务,但它们然后也在内部执行各种并发操作,我使用异步来表达这些并发模式。因此,我不将 CSP 视为异步/await 的替代品,而是作为一种补充设计复杂并发程序结构的方式。
使用其他语言。当然,这是一个选择,但如果我想(或需要)因为其他原因使用 Rust(比如它默认高性能,允许我轻松编写健全且通常是即时正确的代码,拥有令人难以置信的工具等),那么这个选择就不那么相关了。我们也不应该忘记这里存在某些权衡。异步 Rust 试图实现一些非常困难的事情——将协程的动态本质和可中断的非阻塞 I/O 与基于编译时检查的严格静态分析系统结合起来,以确定数据的生命周期和所有权,而这一切都是基于变量的(主要是词法)作用域。而且,它在不需要运行时或 GC 的情况下做到了这一点,支持嵌入式系统,提供低开销,并呈现出一个非常高级且相对便捷的接口(我说的是 async fn,不是 Pin,当然)。
我认为异步 Rust 在这方面做得相当出色,尽管存在不足,尽管它可能无法完全做到这一点,但它在许多不同的用例中能够实现的成就是令人印象深刻的。是的,使用 Go 或例如 Java 的 Project Loom 不需要你考虑函数着色,并且似乎使处理并发变得更简单,但这也要以 GC 为代价,并且由于不使用本地(C)栈而导致更多的痛苦。而且,它也不会给你提供相同程度的控制,以精确地执行并发(例如,当你的运行时像 JavaScript 那样自动在后台启动 Future 时)。
这让我想到了最后一个(也是最常被提及的)异步 Rust 的替代方案——“直接使用线程”。更准确地说,我一直认为这意味着“线程与阻塞 I/O 结合使用”,因为使用非阻塞 I/O 实质上就是上面提到的“手动 epoll/状态机”替代方案。我必须承认,我对此建议感到困惑。要么是我用异步 Rust 表达的东西异常复杂,要么是我错过了某种显而易见的方法,如何以这种方式使用线程和阻塞 I/O 来处理这类用例,而不让我失去理智。我实在无法想象用这种方式编写我之前展示的并发逻辑。
为了澄清,我对这种替代方案的主要问题不是使用线程本身(因为它们在 Rust 中相当便捷且独特地安全),而是使用阻塞 I/O 而不是非阻塞 I/O(或者,更一般地说,使用不可中断的操作而不是可中断的操作)。阻塞 I/O 从设计上就抑制了并发,因此使用线程成为实现任何并发的必要条件,但用线程表达各种并发原语比用协程更困难,至少在我的经验中是这样。
下面,我将尝试用阻塞 I/O 和线程实现超时示例,使用两种不同的方法。
使用支持超时的 I/O 操作
如果你有一个特定的 I/O 操作允许表达超时,例如标准库中 TCP/IP 流的读操作,你可以直接设置超时,然后执行阻塞操作:
let stream = TcpStream::connect(addr)?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
let data = stream.read(…,)?;
这段代码看起来很简单,但对于这个三行代码片段来说,它并不能扩展得太远。一个真正的套接字通信实现会做更多的事情——连接到远程主机,执行多次读取调用来从它那里获取一个完整的(例如,以行为分隔的)消息,然后将该消息返回给调用者。如果我想对这个整个组合过程设置超时,我将需要使用不同的连接方法(因为可能需要多次读取调用来下载整个消息),并且我还需要在每次读取调用之前调用 set_read_timeout,并动态重新计算剩余时间,以确定何时应该发生超时。
但这里的实现复杂性还不是最糟糕的。在我看来,主要问题是,我必须直接在 I/O 操作的实现中添加超时知识。我无法像使用异步那样从外部简单地添加它。为了在任何地方支持超时,我需要在所有并发操作中都这样做。而且即使这样,它也只能用于超时;它不允许我直接指定其他并发模式,例如等待几个这样的操作中的第一个完成执行或取消。当我设计并发应用程序的结构时,我想能够快速尝试各种组合各个异步进程和演员的方法。将每个这样的进程都实现对并发原语的支持将既繁琐又缓慢。
从实际使用这种并发进程的角度来看,这种方法也不是很好。如果我想将消息读取逻辑放在任何类型的抽象(函数/结构体/枚举/特质)后面,以便我可以在应用程序的各个地方(重)使用它,那么我就无法直接从外部设置超时,除非我知道抽象内部使用了哪个特定操作。相反,我需要向该抽象添加暴露超时的能力。虽然这并非世界末日,但它确实使接口复杂化,并且需要为我想要支持的所有类型的并发原语都这样做。
这种方法的最后一个有问题的方面是,即使设置了超时,I/O 操作仍然会阻塞当前线程,尽管这已经意味着使用阻塞 I/O 并且避免了手动编写复杂的状态机。这不是什么大问题,因为通过简单地运行多个线程,每个线程执行一个特定的异步操作,可以恢复一定程度的并发。但它确实需要启动单独的执行上下文才能获得任何并发,这限制了我对这些异步操作的控制(tokio::spawn 等 API 的行为也是如此)。
当然,使用这种方法还需要你使用的(I/O)操作本身“原生”支持超时。例如,标准库中的 TcpListener::accept 方法就不允许表达超时。如果你使用它,它将阻塞当前线程,直到客户端连接。我甚至给学生们布置了一个作业,让他们尝试解决这个问题。当这种情况发生时,你将不得不使用下面描述的另一种方法。
通道
通道可以一定程度上解决上述方法的组合性问题。为“任意”阻塞操作添加超时的常见方法是在线程内运行它,创建一个通道,将发送端发送到线程,并让它在完成后将结果发布到通道中。然后我们可以使用接收端在另一个线程中读取结果,使用方便的 recv_timeout 方法,这允许表达超时:
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let tl = std::thread::spawn(move || {
let result = do_something();
tx.send(result).unwrap();
});
let result = rx.recv_timeout(Duration::from_secs(10))?;
tl.join().unwrap();
这实际上相当有效。我们甚至可以将其包装在一个超时帮助方法中,以实现与使用 tokio 时非常相似的接口。然而,除了我们必须启动(或至少从线程池中获取)一个线程并分配一个通道来执行一个简单的超时之外,更大的问题是最后一行,tl.join().unwrap()。因为即使我们在等待结果时超时,操作本身仍将继
续进行,除非我们以某种方式取消它。因此,为了获得所需的功能(实际上在超时后停止操作),我们需要以一种方式实现并发操作,以便它能够明确知道何时需要停止,并设计某种机制来传达这些信息。这或许可以通过使用类似于 .NET 生态系统中的 CancellationToken 来实现,但这需要在 Rust 生态系统中广泛使用,以便我们常用的所有的原语实际上都可以被取消。而且,这也增加了几乎所有异步操作实现的复杂性,这些操作都需要考虑该令牌,并至少将其传递给嵌套的异步操作。
此外,有些操作根本无法取消;没有取消令牌可以传递给 Linux 上的 read 系统调用。
公平地说,使用线程支持的通道可以走得很远,但这远不如使用异步简单,而且你失去了对 Future 执行的精确控制。当然,使用线程可以实现任何可以用异步完成的并发模式(图灵完备性等等),但至少对我来说,在实践中,这通常只是太繁琐了。因此,我宁愿处理异步的限制,也不愿仅使用线程来实现复杂的并发场景。
结论
在我看来,我们有时对异步 Rust 的期望过高。我们希望它极其快速且完全零成本,可以在从微控制器到超级计算机的任何地方使用,易于理解和使用,不包含任何隐患,并且可以与任何类型的运行时一起使用。但尽管 Rust 有时似乎可以创造奇迹,凡事都有其限制:我们不应期望一个特性在所有可能的方面都是最佳的。我认为,尽管存在一些问题,异步 Rust 提供了许多实际的好处,在讨论时不应忽视。我个人喜欢使用异步,如果不是必须的话,我也不想回到使用线程以粗暴方式处理并发的时代。
无论如何,我的这些杂谈就到这里。这是一个非常复杂的话题,在这篇文章中我省略了许多方面,例如,将吞吐量和延迟优化(都与并发和非阻塞 I/O 相关)简化为仅仅是“性能”,也没有涉及嵌入式用例(我认为在嵌入式领域,异步甚至更有用),但我希望我传达了我的核心观点。