Rust中的异步编程——Coroutines 和 async/await

721 阅读29分钟

现在你已经对Rust的异步模型有了一个简要的了解,是时候来看看它如何融入到本书迄今为止涉及的内容中。

Rust的futures是基于无栈协程(stackless coroutines)的异步模型的一个示例。本章将深入探讨无栈协程的真正含义,并阐明它与有栈协程(stackful coroutines,即fiber/绿色线程)的区别。

我们将围绕一个简化的futures和async/await模型示例展开,看看如何利用这些机制来创建可以暂停和恢复的任务,就像我们在实现自己的fiber时所做的一样。

好消息是,这比实现自己的fiber/绿色线程要容易得多,因为我们可以完全在Rust中操作,这样更安全。相对来说,挑战在于它的概念更加抽象,既涉及编程语言理论,也涉及计算机科学。

本章将涵盖以下内容:

  • 无栈协程简介
  • 手写协程的示例
  • async/await

技术要求

本章的所有示例都是跨平台的,因此你唯一需要的就是安装了Rust,并在本地下载了与本书相关的代码库。本章的所有代码都可以在ch07文件夹中找到。

在本示例中,我们还将使用delayserver,所以你需要打开终端,进入代码库根目录中的delayserver文件夹,并输入cargo run以启动它,使其为接下来的示例做好准备。

如果由于某种原因需要更改delayserver的监听端口,记得在代码中相应地更改端口设置。

Stackless 协程简介

终于到了本书中介绍最后一种异步操作建模方法的部分。你可能还记得在第 2 章中我们大致介绍了有栈协程(stackful coroutines)和无栈协程(stackless coroutines)。在第 5 章中,我们通过编写自己的 fibers/green threads 实现了有栈协程的示例,现在是时候深入探讨无栈协程的实现和使用了。

无栈协程是一种表示可以中断和恢复的任务的方式。如果你还记得在第 1 章中提到过的内容,我们说到,如果希望任务可以同时进行(即在同一时间点处于进展状态)而不必并行执行,我们需要能够暂停和恢复任务。

从最简单的形式来说,协程就是一种可以通过将控制权让渡给其调用方、另一个协程或调度器来暂停和恢复的任务。

许多编程语言会提供协程实现,并附带一个帮助调度和处理非阻塞 I/O 的运行时,但我们最好将协程本身与用于构建异步系统的其他机制区分开来。

在 Rust 中尤其如此,因为 Rust 并没有内置运行时,而是仅提供用于创建在语言中具有原生支持的协程所需的基础设施。Rust 确保每位 Rust 开发者都使用相同的抽象来表示可以暂停和恢复的任务,但将构建和运行异步系统的其他细节交由程序员自己处理。

无栈协程还是协程?

大多数时候,你会看到无栈协程被简单称为协程。为了保持一致性(正如你所知,我不喜欢引入基于上下文含义不同的术语),我会一直称协程为有栈协程或无栈协程,但从此以后,我会简单地将无栈协程称为协程。这也是你在阅读其他资料时对它们的期望。

Fibers/green threads 的任务暂停和恢复方式与操作系统的实现方式非常相似。任务拥有一个栈,可以在其中存储/恢复其当前的执行状态,使得暂停和恢复任务成为可能。

状态机的最简单形式是一个具有一组预定义状态的数据结构。在协程中,每个状态代表一个可能的暂停/恢复点。我们不会将暂停/恢复任务所需的状态存储在单独的栈中,而是将其保存在数据结构中。

这种方式有一些优点,我之前提到过,最显著的优点是它们非常高效且灵活。缺点是你可能并不想手动编写这些状态机(在本章中你会明白为什么),因此需要某种编译器支持或其他机制来将代码重写为状态机,而不是普通的函数调用。

这样做的结果看似简单:它看起来像一个函数/子例程,容易映射到简单的调用指令,但实际效果是比预期更复杂,最终呈现的内容与预期并不相似。

生成器 vs 协程

生成器本质上也是状态机,正是我们将在本章中讨论的那种。通常它们会在语言中实现,用于创建向调用函数返回值的状态机。

理论上,你可以根据生成器和协程的让渡对象来区分它们。生成器通常只会让渡给调用函数,而协程则可以让渡给另一个协程、调度器,或者只是让渡给调用方,这时它们就与生成器相同了。

