Skip to content

25.5 异步编程

async 和 await 关键字是目前许多语言都采用的主流方案,使用关键字而不是用宏来做 API,有助于社区的统一性,避免不同的异步方案使用完全不一样的用户 API。

在 Rust 语言和标准库中,只有极少数必须的关键字、trait 和类型,这也是 Rust 一贯的设计思路。但凡是可以在第三方库中实现的,一律在第三方库中实现,哪怕这个库本来就是官方核心组维护的,这样做可以让这个库的版本升级更灵活,有助于标准库的稳定性。

从上一章节我们知道,Rust 的异步编程模型基于 Future 机制(底层实现是通过 Coroutine 状态机),而 async/await 语法糖让异步代码看起来像是同步的。

25.5.1 核心构件 Future

Future 是一个 Trait,Future 可以组合,一个 Future 可以由其他的一个或者多个 Future 包装而成。跟我们已经见过的迭代器 Iterator 很像。比如,我们可以实现一个新的 Future,它的结果是多个 Future 按顺序执行得到的。或者,实现一个 Future,它的结果是两个子 Future 中先返回的那个。Future 组合的方式可以非常灵活。

其简化定义如下:

rust
// rust-lib/core/src/future/future.rs
use crate::ops;
use crate::pin::Pin;
use crate::task::{Context, Poll};

pub trait Future {
    // 这就是“凭证”未来能兑换的“商品”类型
    type Output;

    // “轮询”一下,看看“商品”到货了没
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

这里有两个关键点:

  1. Output 类型:定义了这个 Future 完成后会返回什么类型的值。比如,一个网络请求的 Future,其 Output 可能是一个 StringVec<u8>

  2. poll 方法:这是 Future 的心脏。它的作用就是去“问一下”:“我的任务完成了吗?” poll 方法的返回值 Poll<T> 是一个枚举:

    rust
    pub enum Poll<T> {
        Ready(T),
        Pending,
    }

    想象一下,你不断地去问餐厅前台:“我的外卖好了吗?”

    你  -> poll() -> 前台
                      |
                      +--> "还没好呢!" (返回 Poll::Pending)
                      |
                      +--> "好了,给你!" (返回 Poll::Ready(你的外卖))

但是我们不断使用 poll 来查询状态,效率太低了。你告诉前台:“我的外卖好了,请打电话通知我。” 这个“电话号码”就是 Waker

poll 方法的参数 cx: &mut Context<'_> 就包含了这个 Waker

  • 当一个 Futurepoll 方法被调用,发现任务还没准备好(例如,网络数据还没到),它会返回 Poll::Pending
  • 在返回之前,它会“克隆”一份 Waker 并存起来。
  • 当底层事件完成时(例如,操作系统通知网络数据已到达),它会调用 Wakerwake() 方法。
  • 这个 wake() 调用就像是给执行器(Executor,下一节讲)打了个电话:“嘿,那个任务现在有进展了,快再来 poll 它一次吧!”

我们有了可以被暂停和唤醒的任务(Future),但谁来管理和驱动它们呢?答案是 Executor(调度器/执行器),也常被称为 Runtime(运行时)。它的具体实现可以由第三方库来实现。Executor 就像是厨房中统筹全局的厨师长。他手里有一份“任务清单”(一堆 Futures),他会不断地轮询这些任务,推动它们向前执行。

一个 Executor 的基本工作流程:

  1. 接收任务:你将一个顶层的 Future 交给 Executor
  2. 事件循环Executor 启动一个循环,从任务队列中取出一个任务。
  3. 驱动任务:调用任务的 poll 方法。
  4. 处理结果
    • Poll::Ready(value):任务完成!Executor 可以将结果交给用户,或者处理下一个任务。
    • Poll::Pending:任务被暂停。Executor 会把它放到一边,这样就不会占用 CPU,等待着被 Waker 唤醒。当 Waker 被调用时,Executor 会把这个任务重新放回待执行的队列里。
  5. 重复Executor 不断重复这个过程,确保所有任务都能在适当的时候得到执行。

Rust 标准库只提供了 Future trait 等核心构建块,但 没有内置 Executor。这是 Rust 的设计哲学:保持标准库的精简和稳定。

你需要选择一个第三方的 Runtime 库来实际运行你的异步代码。最流行的选择是:

  • tokio: 社区生态最庞大,功能全面,为生产环境设计。
  • async-std: API 设计上力求与标准库同步 std 模块相似,学习曲线平缓。

大家可以看到,Future 跟 Coroutine 一样,具备同样的特性,也就是说可以在某个地方主动中断执行,待下一次再进来的时候,刚好可以从上次退出的地方恢复执行。这就是为什么 Rust 的 Future 最终是基于 Coroutine 实现的。 在 Rust 里面,Coroutine 是 Future 的基础设施。

一般情况下,实现任务调度以及为通过各种异步操作实现 Future trait 并不是最终用户关注的问题,这些应该都已经被网络开发框架完成,比如 tokio。大部分用户需要关注的是如何利用这些框架完成业务逻辑。

25.5.2 语法糖 async/await

手动实现 Future 和管理 poll 逻辑非常繁琐。所以有了 async/await 语法,可以让编译器为我们处理所有复杂的工作。

调用一个 async 函数 仅仅是创建了一个 Future 实例,它什么都还没做!就像你拿到了“取餐凭证”,但饭还没开始做。你必须把这个返回的 Future 交给一个 Executor 来运行,它才会真正被执行。

早期的 await!

在 Rust 的异步编程早期(2018-2019 年左右),Rust 还没有正式支持 async/await 语法。当时,异步编程主要通过 futures 库实现。为了方便开发者在异步代码中等待某个 Future 的结果,futures 库提供了一个宏 await!。这个宏的作用是将异步的 Future 转换为阻塞式的结果,类似于后来正式引入的 .await 关键字。

早期,你可能会看到这样的代码:

rust
use futures::Future;

fn some_async_function() -> impl Future<Output = i32> {
    async { 42 }
}

fn main() {
    let result = futures::executor::block_on(async {
        let value = await!(some_async_function());
        value
    });
    println!("{}", result);
}

对于 await 这个宏,我们可以在标准库中看到它的实现:

rust
macro_rules! await {
    ($e:expr) => { {
        let mut pinned = $e;
        let mut pinned = unsafe { $crate::mem::PinMut::new_unchecked(&mut pinned) };
        loop {
            match $crate::future::poll_in_task_cx(&mut pinned) {
                $crate::task::Poll::Pending => yield,
                $crate::task::Poll::Ready(x) => break x,
            }
        }
    } }
}

如果你在非常老旧的代码库中工作,可能仍然会遇到 await! 宏。这种情况下,你需要确保项目依赖了旧版的 futures 库(例如 futures 0.1)。但我强烈建议将代码升级到现代 Rust,使用 .await 语法,因为 await! 已经不再维护,且旧版库可能存在兼容性问题。

现代的 async/await

自 Rust 1.39.0 开始,Rust 正式引入了 async/await 语法,await! 宏被废弃,取而代之的是 .await 关键字。从语法上讲,.await 只能出现在 async 函数、async 闭包或 async 代码块里,不然编译器直接报错。

编译器会将一个 async 函数转换成一个状态机(State Machine)

rust
async fn fetch_and_process() {
    // --- 状态 0 ---
    let url = "https://www.rust-lang.org";
    let response = fetch(url).await; // 暂停点 1

    // --- 状态 1 ---
    let text = response.text().await; // 暂停点 2

    // --- 状态 2 ---
    println!("网页内容: {}", text);
    // --- 最终状态 ---
}

这个状态机实现了 Future trait。每次 Executorpoll 它:

  1. 如果处于状态 0,它会执行 fetch(url),然后 poll 返回的 Future
  2. 如果返回 Poll::Pending,那么整个 fetch_and_process 函数就会暂停,整个状态机就 暂停(yield) 在状态 0,并把控制权交还给 Executor,让它可以去执行其他任务。
  3. fetch(url)FutureWaker 唤醒(resume) 并最终返回 Poll::Ready(value) 时,Executor 会回到 fetch_and_process 函数暂停的地方,把 value 赋值给 response,然后继续向下,会进入状态 1。
  • 如果处于状态 1,它会执行 response.text(),然后 poll 返回的 Future,逻辑同上。
  • ...以此类推,直到最终状态。

可以看到,使用 async/await 来写异步逻辑,一方面可以保证高效率,另一方面,代码流程还是跟普通的同步逻辑类似,比较符合直觉。

每次碰到 .await,就是告诉调度器:“我等一会儿,别让我的代码占用 CPU,其他任务可以先执行;东西准备好了再叫我回来。”。这一块被编译器包装进生成的状态机,巧妙地隐藏了所有 poll, Pending, ReadyWaker 的细节,自动帮你管理状态、挂起、唤醒流程。

下面给大家解释一下 async 和 await 分别做了什么事情。

async 关键字可以修饰函数、闭包以及代码块。对于函数:

rust
async fn read_from_db(id: u32) -> String {
    // ... 模拟数据库查询 ...
    "some data".to_string()
}

在编译器看来,等价于这个返回 impl Future 的普通函数::

rust
fn read_from_db_desugared(id: u32) -> impl Future<Output = String> {
    // 编译器在这里会为你生成一个实现了 Future 的匿名状态机
    // ...
}

这两种写法实际上是一模一样的。凡是被 async 修饰的函数,返回的都是一个实现了 Future trait 的类型。 由 async 修饰的闭包也是一样的。async 代码块同样类似。它相当于创建了一个语句块表达式,这个表达式的返回类型是 impl Future

async 关键字不仅对函数签名做了一个改变,而且对函数体也自动做了一个包装,被 async 关键字包起来的部分,会自动产生一个 Coroutine,并把这个 Coroutine 包装成一个满足 Future 约束的结构体。在函数体中用户需要返回的是 Future::Output 类型,也就是异步任务成功后得到的结果,比如 Stringio::Result<T> 等等。

25.5.3 总结

让我们回顾一下 Rust 异步编程的全景图:

  1. 为什么异步? 为了高效处理大量并发 I/O 任务,避免线程阻塞,就像一个聪明的厨师。
  2. Future 是什么? 是一个异步任务的“凭证”,代表一个未来会完成的值。它可以通过 poll 方法查询状态(ReadyPending)。
  3. Executor 是什么? 是任务的“调度器”或“运行时”(如 tokio),负责驱动 Futures 执行,并在它们 Pending 时暂停,在被 Waker 唤醒后继续。
  4. async/await 是什么? 是 Rust 提供的“语法糖”,让我们能用近乎同步的方式编写异步代码。async 将函数变为 Future 生成器,.await 则优雅地处理了任务的暂停和恢复。

通过这套精心设计的系统,Rust 成功地在提供极致性能的同时,保持了其赖以成名的内存安全保证,让编写大规模、高并发的程序变得前所未有的简单和安全。

Released under the MIT License