Rust 学习笔记:深入研究异步 trait

Rust 学习笔记:深入研究异步 trait

我们以各种方式使用了 Future、Pin、Unpin、Stream 和 StreamExt trait。不过,到目前为止,我们还没有深入讨论它们是如何工作的,或者它们是如何组合在一起的。在本文中,我们将对这些场景进行足够深入的研究。

Future trait

Rust 这样定义 Future trait:

use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

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

Future 的关联类型 Output 表示 Future 要解析什么。这类似于 Iterator trait 的 Item 关联类型。

Future的 poll 方法为其 self 参数接受一个特殊的 Pin 引用和一个对 Context 类型的可变引用,并返回一个 Poll<\Self::Output>。我们一会儿会更多地讨论Pin和Context。现在,让我们关注方法返回的是Poll类型

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

Poll 是一个枚举类型,类似于 Option。它有一个有值的变体 Ready(T) 和一个没有值的变体 Pending。其中,Pending 变体表明未来仍有工作要做,因此调用者稍后需要再次检查。Ready 变体表示 future 已经完成了它的工作,并且 T 值可用。

注意:对于大多数 future,在返回 Ready 后,调用者不应该再次调用 poll。许多 future 在完成之后,如果再次 poll,将会出现 panic。允许再次进行 poll 的 future 将在其文件中明确说明这一点。这类似于 Iterator::next 的行为。

当看到使用 await 的代码时,Rust 会将其编译为调用 poll 的代码。

例如:

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

Rust 将其编译成类似于(尽管不完全是)这样的内容:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // continue
        }
    }
}

当 future 尚未到来的时候,我们需要某种方式来尝试,一次又一次,一次又一次,直到 future 终于准备好。换句话说,我们需要一个循环。

但是,如果 Rust 真的是一直轮询直到 future 完成,那么每个 await 都将阻塞,这就不具有异步性了!

相反,Rust 确保循环可以将控制权移交给某些东西,这些东西可以暂停这个 future 的工作,以处理其他 future,然后稍后再检查这个 future。正如我们所看到的,这个东西是一个异步运行时,这个调度和协调工作是它的主要工作之一。

再回头看看接收器的 recv 函数,该调用返回一个 future,并等待 future 轮询它。我们注意到,当通道关闭时,运行时将暂停 future,直到它准备好使用 Some(message) 或 None。随着我们对 Future trait 的深入了解,特别是对 Future::poll 的了解,我们可以看到它是如何工作的。当运行时返回 Poll::Pending 时,它知道 future 还没有准备好。相反,运行时知道 future 准备好了,并在 poll 返回 Poll::Ready(Some(message)) 或 Poll::Ready(None) 时推进它。

运行时如何做到这一点的具体细节就不再展开了,但关键是要了解 future 的基本机制:运行时轮询它所负责的每个 future,在 future 尚未准备好时将其重新置于睡眠状态。

Pin trait 和 Unpin trait

在之前的程序我们遇到了这样的报错:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

这个错误消息不仅告诉我们需要固定这些值,还告诉我们为什么需要固定。trpl::join_all 函数的作用是返回一个名为 JoinAll 的结构体。该结构体在类型 F上是泛型的,它被约束为必须实现 Future trait。

直接用 await 来等待一个 future 会隐式地固定该 future。这就是为什么我们不需要在任何地方使用 pin! 来等待 future。

然而,我们并不是直接在这里等待 future。相反,我们通过将一个 future 集合传递给 join_all 函数来构造一个新的 future,即 JoinAll。join_all 的签名要求集合中所有项的类型都实现 Future trait, 只有当 Box 包装的 T 是实现 Unpin trait 的 future 时,Box<T> 才实现 Future trait。

再看一次 Future trait 的定义:

use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Required method
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

cx 参数及其 Context 类型是运行时如何知道何时检查任何给定的 future 而仍然处于惰性状态的关键。self 的类型注释与其他函数参数的类型注释一样,但有两个关键区别:

  1. 它告诉 Rust 要调用该方法,self 必须是什么类型。
  2. 它不可能是任何类型。它仅限于实现该方法的类型、指向该类型的引用或智能指针,或者包装该类型引用的 Pin。

