Async Rust 近况补课:从 `async-trait` 到原生 async trait

本文是对 Catching up with async Rust的整理与翻译

内容结构概览

  1. Rust 1.75 之前:为什么 trait 里不能直接写 async fn
  2. async-trait crate 做了什么:把 Future 装箱
  3. Future 为什么有大小问题:局部变量会被保存进状态机
  4. Box、Pin、dyn Future 与动态分发
  5. Rust 1.75 之后:原生 async fn in trait
  6. 新能力的限制:目前仍然不能直接做 dyn AsyncRead
  7. async fn 背后的本质:返回 impl Future,再对应到关联类型
  8. Service trait 理解现实工程中的 async trait
  9. 为什么"简化版 Service"既好用又不完全等价
  10. 生命周期、隐藏捕获与并发调用问题
  11. Send 约束为什么必须在 trait 声明处决定
  12. 当前生态补位:trait-variantdynosaur
  13. 结语:async Rust 正在变好,但还没完全抵达终点

Rust 的 async 生态一直有一种很典型的气质:底层设计非常严谨,抽象边界非常清楚,但用户体验有时会让人觉得"明明只是想写个异步函数,怎么突然开始研究 Future 的大小、vtable 和生命周期了"。

2023 年 12 月,Rust 1.75 发布,一个长期等待的能力终于稳定了:trait 中可以直接写 async fn

这件事看起来只是语法进步:

rust 复制代码
trait AsyncRead {
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}

但它背后其实牵出了一整套 Rust async 的核心问题:Future 到底是什么?为什么以前要用 async-trait?为什么装箱会带来动态分发?为什么现在支持了 async fn in trait,却仍然不能随便写 dyn AsyncRead?为什么 Send 这种约束必须提前设计好?

这篇文章就沿着这些问题,把 async Rust 的现状讲清楚。

一、Rust 早就有 async fn,但 trait 里曾经不行

从 Rust 1.39 开始,我们已经可以写普通的异步函数:

rust 复制代码
pub async fn read_hosts() -> eyre::Result<Vec<u8>> {
    // ...
}

也可以在 impl 块里写异步方法:

rust 复制代码
impl HostReader {
    pub async fn read_hosts(&self) -> eyre::Result<Vec<u8>> {
        // ...
    }
}

但在很长一段时间里,下面这种写法是不被稳定 Rust 接受的:

rust 复制代码
trait AsyncRead {
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}

编译器会告诉你:trait 里的函数不能声明为 async。如果你确实需要这么做,它会建议你使用 async-trait 这个 crate。

于是,async-trait 成了 Rust 异步生态里非常常见的工具。

二、async-trait 做了什么

async-trait 让你可以这样写:

rust 复制代码
#[async_trait::async_trait]
trait AsyncRead {
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}

它表面上保留了 async fn 的写法,但实际会通过过程宏把 trait 方法改写成另一种形式。

核心变化是:原本的异步方法会被改成返回一个装箱后的 Future。

大致可以理解为:

rust 复制代码
fn read(...) -> Pin<Box<dyn Future<Output = io::Result<usize>> + Send + '_>>;

也就是说,async-trait 的关键不是"让 trait 真正支持 async",而是把返回的 Future 放到堆上,并通过 dyn Future 做动态分发。

这能解决问题,但不是免费的。它引入了堆分配,也引入了动态分发。

那么,为什么需要这么做?

答案要从 Future 的大小说起。

三、Future 的大小不是固定的

Rust 里的 async fn 并不是魔法。一个 async fn 返回的是某个实现了 Future 的匿名类型。

这个 Future 本质上是一个状态机。函数执行到 .await 时,当前尚未完成的状态需要被保存下来,等异步操作完成后再恢复执行。

看两个函数:

rust 复制代码
async fn foo() {
    sleep(Duration::from_secs(1)).await;
    println!("done");
}
rust 复制代码
async fn bar() {
    let mut a = [0u8; 72];

    sleep(Duration::from_secs(1)).await;

    for _ in 0..10 {
        a[0] += 1;
    }

    println!("done");
}

barfoo 多了一个数组,而且这个数组跨过了 .await。这意味着它不能在 sleep 的时候被释放,因为恢复执行时还要用到它。

所以,这个数组会成为 Future 状态的一部分。

因此,foo() 返回的 Future 和 bar() 返回的 Future 大小不同。原文里做了实验,foo 的 Future 是 128 字节,而 bar 的 Future 是 200 字节。