在我看来,区分二者并没有太大意义。它们代表的都是暂停和恢复任务执行的相同底层机制,因此在本书中我们将它们视为基本相同的概念。

现在我们已经用文字说明了什么是协程,可以开始查看代码中的实现形式了。

手写协程示例

在接下来的例子中,我们将使用一个简化版的 Rust 异步模型,创建并实现以下内容:

  • 自己简化版的 Future trait
  • 一个只能发起 GET 请求的简单 HTTP 客户端
  • 一个可以实现暂停和恢复的状态机形式的任务
  • 我们自己简化版的 async/await 语法,称为 coroutine/wait
  • 自制的预处理器,用于将 coroutine/wait 函数转换为状态机,类似 async/await 的转换方式

为了真正揭开协程、futures 和 async/await 的神秘面纱,我们将做出一些简化。如果不这样,我们将不得不重新实现 Rust 中的所有 async/await 和 futures 功能,而这对于理解基础技术和概念来说过于复杂。

因此,我们的示例将遵循以下几点:

  • 避免错误处理。如果发生错误,我们将直接 panic。
  • 具体而非泛化。创建通用解决方案会引入很多复杂性,并让基本概念变得难以理解。不过,必要时我们会保留一些泛型特性。
  • 限制功能。示例中只涵盖我们需要的内容。当然,你可以随意扩展、修改和尝试这些示例(我鼓励你这样做)。
  • 避免使用宏。

既然如此,我们就可以开始示例了。

首先,创建一个新文件夹。该示例在仓库的 ch07/a-coroutine 文件夹中,因此建议将你的文件夹命名为 a-coroutine

然后,在文件夹中输入 cargo init 来初始化一个新的 crate 项目。

现在,我们的新项目已经启动并运行了,可以创建所需的模块和文件夹:

首先,在 main.rs 中声明两个模块,如下所示:

// ch07/a-coroutine/src/main.rs

mod http;
mod future;

接着,在 src 文件夹中创建两个新文件:

  • future.rs,用于存放 future 相关代码
  • http.rs,用于存放 HTTP 客户端相关代码

最后,我们需要添加 mio 依赖。我们将在接下来的章节中使用 mioTcpStream,并使用 mio 作为非阻塞 I/O 库,因为我们已经熟悉它:

# ch07/a-coroutine/Cargo.toml

[dependencies]
mio = { version = "0.8", features = ["net", "os-poll"] }

让我们先从 future.rs 开始,实现与 future 相关的代码。

Futures 模块

futures.rs 中,我们将首先定义一个 Future trait,如下所示:

// ch07/a-coroutine/src/future.rs

pub trait Future {
    type Output;
    fn poll(&mut self) -> PollState<Self::Output>;
}

如果将此与 Rust 标准库中的 Future trait 进行对比,会发现它们非常相似,只是我们没有传递 cx: &mut Context<'_> 参数,并且我们返回的枚举有一个略微不同的名称,以便于区分它们:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

接下来我们定义一个 PollState<T> 枚举:

// ch07/a-coroutine/src/future.rs

pub enum PollState<T> {
    Ready(T),
    NotReady,
}

同样,如果将此与标准库中的 Poll 枚举进行对比,可以看到它们几乎相同:

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

目前为止,这就是让示例初步运行所需要的全部内容。接下来,我们继续实现 http.rs 文件。

HTTP 模块

在这个模块中,我们将实现一个非常简单的 HTTP 客户端。这个客户端只能向我们的 delayserver 发起 GET 请求,因为我们只需要它来模拟典型的 I/O 操作,而不关心能否执行更多其他操作。

首先,我们从标准库中导入一些类型和 traits,并引入我们定义的 Future 模块:

// ch07/a-coroutine/src/http.rs

use crate::future::{Future, PollState};
use std::io::{ErrorKind, Read, Write};

接下来,创建一个用于构建 HTTP 请求的小辅助函数。我们之前在本书中已经使用过这段代码,因此这里不再详细解释:

// ch07/a-coroutine/src/http.rs

fn get_req(path: &str) -> String {
    format!(
        "GET {path} HTTP/1.1\r\n\
        Host: localhost\r\n\
        Connection: close\r\n\
        \r\n"
    )
}

现在可以开始编写 HTTP 客户端的代码了,代码非常简洁:

pub struct Http;

impl Http {
    pub fn get(path: &str) -> impl Future<Output = String> {
        HttpGetFuture::new(path)
    }
}