现在,只要知道如果我们想要轮询一个 future 来检查它是 Pending 还是 Ready(Output) 就足够了,我们需要一个 Pin 包装的可变类型引用。

Pin 是类似指针类型(如 &、&mut、Box、Rc)的包装器。从技术上讲,Pin 与实现 Deref 或 DerefMut trait 的类型一起工作,但这实际上相当于只与指针一起工作。Pin 本身不是指针,也不像 Rc/Arc 那样有自己的引用计数行为,它纯粹是一个编译器可以用来强制约束指针使用的工具。

回顾一下,await 被 Rust 编译器转化为调用 poll 的代码。future 中的一系列 await 点会被编译成一个状态机,并且编译器会确保状态机遵循 Rust 关于安全的所有常规规则,包括借用和所有权。为了实现这一点,Rust 查看在一个等待点和下一个等待点或异步块结束之间需要哪些数据。然后,它在已编译的状态机中创建相应的变体。每个变体都获得对将在该源代码部分中使用的数据所需的访问权限,无论是通过获取该数据的所有权,还是通过获取对该数据的可变或不可变引用。

如果我们在给定的异步块中发现任何关于所有权或引用的错误,借用检查器会告诉我们。

当我们想要移动对应于该块的 future 时——无论是将其放入数据结构中作为 join_all 的迭代器使用,还是从函数中返回它——实际上意味着移动 Rust 为我们创建的状态机。与 Rust 中的大多数其他类型不同,Rust 为异步块创建的 future 可能最终在任何给定变体的字段中包含对自身的引用:

在这里插入图片描述

然而,默认情况下,移动任何引用自身的对象都是不安全的,因为引用总是指向它们所引用的对象的实际内存地址。如果移动数据结构本身,这些内部引用将指向旧位置,旧位置是无效的。这是内存泄漏!

在这里插入图片描述

理论上,只要对象被移动,Rust 编译器可以尝试更新对它的每个引用,但这会增加很多性能开销,特别是当整个引用网络需要更新时。如果我们能够确保所讨论的数据结构不会在内存中移动,我们就不需要更新任何引用。这正是 Rust 的借用检查器所需要的:在安全代码中,它阻止你移动任何对它有活动引用的项。

Pin 在此基础上为我们提供所需的确切保证。当我们通过在 Pin 中包装指向该值的指针来固定一个值时,它就不能再移动了。因此,如果你有 Pin<Box<SomeType>>,你实际上固定了 SomeType 值,而不是 Box 指针。

在这里插入图片描述

实际上,Box 指针仍然可以自由移动。比如这样:

在这里插入图片描述

请记住:我们关心的是确保最终被引用的数据保持在适当的位置。如果指针四处移动,但它指向的数据在同一个位置,则没有潜在的问题。关键是自我引用类型本身不能移动,因为它仍然是固定的。

然而,大多数类型的移动是完全安全的。例如数字和布尔值,它们显然没有任何内部引用,所以它们的移动是安全的。

我们只需要在项目具有内部引用时考虑固定。如果你有一个 Pin<Vec<String>>,你必须通过 Pin 提供的安全但限制性的 API 来做所有事情,即使没有其他引用它,Vec<String> 总是安全的移动。

我们需要一种方法来告诉编译器在这种情况下移动项是可以的——这就是 Unpin 发挥作用的地方。

Unpin 是一个 marker trait,类似于我们之前看到的 Send 和 Sync trait,因此 Unpin trait 没有自己的方法。marker trait 的存在只是为了告诉编译器在特定上下文中使用实现给定 trait 的类型是安全的。Unpin 通知编译器,给定的类型不需要维护有关值是否可以安全移动的任何保证。

