80、Asynchronous functions(异步函数)
异步函数
我们目前编写的所有函数和方法都是“急切型”的。
在我们调用它们之前,它们什么都不会发生。但一旦调用,它们就会一直运行到完成:完成所有工作,然后返回结果。
有时,这种做法并不理想。
例如,如果我们正在编写一个 HTTP 服务器,可能会有很多等待:等待请求体到达、等待数据库响应、等待下游服务响应等等。
如果在等待期间可以做其他事情呢?
如果可以选择在计算过程中途放弃呢?
如果可以选择优先执行其他任务而不是当前任务呢?
这就是异步函数的用武之地。
async fn
我们可以使用 async 关键字来定义异步函数:
use tokio::net::TcpListener;
// 这个函数是异步的
async fn bind_random() -> TcpListener {
// [...]
}
如果像调用普通函数一样调用 bind_random 会发生什么?
fn run() {
// 调用 `bind_random`
let listener = bind_random();
// 执行到这里呢?
}
什么都没发生!
Rust 不会在你调用 bind_random 时立即执行它,即使是作为后台任务也不会(这与我们基于其他语言经验的预期不同)。Rust 中的异步函数是惰性的:它们不会执行任何操作,直到我们明确地请求它们执行。用 Rust 的术语来说,bind_random 返回的是一个 Future,它代表一个可能稍后完成的计算。之所以称之为 Future,是因为它们实现了 Future trait,我们将在本章后面详细学习这个接口。
.await
让异步函数执行某些操作的最常用方法是使用 .await 关键字:
use tokio::net::TcpListener;
async fn bind_random() -> TcpListener {
// [...]
}
async fn run() {
// 调用 `bind_random` 并等待其完成
let listener = bind_random().await;
// 现在 `listener` 已准备就绪
}
.await 不会将控制权返回给调用者,直到异步函数运行完成为止——例如,直到上面的示例中创建了 TcpListener 为止。
Runtimes
到这里也挺困惑的,我们刚才说了异步函数的优势在于它们不会一次性完成所有工作。然后我们又介绍了 .await,它会在异步函数执行完毕之前一直保持等待状态。这岂不是又把我们原本想要解决的问题重新引入了?这到底有什么意义呢?
并非如此!调用 .await! 时,幕后会发生很多事情。 我们实际上是将控制权交给了异步运行时async executor,也称为异步执行器async exector。执行器才是真正发挥作用的地方:它们负责管理所有正在进行的异步任务。具体来说,它们需要平衡两个不同的目标:
- Progress:他们会确保各项任务尽可能取得进展。
- Efficiency:如果某项任务需要等待某个条件,他们会尽量确保其他任务在此期间可以运行,从而充分利用可用资源。
没有默认运行时
Rust 在异步编程方面相当独特:它没有默认的运行时库。标准库中也没有提供。我们需要自行选择!
大多数情况下,我们会从生态系统中选择一个运行时库。有些运行时库设计得比较通用,适用于大多数应用场景。tokio 和 async-std 就属于这一类。其他运行时库则针对特定用例进行了优化,例如 Embassy 专为嵌入式系统而设计。
在本课程中,我们将使用 tokio,它是 Rust 中最流行的通用异步编程运行时库。
#[tokio::main]
可执行文件的入口点,也就是主函数,必须是一个同步函数(synchronous function)。我们需要在这里设置并启动我们选择的异步运行时。
大多数运行时都提供了一个宏来简化这个过程。例如,对于 tokio 运行时,可以使用 tokio::main。
#[tokio::main]
async fn main() {
// 在这里编写异步代码
}
展开之后为:
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(
// 这里放置异步函数
// [...]
);
}
#[tokio::test]
测试也一样:它们必须是同步函数。 每个测试函数都在其自身的线程中运行,如果您需要在测试中运行异步代码,则需要自行设置和启动异步运行时。
tokio 提供了一个 #[tokio::test] 宏来简化此操作:
#[tokio::test]
async fn my_test() {
// 这里放置异步测试代码
}