我们实际上不需要一个 Http 结构体,但我们添加了它,以便后续可能会添加状态,同时也将属于 HTTP 客户端的函数组合在一起。

我们的 HTTP 客户端只有一个函数 get,它将向 delayserver 发送指定路径的 GET 请求(路径指的是 URL 中主体部分,例如 http://127.0.0.1:8080/1000/HelloWorld 中的 /1000/HelloWorld)。

在函数体中,只返回了 HttpGetFuture。在函数签名中,返回类型是实现了 Future trait 的对象,并在解析完成后输出一个字符串。这个字符串是服务器返回的响应。

接下来我们深入了解 HttpGetFuture,这是我们唯一使用的叶子 future 的示例。

HttpGetFuture 结构体

将结构体声明添加到文件中:

// ch07/a-coroutine/src/http.rs

struct HttpGetFuture {
    stream: Option<mio::net::TcpStream>,
    buffer: Vec<u8>,
    path: String,
}

此数据结构用于存储以下数据:

  • stream:持有一个 Option<mio::net::TcpStream>。它是 Option 类型,因为我们不会在创建此结构时立即连接到流。
  • buffer:用于存放从 TcpStream 读取的数据,直到我们接收了服务器返回的所有数据。
  • path:存储 GET 请求的路径,以便稍后使用。

HttpGetFuture 的实现块

// ch07/a-coroutine/src/http.rs

impl HttpGetFuture {
    fn new(path: &'static str) -> Self {
        Self {
            stream: None,
            buffer: vec![],
            path: path.to_string(),
        }
    }

    fn write_request(&mut self) {
        let stream = std::net::TcpStream::connect("127.0.0.1:8080").unwrap();
        stream.set_nonblocking(true).unwrap();
        let mut stream = mio::net::TcpStream::from_std(stream);
        stream.write_all(get_req(&self.path).as_bytes()).unwrap();
        self.stream = Some(stream);
    }
}

impl 块定义了两个函数。第一个是 new,它仅设置初始状态。接下来是 write_request,用于向服务器发送 GET 请求。我们在第 4 章的示例中见过这段代码,所以应该很熟悉。

注意:当创建 HttpGetFuture 时,实际上没有执行任何 GET 请求,因此调用 Http::get 后立即返回的只是一个简单的数据结构。我们在此示例中直接使用 IP 地址,而非 DNS 名称,并让 connect 阻塞执行,其他一切为非阻塞。

实现 Future trait

接下来是实现我们定义的 Future trait 的部分:

// ch07/a-coroutine/src/http.rs

impl Future for HttpGetFuture {
    type Output = String;

    fn poll(&mut self) -> PollState<Self::Output> {
        if self.stream.is_none() {
            println!("FIRST POLL - START OPERATION");
            self.write_request();
            return PollState::NotReady;
        }

        let mut buff = vec![0u8; 4096];
        loop {
            match self.stream.as_mut().unwrap().read(&mut buff) {
                Ok(0) => {
                    let s = String::from_utf8_lossy(&self.buffer);
                    break PollState::Ready(s.to_string());
                }
                Ok(n) => {
                    self.buffer.extend(&buff[0..n]);
                    continue;
                }
                Err(e) if e.kind() == ErrorKind::WouldBlock => {
                    break PollState::NotReady;
                }
                Err(e) if e.kind() == ErrorKind::Interrupted => {
                    continue;
                }
                Err(e) => panic!("{e:?}"),
            }
        }
    }
}

这里是主要的逻辑:

  • 首先,设置 Output 类型为 String
  • 检查 poll 是否是第一次被调用,通过检查 self.stream 是否为 None 来判断。如果是第一次调用,我们打印一条消息(用于观察首次调用时的情况),然后写入 GET 请求并返回 PollState::NotReady
  • 如果不是第一次调用 poll,我们尝试从 TcpStream 读取数据。

数据读取的可能情况如下:

  1. 返回 0 字节,表示数据读取完成。此时我们将数据转为 String 并返回 PollState::Ready
  2. 读取成功并返回 n > 0 字节。我们将数据追加到 self.buffer 中,并继续读取。
  3. 返回 WouldBlock 错误,表示数据尚未准备好,返回 PollState::NotReady
  4. 返回 Interrupted 错误,表示读取被信号中断。此时我们会继续尝试读取。
  5. 返回其他无法处理的错误,直接 panic!

这里我们可以将 HttpGetFuture 视为一个简单的状态机,具有以下三个状态:

  • 未开始:即 self.streamNone
  • 等待中:self.stream 有值,并且读取操作返回 WouldBlock
  • 完成:self.stream 有值,读取返回 0 字节

所有的 future 都必须是惰性的吗?

惰性 future 是指在首次被调用 poll 之前不执行任何工作。这在 Rust 的 futures 讨论中经常出现,因为我们的 Future trait 就基于这种模型,因此同样的问题也会出现在这里。简单的回答是:不,future 不一定必须是惰性的!

没有什么强制规定叶子 future(例如我们编写的 HttpGetFuture)必须是惰性的。我们完全可以在调用 Http::get 函数时就发送 HTTP 请求。如果仔细想想,这样做会带来一个潜在的重大变化,从而影响我们在程序中实现并发的方式。

当前的实现方式要求至少调用一次 poll 才能真正发送请求。因此,调用该 future 的 poll 函数的人将需要对多个 future 调用 poll,以便同时启动这些操作。如果我们在 future 创建时立即启动操作,那么即便在逐一调用 poll 直至完成,多个 future 也能并发运行。但在当前设计中,如果逐一调用 poll 直到完成,这些 future 将无法并发运行。请仔细体会这一点。

像 JavaScript 这样的语言在创建协程时就立即启动操作,因此并没有唯一的方式。每次遇到协程实现时,应当弄清楚它们是惰性还是主动的,因为这会影响你的编程方式。

尽管我们可以让我们的 future 在这里主动执行,但其实不应该这么做。Rust 的开发者通常期望 future 是惰性的,他们可能依赖于在首次调用 poll 之前不会发生任何操作。如果你编写的 future 不是惰性的,可能会引发意外的副作用。

现在,当你听到“Rust 的 future 一定是惰性的”这样的说法(我经常看到这样的表述)时,这其实是指编译器生成的状态机,即使用 async/await 生成的 futures。正如我们将会看到的,当编译器将 async 函数重写时,生成的代码会确保在第一次调用 Future::poll 之前,async 函数体内不会执行任何代码。

好了,我们已经讨论了 Future trait 以及命名为 HttpGetFuture 的叶子 future。下一步是创建一个可以在预定义点暂停和恢复的任务。

创建协程

我们将继续从基础开始,逐步构建我们的知识与理解。首先,我们手动将任务建模为状态机,创建一个可以暂停和恢复的任务。完成这一部分后,我们将了解这种方式如何使我们能够编写类似 async/await 的语法,并依赖代码转换来生成状态机,而不是手动编写它们。

我们将创建一个简单的程序,执行以下操作:

  1. 打印消息,表明我们的可暂停任务启动。
  2. delayserver 发出 GET 请求。
  3. 等待 GET 请求完成。
  4. 打印服务器的响应。
  5. delayserver 发出第二个 GET 请求。
  6. 等待第二个响应。
  7. 打印第二次服务器响应。
  8. 退出程序。

此外,我们将通过调用手工创建的协程的 Future::poll,按需多次调用以运行至完成。还未涉及运行时、反应器或执行器的概念,这些将在下一章中讨论。

如果将该程序编写为 async 函数,它的代码将如下所示:

async fn async_main() {
    println!("Program starting");
    let txt = Http::get("/1000/HelloWorld").await;
    println!("{txt}");
    let txt2 = Http::get("500/HelloWorld2").await;
    println!("{txt2}");
}

main.rs 中,首先进行必要的导入和模块声明:

use std::time::Instant;
mod future;
mod http;
use crate::http::Http;
use future::{Future, PollState};

接下来,编写一个可暂停/可恢复的任务,称为 Coroutine

struct Coroutine {
    state: State,
}

定义该任务的各种可能状态:

enum State {
    Start,
    Wait1(Box<dyn Future<Output = String>>),
    Wait2(Box<dyn Future<Output = String>>),
    Resolved,
}

这个协程特定的状态如下:

  • Start: 协程已创建但尚未被轮询(polled)。
  • Wait1: 调用 Http::get 后,返回一个 HttpGetFuture 并存储在 State 枚举中,此时将控制权返回给调用函数。
  • Wait2: 第二次调用 Http::get,再次将控制权返回给调用函数。
  • Resolved: future 已解决,无需再进行操作。

注意:我们可以将 Coroutine 直接定义为一个枚举,因为它仅包含一个状态指示符,但这里保留了扩展可能性以便以后增加状态。

接下来是 Coroutine 的实现:

impl Coroutine {
    fn new() -> Self {
        Self {
            state: State::Start,
        }
    }
}

创建一个新的 Coroutine,初始状态为 Start,即可。

接下来是 Future 的实现,这是任务实际执行的部分:

impl Future for Coroutine {
    type Output = ();
    fn poll(&mut self) -> PollState<Self::Output> {
        loop {
            match self.state {
                State::Start => {
                    println!("Program starting");
                    let fut = Box::new(Http::get("/600/HelloWorld1"));
                    self.state = State::Wait1(fut);
                }
                State::Wait1(ref mut fut) => match fut.poll() {
                    PollState::Ready(txt) => {
                        println!("{txt}");
                        let fut2 = Box::new(Http::get("/400/HelloWorld2"));
                        self.state = State::Wait2(fut2);
                    }
                    PollState::NotReady => break PollState::NotReady,
                },
                State::Wait2(ref mut fut2) => match fut2.poll() {
                    PollState::Ready(txt2) => {
                        println!("{txt2}");
                        self.state = State::Resolved;
                        break PollState::Ready(());
                    }
                    PollState::NotReady => break PollState::NotReady,
                },
                State::Resolved => panic!("Polled a resolved future"),
            }
        }
    }
}

简要说明:

  1. 首先设置 Output 类型为 (),表明不返回任何值。
  2. 接着是 poll 方法的实现,通过 loop 循环匹配 self.state 以驱动状态机向前推进,直到遇到 PollState::NotReady
  3. State::Start 状态下,运行初始指令,并在调用 Http::get 获取一个 Future 后进入 State::Wait1
  4. Wait1 状态下,调用 poll,如果返回 Ready,打印结果并进入 Wait2;如果返回 NotReady,则退出循环。
  5. Wait2 状态类似于 Wait1,调用 poll 获取数据并最终进入 Resolved
  6. Resolved 表示任务完成,如果再次调用 poll,则会 panic

为了便于理解 async/await,我们还创建了一个辅助函数 async_main 返回一个 Coroutine 实例:

fn async_main() -> impl Future<Output = ()> {
    Coroutine::new()
}

main 函数中驱动状态机:

fn main() {
    let mut future = async_main();
    loop {
        match future.poll() {
            PollState::NotReady => {
                println!("Schedule other tasks");
            },
            PollState::Ready(_) => break,
        }
        thread::sleep(Duration::from_millis(100));
    }
}

运行该程序,输出如下:

Program starting
FIRST POLL - START OPERATION
Schedule other tasks
...
HTTP/1.1 200 OK
...
HelloWorld1
FIRST POLL - START OPERATION
...
HelloWorld2

从输出中可以看出程序的执行流程:

  1. 启动时显示 Program starting
  2. 接着进入 FIRST POLL – START OPERATION,表示 HTTP 请求第一次被轮询。
  3. 主循环中每隔 100ms 检查任务是否完成,输出 Schedule other tasks
  4. 最后依次输出服务器的响应结果。

为何不手动写这样的代码

显然,编写 55 行状态机代码远不如编写简单的 7 行顺序代码直观、简洁。我们的状态机虽然高效,但表达性较差,不易编写且易出错。

然而,正是这种状态机的结构让我们有机会使用 async/await 来简化代码,通过标签标记暂停点和恢复点,从而自动生成状态机。这就是 async/await 背后的基本理念。

接下来,我们将了解 async/await 如何在这个示例中运作。

async/await

在之前的例子中,我们可以简单地用 async/await 关键字重写代码,如下所示:

async fn async_main() {
    println!("Program starting");
    let txt = Http::get("/1000/HelloWorld").await;
    println!("{txt}");
    let txt2 = Http::get("500/HelloWorld2").await;
    println!("{txt2}");
}

这是七行代码,和我们在普通子程序/函数中写的代码非常相似。我们可以让编译器为我们自动生成这些状态机,而不是手动编写它们。实际上,使用简单的宏就能实现这一点,这也是当前 async/await 语法在成为语言的一部分之前的原型实现方式。你可以在 github.com/alexcrichto… 找到类似的示例。

缺点是,这些函数看起来像普通的子程序,但本质上却完全不同。对于像 Rust 这样强类型并采用借用语义的语言来说,隐藏这些差异是不可能的。这可能会导致一些编程上的困惑,因为程序员可能期望它们的行为和普通函数一样。

协程示例扩展

为了展示我们示例与 std::future::Future 特性和 async/await 语法的行为有多相似,我使用“正式”的 Future 以及 async/await 语法在 a-coroutines 中创建了相同的示例。首先,你会注意到代码只需少量改动即可实现。其次,你可以看到,输出与我们手写状态机的程序流程完全一致。你可以在 ch07/a-coroutines-bonus 文件夹中找到这个示例。

让我们更进一步。为了避免混淆(因为当前协程只返回给调用函数,没有调度器、事件循环等),我们使用 coroutine/wait 语法来生成这些状态机。

coroutine/wait

coroutine/wait 语法与 async/await 非常相似,但其功能有限。基本规则如下:

  1. 每个带有 coroutine 前缀的函数将被重写为我们之前写的状态机。
  2. coroutine 标记的函数的返回类型将被重写为 -> impl Future<Output = String>(我们的语法只处理返回 String 的 future)。
  3. 只有实现了 Future 的对象才能使用 .wait 后缀。这些点将作为状态机的独立阶段。
  4. 带有 coroutine 前缀的函数可以调用普通函数,但普通函数不能调用 coroutine 函数,除非它们重复调用 poll 直到返回 PollState::Ready

我们的实现确保以下代码能够编译成与我们在本章开头编写的状态机相同的内容(所有协程都将返回一个 String):

coroutine fn async_main() {
    println!("Program starting");
    let txt = Http::get("/1000/HelloWorld").wait;
    println!("{txt}");
    let txt2 = Http::get("500/HelloWorld2").wait;
    println!("{txt2}");
}

但是,coroutine/wait 并不是 Rust 中的有效关键字,直接使用会导致编译错误。

你说得对,因此我创建了一个名为 corofy 的小程序,它将 coroutine/wait 函数重写成状态机。下面简单介绍一下。

corofy —— 协程预处理器

在 Rust 中,最佳的代码重写方法是使用宏系统。然而,宏展开后的内容难以追踪,而且不利于我们观察代码在转换前后的差异。此外,宏在阅读和理解上也可能变得相当复杂。

因此,corofy 是一个普通的 Rust 程序,可以在 ch07/corofy 文件夹中找到。进入该文件夹后,通过以下命令全局安装该工具:

cargo install --path .

安装完成后,你可以在任意位置使用它。使用方法是提供包含 coroutine/wait 语法的输入文件,例如 corofy ./src/main.rs [可选的输出文件]。如果没有指定输出文件,它将在相同文件夹中生成带有 _corofied 后缀的文件。

注意:该工具功能有限。原因是为了避免完成示例时年复一年的拖延(模拟重写整个 Rust 编译器)。直接在没有 Rust 类型系统支持的情况下编写这样的转换非常困难。

corofy 读取指定的文件,查找 coroutine 关键字的用法,将这些函数注释掉(保持在文件中),并在文件末尾重新写出状态机实现,同时在每个 wait 点之间标明原始代码位置。

现在我们已经介绍了这个新工具,是时候将它付诸实践了。

b-async-await:一个 coroutine/wait 转换示例

让我们稍微扩展我们的示例。现在我们有一个程序可以输出状态机,这让我们更容易创建一些示例,并涵盖 coroutine 实现中的更复杂部分。

我们会基于之前示例中的相同代码。在项目仓库中,你可以在 ch07/b-async-await 下找到此示例。如果你从头开始编写书中的每个示例而不依赖于仓库中的现有代码,可以选择以下两种方法:

  1. 在第一个示例中不断更改代码。
  2. 创建一个新的 Cargo 项目 b-async-await,然后将 src 文件夹中的所有内容以及 Cargo.toml 文件的依赖部分从前一个示例复制到新的项目中。

无论你选择哪种方式,确保面前的代码是相同的。

让我们简单地将 main.rs 中的代码更改为以下内容:

use std::time::Instant;
mod http;
mod future;
use future::*;
use crate::http::Http;

fn get_path(i: usize) -> String {
    format!("/{}/HelloWorld{i}", i * 1000)
}

coroutine fn async_main() {
    println!("Program starting");
    let txt = Http::get(&get_path(0)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(1)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(2)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(3)).wait;
    println!("{txt}");
    let txt = Http::get(&get_path(4)).wait;
    println!("{txt}");
}

fn main() {
    let start = Instant::now();
    let mut future = async_main();
    loop {
        match future.poll() {
            PollState::NotReady => (),
            PollState::Ready(_) => break,
        }
    }
    println!("\nELAPSED TIME: {}", start.elapsed().as_secs_f32());
}

该代码包含了一些更改。首先,我们添加了一个名为 get_path 的便捷函数,用于创建 GET 请求的路径,并根据传入的整数生成一个包含延迟和消息的路径。

async_main 函数中,我们创建了五个请求,延迟时间从 0 到 4 秒不等。

最后,我们在 main 函数中进行了一些更改。我们不再在每次调用 poll 时输出信息,因此也不使用 thread::sleep 来限制调用次数,而是记录从进入 main 函数到退出的时间,以此来验证代码是否并发运行。

现在我们的 main.rs 如上所示,可以使用 corofy 将其转换成状态机。假设我们在 ch07/b-async-await 的根目录,可以运行以下命令:

corofy ./src/main.rs

这将生成一个名为 main_corofied.rs 的文件,在 src 文件夹中可以打开并查看。

接下来,可以将 main_corofied.rs 文件中的内容全部复制到 main.rs 中。

注意:为了方便,项目的根目录中提供了一个名为 original_main.rs 的文件,其中包含我们展示的 main.rs 的代码内容,因此不需要手动保存 main.rs 的原始内容。如果你自己手动编写了每个示例,可以在覆盖之前将 main.rs 的原始内容存储在某个位置。

由于使用 coroutine/wait 写的 39 行代码在转换为状态机后变成了 170 行代码,这里不展示完整的状态机代码,但我们的 State 枚举现在看起来如下:

enum State0 {
    Start,
    Wait1(Box<dyn Future<Output = String>>),
    Wait2(Box<dyn Future<Output = String>>),
    Wait3(Box<dyn Future<Output = String>>),
    Wait4(Box<dyn Future<Output = String>>),
    Wait5(Box<dyn Future<Output = String>>),
    Resolved,
}

如果使用 cargo run 运行程序,现在会看到以下输出:

Program starting
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:05:55 GMT
HelloWorld0
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:05:56 GMT
HelloWorld1
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:05:58 GMT
HelloWorld2
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:06:01 GMT
HelloWorld3
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:06:05 GMT
HelloWorld4
ELAPSED TIME: 10.043025

结果显示代码按预期顺序运行。由于我们在每次调用 Http::get 时都等待结果,代码按顺序执行,从耗时来看大约为 10 秒。这样看来是合理的,因为我们设置的延迟是 0 + 1 + 2 + 3 + 4 秒,总共 10 秒。

如果我们想让这些 futures 并发运行呢?

还记得我们提到这些 futures 是懒惰的吗?很好。所以我们知道仅创建 future 不会实现并发运行,我们需要通过轮询启动操作。

为了解决这个问题,我们从 Tokio 中获得一些启发,创建了一个名为 join_all 的函数。它接收一个 futures 集合,并将它们全部并发驱动至完成。

让我们为本章创建最后一个示例,实现并发执行。

c-async-await:并发的 futures 示例

我们将在前一个示例的基础上构建一个并发版本。创建一个名为 c-async-await 的新项目,并复制 Cargo.tomlsrc 文件夹中的内容。

首先,打开 future.rs,在现有代码下方添加一个 join_all 函数:

pub fn join_all<F: Future>(futures: Vec<F>) -> JoinAll<F> {
    let futures = futures.into_iter().map(|f| (false, f)).collect();
    JoinAll {
        futures,
        finished_count: 0,
    }
}

该函数接收一组 futures,返回一个 JoinAll<F> future。它创建一个包含原始 future 和一个表示是否已完成的布尔值的元组集合。

接下来定义 JoinAll 结构体:

pub struct JoinAll<F: Future> {
    futures: Vec<(bool, F)>,
    finished_count: usize,
}

该结构体用于存储 future 集合以及一个计数器 finished_count,方便追踪已完成的 future 数量。接着,实现 JoinAllFuture

impl<F: Future> Future for JoinAll<F> {
    type Output = String;
    fn poll(&mut self) -> PollState<Self::Output> {
        for (finished, fut) in self.futures.iter_mut() {
            if *finished {
                continue;
            }
            match fut.poll() {
                PollState::Ready(_) => {
                    *finished = true;
                    self.finished_count += 1;
                }
                PollState::NotReady => continue,
            }
        }
        if self.finished_count == self.futures.len() {
            PollState::Ready(String::new())
        } else {
            PollState::NotReady
        }
    }
}

该实现会遍历每个 (flag, future) 元组并调用 poll,若返回 PollState::Ready,则设置 finishedtrue 并增加 finished_count

注意join_all 仅用于展示并发效果,在此版本中并未处理返回值。Tokio 的 join_all 会将所有返回值存储在 Vec<T> 中并返回。

修改 main.rs

main 函数、导入和声明保持不变。我们只展示修改后的 coroutine/wait 函数:

coroutine fn request(i: usize) {
    let path = format!("/{}/HelloWorld{i}", i * 1000);
    let txt = Http::get(&path).wait;
    println!("{txt}");
}

coroutine fn async_main() {
    println!("Program starting");
    let mut futures = vec![];
    for i in 0..5 {
        futures.push(request(i));
    }
    future::join_all(futures).wait;
}

async_main 收集一组 request coroutine 并通过 join_all 并发执行这些任务。request 函数生成 GET 请求,等待响应后打印结果。

由于我们设置的延迟是 0 到 4 秒不等,所有任务应并发进行,程序总耗时应略超过 4 秒。

确保在 ch07/c-async-await 文件夹中运行 corofy ./src/main.rscoroutine/wait 转换为状态机。生成的文件 main_corofied.rs 会出现在 src 文件夹,将其内容复制到 main.rs 中。

执行程序 cargo run,输出应如下所示:

Program starting
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
FIRST POLL - START OPERATION
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Tue, xx xxx xxxx 21:11:36 GMT
HelloWorld0
...
ELAPSED TIME: 4.0084987

重点在于程序耗时约 4 秒,证明 futures 是并发运行的。

小结

从程序员的角度看,coroutine/wait 大大简化了代码编写体验:

  • 高效:状态机无需上下文切换,仅需保存和恢复特定任务的数据。
  • 表达力:代码写法类似常规 Rust 函数,配合编译器支持,能获得相同的错误信息和工具支持。
  • 易用性和安全性:虽然 coroutine/await 转换会导致代码隐式变化,但大多数情况下能保持正常的编程习惯。

最终思考

在结束本章前,我们应该已经清楚了为什么协程并不是真正可抢占的。还记得第二章中我们提到的栈式协程(如我们的纤程/绿线程示例)可以被抢占并在任意位置暂停执行,因为它们有自己的栈,暂停任务只需将当前执行状态存储到栈中并跳转到其他任务。

这里则不同,我们只能在手动标记的 wait 预定义挂起点暂停和恢复执行。

理论上,如果你完全控制编译器、协程定义、调度器和 I/O 原语,可以在状态机中添加额外的状态,并创建额外的挂起/恢复点,使这些挂起点对用户透明,且与普通的挂起点不同。

例如,每次遇到普通函数调用时,你可以添加一个挂起点(即在状态机中新建一个状态),检查当前任务是否已经用完时间配额。如果是,你可以调度其他任务运行,并稍后恢复该任务,即便这一过程并非协作完成。

不过,尽管这对用户不可见,这种方法仍然不等同于可以在代码中的任意点暂停/恢复执行。而且这也违背了协程通常隐含的协作性质。

总结

干得不错!在本章中,我们引入了许多代码,并设置了一个将在后续章节中继续使用的示例。

到目前为止,我们已经专注于使用 futuresasync/await 来建模和创建可以在特定点暂停和恢复的任务。这是实现任务同时进行的前提。为此,我们引入了自己的简化 Future trait 和 coroutine/wait 语法,虽然比 Rust 的 futuresasync/await 语法更有限,但更易于理解,并有助于与纤程/绿线程形成对比(希望如此)。

我们还讨论了“急切”和“惰性”协程之间的差异,以及它们如何影响并发实现。我们从 Tokio 的 join_all 函数中获得灵感,并实现了自己的版本。

本章仅创建了可以暂停和恢复的任务,还没有事件循环或调度等功能,但别担心,这正是我们在下一章中要深入探讨的内容。好消息是,获得协程的清晰概念(如本章所述)是最难的部分之一。