这就是 async Rust 很多复杂性的源头:Future 是一个具体类型,而且不同 async 函数生成的 Future 类型和大小都可能不同。

四、为什么大小问题会影响 trait

普通函数调用时,编译器通常要知道返回值的大小。它需要提前为局部变量安排空间。

例如:

rust 复制代码
fn main() {
    let step1: u64 = 0;
    let fut = foo();
    let step2: u64 = 0;
}

编译器会为 step1futstep2 安排栈空间。由于 foo() 的返回类型在编译期明确,空间大小也明确。

但如果我们有一个 trait object:

rust 复制代码
fn use_read(r: &mut dyn AsyncRead) {
    let mut buf = [0; 1024];
    let fut = r.read(&mut buf);
}

问题来了:r 背后的真实类型可能是任何实现了 AsyncRead 的类型。不同实现的 read 方法可能生成不同大小的 Future。

fut 应该占多少空间?

编译器不知道。

这就是为什么早期方案需要把 Future 装箱。装箱以后,局部变量里保存的就不是整个 Future 本体,而是指向堆上 Future 的指针。指针大小是固定的。

五、Boxed Future:把不可预测变成固定大小

如果把不同大小的 Future 都放进 Box,再通过 dyn Future 使用,那么局部变量的大小就固定了。

例如:

rust 复制代码
let fut: Pin<Box<dyn Future<Output = ()>>> = Box::pin(foo());

不管 foo() 原本返回的 Future 是 128 字节,还是 bar() 返回的 Future 是 200 字节,Pin<Box<dyn Future<Output = ()>>> 这个值本身的大小都是固定的。

原文里还特意解释了一个细节:它不是 8 字节,而是 16 字节。

为什么?因为 Box<dyn Trait> 是一个 fat pointer,里面包含两个指针:

一个指向数据本身,也就是堆上的 Future。

另一个指向 vtable,也就是动态分发所需的方法表。

这就是 Rust 的 trait object 工作方式。

六、动态分发:dyn 背后的 vtable

普通的 Box<T> 只需要一个指针,因为 T 的类型在编译期已知。

Box<dyn Display>Box<dyn Future> 这种 trait object 需要额外知道:这个值到底该怎么调用方法、怎么 drop、有哪些类型相关操作。

这些信息放在 vtable 里。

所以:

rust 复制代码
Box<String>

通常只是一个指针。

而:

rust 复制代码
Box<dyn Display>

通常是两个指针:数据指针加 vtable 指针。

这就是 async-trait 的基本思路:把原本无法直接命名、大小也不统一的 Future,变成一个统一大小的 trait object。

它解决了 trait 中 async 方法的问题,但代价是装箱和动态分发。

七、Rust 1.75:原生 async fn in trait 到来

从 Rust 1.75 开始,trait 里可以直接写:

rust 复制代码
trait AsyncRead {
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}

实现也很自然:

rust 复制代码
impl AsyncRead for MyReader {
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        // ...
    }
}

这次不需要 async-trait,也不会默认把 Future 装箱。

原文里的实验显示,一个具体实现返回的 Future 可以是 224 字节。如果它被装箱成 Pin<Box<dyn Future>>,看到的就会是 16 字节。

这说明原生 async fn in trait 的性能模型更接近普通 async 函数:编译器知道具体实现时,可以保留具体 Future 类型,不必强制堆分配。

这当然是好事。

但问题还没有完全解决。

八、为什么 dyn AsyncRead 仍然不行

假设我们写:

rust 复制代码
fn use_async_read(r: Box<dyn AsyncRead>) {
    let fut = r.read(&mut buf);
}

这在当前 Rust 中仍然不行。

原因和前面一样:dyn AsyncRead 背后的真实类型未知,而不同类型的 read 方法可能返回不同大小的 Future。当前 Rust 还不能把"异步 trait 方法返回的 Future 大小"放进 vtable,并据此处理这种动态调用。

所以,原生 async fn in trait 目前还不是 dyn-compatible。

换句话说,你可以在泛型上下文里用它:

rust 复制代码
fn use_reader<R: AsyncRead>(reader: R) {
    // ...
}

或者:

rust 复制代码
fn use_reader(reader: impl AsyncRead) {
    // ...
}

但不能直接把它当成:

rust 复制代码
dyn AsyncRead

来用。

这不是 async Rust 独有的问题。Rust 中很多 trait 方法都可能导致 trait 无法成为对象,例如按值接收 self 的方法:

rust 复制代码
trait EatSelf {
    fn eat(self);
}

因为 Self 的大小未知,动态分发时就会遇到问题。

不过,如果接收的是 Box<Self>&SelfArc<Self> 这类指针或智能指针,情况就不同了,因为指针大小是确定的。

所以,dyn compatibility 是 Rust trait 系统的一般性问题,只是 async trait 把它暴露得更明显。

九、async fn 本质上是什么

要理解 async trait,必须记住一句话:

async fn 大致等价于返回 impl Future 的函数。

也就是说:

rust 复制代码
trait AsyncRead {
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}

可以理解为:

rust 复制代码
trait AsyncRead {
    fn read(&mut self, buf: &mut [u8])
        -> impl Future<Output = io::Result<usize>>;
}

而在 trait 中,返回位置的 impl Trait 可以进一步理解成某种关联类型:

rust 复制代码
trait AsyncRead {
    type ReadFuture: Future<Output = io::Result<usize>>;

    fn read(&mut self, buf: &mut [u8]) -> Self::ReadFuture;
}

这就是很多 Rust 异步库长期以来的设计方式。

例如 towerService trait 就是这样:

rust 复制代码
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>)
        -> Poll<Result<(), Self::Error>>;

    fn call(&mut self, req: Request) -> Self::Future;
}

实现 Service 时,你通常有三种选择:

  1. 手写 Future,避免堆分配。
  2. type Future 设为 boxed future。
  3. 使用 nightly 的 impl_trait_in_assoc_type,让编译器推断关联 Future 类型。

这就是 Rust async 工程代码里常见的复杂性。

十、如果用 Rust 1.75 重写 Service,会怎样

既然 trait 里可以写 async fn 了,我们可以想象一个更简单的 Service

rust 复制代码
trait Service<Request> {
    type Response;
    type Error;

    fn poll_ready(&mut self, cx: &mut Context<'_>)
        -> Poll<Result<(), Self::Error>>;

    async fn call(&mut self, request: Request)
        -> Result<Self::Response, Self::Error>;
}

这比传统 tower Service 简洁很多。

实现一个空服务很简单:

rust 复制代码
impl<Request> Service<Request> for () {
    type Response = ();
    type Error = ();

    fn poll_ready(&mut self, _cx: &mut Context<'_>)
        -> Poll<Result<(), Self::Error>>
    {
        Poll::Ready(Ok(()))
    }

    async fn call(&mut self, _request: Request)
        -> Result<Self::Response, Self::Error>
    {
        Ok(())
    }
}

再实现一个包装器,比如记录请求日志的 LogRequest<S>,也非常自然:

rust 复制代码
async fn call(&mut self, request: Request)
    -> Result<Self::Response, Self::Error>
{
    println!("{:?}", request);
    self.inner.call(request).await
}

这里没有 boxed future,也能在 Rust 1.75 stable 上工作。

这就是原生 async trait 的胜利之处:很多以前需要手动定义 Future 关联类型的场景,现在可以写得更直观。

但它也带来限制。

十一、不可命名的返回类型

使用传统 Service 时,call 的返回类型是一个显式关联类型:

rust 复制代码
type Future = ...

这意味着你可以在组合器里精确描述返回 Future 的类型。

例如 Either<A, B> 这种服务:它可能走左边服务,也可能走右边服务。传统写法可以定义一个 EitherResponseFuture<A::Future, B::Future> 来统一返回类型。

但如果使用 async fn call,返回的 Future 类型是编译器生成的匿名类型。你不能直接给它起名字。

好消息是,在 Either 这个具体例子里,新的写法反而更自然:

rust 复制代码
async fn call(&mut self, request: Request)
    -> Result<Self::Response, Self::Error>
{
    match self {
        Either::Left(service) => service.call(request).await,
        Either::Right(service) => service.call(request).await,
    }
}

这说明原生 async trait 并不是单纯"能力少了",它确实能让某些代码更简单。

真正复杂的是:当你需要对返回 Future 添加额外约束时,比如生命周期约束、Send 约束,事情会变得麻烦。

十二、生命周期复习:返回值是否借用了输入

Rust 的生命周期系统要求你说清楚:返回值是否借用了输入。

例如:

rust 复制代码
fn substring<'s>(input: &'s str, start: usize, end: usize) -> &'s str {
    &input[start..end]
}

这里返回的 &str 借用了输入 input,所以返回值不能比输入活得更久。

如果你这么写:

rust 复制代码
let s = String::from("Hello, world!");
let t = substring(&s, 0, 5);
drop(s);
println!("{t}");

编译器会拒绝,因为 t 还借着 s 的内存。

但如果函数返回的是拥有所有权的 String

rust 复制代码
fn substring(input: &str, start: usize, end: usize) -> String {
    input[start..end].to_string()
}

那就没问题。返回值拥有自己的内存,不依赖输入。

这个基础问题到了 async 里会更复杂,因为 Future 可以处在"执行到一半"的状态。只要 Future 还没完成,它内部就可能保存着对某些变量的借用。

十三、隐藏捕获:async fn 可能借用 self

传统 tower Service 的定义是:

rust 复制代码
trait Service<Request> {
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn call(&mut self, request: Request) -> Self::Future;
}

这里的 Self::Future 没有生命周期参数。它表达的是:返回的 Future 不应该借用 self

但如果我们写:

rust 复制代码
async fn call(&mut self, request: Request)

这个 async 方法生成的 Future 很自然地可能借用 self。因为 &mut self 进入了异步状态机,只要 Future 没完成,这个可变借用就还存在。

这会改变并发行为。

传统 Service 允许你连续调用多次 service.call(),得到多个相互独立的 Future,然后把它们提交给 executor 并发执行。

但使用简化版 async trait 时,下面这种代码可能不成立:

rust 复制代码
let fut1 = service.call(req1);
let fut2 = service.call(req2);

因为第一次调用已经可变借用了 service,而 fut1 还没完成,第二次调用就不能再次可变借用。

编译器会报:不能同时多次可变借用。

这不是小差异,而是 API 语义上的差异。

所以,虽然 async fn in trait 写起来更简单,但它并不能无痛替代所有传统的 associated future 设计。

十四、用 'static 放宽并发问题

如果我们希望返回的 Future 不借用 self,可以尝试在 trait 里声明:

rust 复制代码
fn call(&mut self, request: Request)
    -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static;

这表示返回的 Future 必须是 'static,也就是不能借用短生命周期的 self

但这时,实现也必须配合。不能再写会捕获 &mut selfasync fn call,而要在返回 async block 前,把需要的数据先拷贝或移动出来。

例如对于 i32

rust 复制代码
fn call(&mut self, request: i32)
    -> impl Future<Output = Result<i32, ()>> + 'static
{
    let this = *self;
    async move {
        Ok(this + request)
    }
}

这样 Future 捕获的是 this 的值,而不是 &mut self

于是我们又能同时创建多个 Future,并发等待它们。

这展示了一个重要原则:如果你关心返回 Future 的生命周期、是否借用 self、能不能并发、能不能 spawn,这些都必须体现在 trait 的方法签名里。

十五、Send 问题:为什么调用方补不了

在 Tokio 里,tokio::spawn 要求 Future 是 Send + 'static

大致约束是:

rust 复制代码
F: Future + Send + 'static

如果你有一个具体类型,比如 i32 实现的 Service,编译器可以看到 call 返回的具体 Future 类型。如果它刚好是 Send,那就能 spawn

但如果你在一个泛型函数里写:

rust 复制代码
async fn do_the_spawning<S>(service: &mut S)
where
    S: Service<i32>,
{
    tokio::spawn(service.call(-34));
}

问题就出现了。

对泛型 S 来说,编译器只知道它实现了 Service<i32>,但不知道它的 call 返回的 Future 是否是 Send。即使你给 S::ResponseS::Error 都加上 Send,也不够,因为 Future 本身可能捕获了非 Send 的东西,比如 Rc<()>

关键点是:返回 Future 是否 Send,必须在 trait 方法声明里写清楚。

例如:

rust 复制代码
fn call(&mut self, request: Request)
    -> impl Future<Output = Result<Self::Response, Self::Error>>
       + Send
       + 'static;

这样泛型调用方才能依赖这个保证。

这也是为什么当前 Rust 会警告:不要轻易在 public trait 里直接写 async fn。因为 async fn 语法本身没有地方让你表达这些额外约束。

如果你的 trait 是公开 API,未来用户可能需要 Send,可能需要 'static,可能需要非 Send 版本。一旦你用裸 async fn 固定了接口,后面就很难无破坏地调整。

十六、所以现在该怎么写 async trait

可以粗略总结为几种情况。

如果 trait 是内部使用的,调用场景明确,不需要动态分发,不需要复杂的 Send'static 约束,那么 Rust 1.75 的原生 async fn in trait 很好用。