就像 Send 和 Sync 一样,编译器对所有类型自动实现 Unpin,只要它能证明它是安全的。一种特殊情况,同样类似于 Send 和 Sync,是未为类型实现 Unpin 的情况。它的表示法是 impl !Unpin for SomeType,其中 SomeType 是一个类型的名称。当一个指向该类型的指针被包裹在 Pin 中使用时,该类型都需要维护这些保证(不能被“移动”的保证)以确保使用时的内存安全。

换句话说,关于 Pin 和 Unpin 之间的关系,有两件事要记住。首先,Unpin 是“正常”情况,而 !Unpin 是特殊情况。第二,类型实现 Unpin 还是 !Unpin 只有在使用 Pin 指针指向该类型(如 Pin<&mut SomeType>)时才重要。

具体来说,考虑一个字符串:它有一个长度和组成它的 Unicode 字符。我们可以在 Pin 中包装一个 String,就像这样:

在这里插入图片描述
然而,String 会自动实现 Unpin,就像 Rust 中的大多数其他类型一样。

因此,如果 String 实现了 !Unpin,我们就可以做一些非法的事情,比如在内存中的相同位置用另一个字符串替换一个字符串,如下图所示:

在这里插入图片描述

这不会违反 Pin 的约束,因为 String 没有内部引用使其移动不安全。这正是String 实现 Unpin 而不是 !Unpin 的原因。

现在回到本小节最开始那段的报错信息。如今我们知道了 join_all 错误的原因:我们最初试图将异步块生成的 future 移动到 Vec<Box<dyn Future<Output =()>>> 中,但这些 future 可能有内部引用,因此它们不实现 Unpin。它们需要被固定,于是我们要在 Box 之外套一层 Pin<> ,确信 future 中的基础数据不会被移动。

Stream trait

Stream 类似于异步迭代器。

与 Iterator 和 Future 不同的是,Stream 在标准库中没有定义,而是在 Future crate 中有一个非常常见的定义。

在了解 Stream trait 如何将 Iterator 和 Future trait 合并在一起之前,让我们先回顾一下它们的定义:

  • 从 Iterator 中,我们得到了序列的概念:它的 next 方法提供了一个 Option<Self::Item>。

  • 从 Future 中 ,我们了解了随时间变化的准备情况:它的 poll 方法提供了一个 Poll<Self::Output>。

为了表示一个随时间推移而准备好的项目序列,我们定义了一个 Stream trait 来把这两个 trait 放在一起:

use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}

Stream trait 为流生成的项的类型定义了一个名为 Item 的关联类型。这类似于 Iterator,其中可能有零到多个项目。这不像 Future 总是有一个输出。

Stream 还定义了一个 poll_next 方法来获取这些项,它以与 Future::poll 相同的方式进行轮询,并以与 Iterator::next 相同的方式产生一系列项。它的返回类型结合了 Poll 和 Option。外部类型是 Poll,因为它必须检查是否准备就绪,就像 Future 一样。内部类型是 Option,因为它需要发出是否有更多消息的信号,就像 Iterator 一样。

但是,在我们之前的示例中,我们没有使用 Stream trait 的 poll_next 方法,而是使用 StreamExt trait 的 next 方法。当然,我们可以通过手工编写自己的流状态机来直接使用 poll_next API,不过使用 await 要好得多,并且 StreamExt trait 提供了 next 方法:

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}

有些 Rust 版本不支持在 trait 中使用异步函数,因此 StreamExt trait 的 next 方法还有这样一种声明:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

Next 类型是一个实现 Future 的结构体,它允许我们用 Next< _, self > 来命名 self 引用的生命周期,这样 await 就可以和这个方法一起工作。

对于实现 Stream 的每种类型,StreamExt 都是自动实现的。但 StreamExt 是单独定义的,以使社区能够在不影响 Stream 的情况下迭代 StreamExt 的 API。

在 trpl crate 中使用的 StreamExt 中,该 trait 不仅定义了 next 方法,而且还提供了 next 的默认实现,该实现正确处理调用 Stream::poll_next 的细节。这意味着即使当你需要编写自己的流数据类型时,你只需要实现 Stream,然后任何使用你的数据类型的人都可以自动使用 StreamExt 及其方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UestcXiye

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值