Rust 1.75带来的async traits支持

Rust Async易用性增强

Posted by Kingtous on March 29, 2024
本页面总访问量

前言

受限于Rust类型系统,在Rust 1.75之前,trait接口无法定义异步函数,并且无法使用impl Struct作为返回值。但在Rust 1.75稳定版,这些特性均得到支持。

简述

在Rust 1.26上,Rust稳定了一个特性:支持开发者使用impl Trait作为函数的返回值,以下是一个例子:

1
2
3
4
5
6
7
8
/// 给定一个匿名生命周期的players,返回其对应的同等生命周期的迭代器。
fn player_names(
    players: &[Player]
) -> impl Iterator<Item = &String> {
    players
        .iter()
        .map(|p| &p.name)
}

但是类似impl Iterator<Item = &String>无法出现在traits内部接口函数中。从Rust 1.75版本开始,这类写法得到支持,成为了新语法糖。

trait Container {
    fn items(&self) -> impl Iterator<Item = Widget>;
}

impl Container for MyContainer {
    fn items(&self) -> impl Iterator<Item = Widget> {
        self.items.iter().cloned()
    }
}

另外对于async fn,事实上还是会被解析成fn。考虑一个trait:

trait HttpService {
    async fn fetch(&self, url: Url) -> HtmlBody;
}

事实上会被解析为:

fn fetch(&'a self, url: Url) -> impl Future<Output = HtmlBody>;

笔者十分感叹,在刚开始写Rust的时候,还是版本1.49版本,当时连GAT都没有,上述写法impl Future还不支持。只能使用async-trait完成,但是async-trait使用宏,解构后实际上还是使用了Box数据结构,会导致堆分配,造成性能问题。

最佳使用实践

这两个特性尽量不在public公用traits中出现。

impl Trait在公用trait中出现

考虑一个trait:

trait Test {
    fn print_in_reverse(container: impl Container);
}

container类型为impl Container,在实现这个trait的struct内重写这个函数,container无法增加任何约束,比如:

fn print_in_reverse(container: impl Container) {
    for item in container.items().rev() {
        // ERROR:                 ^^^
        // the trait `DoubleEndedIterator`
        // is not implemented for
        // `impl Iterator<Item = Widget>`
        eprintln!("{item}");
    }
}

rev()需要类实现DoubleEndedIterator trait,若业务层需要这个rev,则会导致写起来十分困难,目前Rust 1.75版本不支持实现这个接口函数的时候给这个参数施加更多的约束。以下是报错信息:

Rust官方后期通过其他手段来解决这个trait问题。

所以Rust社区鼓励在私有trait中使用impl Trait,但是不鼓励在公有trait中使用,因为私有trait只有开发者内部使用,其知道需要增加哪些约束,在写这个Trait的时候就已经加好了。但是公有trait,不同开发者使用这个trait时可能根据业务需求增加约束,但是目前Rust又不支持。

async fn在公用trait中出现

由于async fn就是一个语法糖,语法糖必然容易出现不灵活的地方。 考虑以下一个trait函数定义:

async fn fetch(&self, url: Url) -> HtmlBody;

以上定义了一个异步函数,传入一个url,返回一个HtmlBody。实际上,在Rust编译器解析后,会解语法糖为:

fn fetch(&self, url: Url) -> impl Future<Output = HtmlBody>;

解构后,fetch函数返回的Future事实上是没有实现Send的,这会直接限制这个Future在线程中传递。即async fn的返回值无法设置Send约束。

异步分为两种情况:一种是多线程任务窃取机制运行,另一种是epoll/io-uring机制下单线程异步运行。多线程环境下需要满足变量满足Send trait,表明变量可在线程之间安全传递。单线程则不需要满足Send

Send无法应用于返回值,直接导致了某些异步写法无法使用,因此,Rust社区提出了trait_variant用于快速将一个async fn trait转换为两个trait,一个满足Send、一个不满足Send,用于支持单线程和多线程异步环境。

使用了二者特性的trait不支持动态分发

使用了-> impl Traitasync fn并不是对象安全的,而动态分发的要求是trait是对象安全的。导致这类trait无法动态分发,Rust社区承诺将在后期通过trait_variant解决。

加餐,对象安全是什么:

当一个 trait 不是 object-safe 的时候,意味着它无法在运行时进行动态分发。这是因为 trait objects 需要在编译时知道有关 trait 和方法的具体信息,以便正确地调用方法。如果一个 trait 不满足 object-safe 的条件,编译器无法保证在运行时能够正确地处理相关的类型和方法调用,因此无法用于动态分发。

关联类型必须是 Sized:

如果一个 trait 中的关联类型不是 Sized,那么编译器无法确定这些类型的大小,在编译时无法生成正确的调用代码。 例如,假设一个 trait 的方法返回一个关联类型,但这个关联类型不是 Sized,那么编译器无法预先分配内存空间来存储返回值,也无法确定如何正确地传递和使用这个返回值。

方法参数和返回值的确定性:

Object-safe 的 trait 要求方法不能有泛型参数,并且不能返回 Self 类型。这是因为在动态分发时,编译器需要知道每个方法的确切参数类型和返回类型,以便正确地调用这些方法。

如果一个 trait 的方法有泛型参数,或者返回 Self 类型,这就会导致不确定性,编译器无法在编译时确定这些类型,因此无法生成正确的调用代码。

object-safe 的 trait 需要满足一系列的限制条件,以便编译器在编译时能够准确地生成 trait objects 所需的信息。如果一个 trait 不满足这些限制条件,编译器无法在运行时保证能够正确地处理相关的类型和方法调用,因此无法用于动态分发。