rust 复制代码
trait MyInternalService {
    async fn call(&mut self) -> Result<(), Error>;
}

如果这是公共库 API,就要谨慎。你可能更适合显式返回 impl Future,并把约束写出来:

rust 复制代码
trait Service<Request> {
    type Response;
    type Error;

    fn call(&mut self, request: Request)
        -> impl Future<Output = Result<Self::Response, Self::Error>>
           + Send
           + 'static;
}

如果你需要动态分发,当前原生 async trait 还不够。你可能仍然需要 boxed future、async-trait,或者使用新的辅助 crate。

如果你想同时提供 Send 和非 Send 版本,可以看看 trait-variant

如果你想让带有 async 方法的 trait 支持动态分发,可以关注 dynosaur 或未来的 dyn async traits 设计。

十七、当前生态的补位方案

原文最后提到,Rust async 工作组已经在提供一些过渡工具。

第一个是 trait-variant

它可以帮助你一次性声明 trait 的 Send 和非 Send 版本。因为在 Rust async 生态里,这个分歧很常见:有些运行时和任务需要跨线程移动,要求 Send;有些本地任务不需要。

第二个是 dynosaur

它尝试让带有 async fn 的 trait 可以通过动态分发使用,填补当前原生语言能力尚未完成的部分。

作者真正期待的是 dyn async traits,也就是未来 Rust 能让 async trait 方法自然支持 trait object。等那一天到来,很多现在必须手动装箱或借助宏的地方,都会变得更干净。

十八、这篇文章真正想说明什么

这篇博客表面上是在讲 Rust 1.75 的 async fn in trait,但真正主线其实是:

Rust 的 async 不是语法糖那么简单。它牵涉到类型大小、栈布局、堆分配、动态分发、vtable、生命周期、关联类型、GAT、Send'static,以及公共 API 的兼容性设计。

async-trait 曾经是实用解法,因为它把复杂性统一折叠成 boxed future。

Rust 1.75 的原生 async trait 是重大进步,因为它让很多场景不再需要装箱,写法更自然,性能模型也更接近零成本抽象。

但它还没有完全替代传统 associated future 模式,也还不能解决所有动态分发问题。

尤其对于库作者来说,最重要的不是"能不能写 async fn",而是:

这个 Future 能不能借用 self

它需不需要是 Send

它需不需要是 'static

调用者是否需要并发持有多个 Future?

这个 trait 是否要支持 dyn Trait

这些问题决定了 API 该怎么设计。

结语

Async Rust 正在进入一个更舒服的阶段。

过去我们常常需要解释:为什么 trait 里不能写 async,为什么要用 async-trait,为什么一写就多了 Pin<Box<dyn Future>>

现在,很多普通场景已经可以直接写 async fn in trait,这是一大步。

但 Rust 仍然坚持它一贯的原则:抽象不能模糊成本,类型系统必须知道边界,异步状态机里捕获了什么、能不能跨线程、能不能被动态分发,都要讲清楚。

所以,今天的 async Rust 不是"已经完全简单了",而是"终于开始把常见场景变简单了"。

剩下的部分,比如 dyn async traits,还在路上。对于开发者来说,理解这些限制并不是白费力气。它能帮我们写出更稳的库 API,也能让我们在面对编译器错误时,知道它到底在保护什么。

相关推荐
一行代码一行诗++3 小时前
循环的嵌套
数据结构·算法
Yang96113 小时前
DXGF-101A:打造稳定可靠的交通通信网络
网络·信息与通信
玖釉-3 小时前
C++ 中的矩阵介绍:以二维矩阵查找为例
c++·windows·算法·矩阵
ECT-OS-JiuHuaShan3 小时前
存在是微分张量积,标量是参数但不可能是本质。还原论泛化,是语义劫持和以偏概全的逻辑谋杀伪科学庞氏骗局
数据库·人工智能·算法·机器学习·数学建模
CQU_JIAKE3 小时前
5.22【A】
算法
跨境牛马哥3 小时前
2026爬虫开发:Playwright对决Puppeteer
大数据·网络·网络协议
Brookty3 小时前
协议分层传输、TCP报头与TCP三次握手介绍
网络·tcp
2601_957882243 小时前
多账号流量内容运营的数据归因与ROI优化:从经验驱动到算法决策的技术转型
算法·产品运营·内容运营
ze^03 小时前
Day04 Web应用&蜜罐系统&堡垒机运维&API内外接口&第三方拓展架构&部署影响
网络·安全·web安全·安全架构