跳转到正文

Rust 手写异步 Trait:不用 async_trait,搞懂生命周期、Send/Sync 和动态分发


目标:理解为什么"trait 里的 async 方法"很麻烦,以及不用 async_trait 时如何手工写出正确、可跨线程的代码。

1. 先看最终能跑的代码

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

struct User {
    id: String,
    name: String,
    email: String,
    age: u8,
}

// 关键点 1: trait 声明返回 `Pin<Box<dyn Future + Send>>`
// 关键点 2: trait 继承 `Send + Sync`,保证 `dyn` 能跨线程
trait UserService: Send + Sync {
    fn get_user<'a>(&'a self, id: &'a str)
        -> Pin<Box<dyn Future<Output = Option<User>> + Send + 'a>>;
}

struct TestUserService;
impl UserService for TestUserService {
    fn get_user<'a>(&'a self, id: &'a str)
        -> Pin<Box<dyn Future<Output = Option<User>> + Send + 'a>> {
        let id = id.to_string();            // 提前复制,消除借用
        Box::pin(async move {               // 装箱 + 变为 'static
            Some(User {
                id,
                name: "test".to_string(),
                email: "xx@x.com".to_string(),
                age: 11,
            })
        })
    }
}

async fn user_service(service: Arc<dyn UserService>) {
    tokio::spawn(async move {
        let user = service.get_user("123").await;
    });
}

#[tokio::main]
async fn main() {
    user_service(Arc::new(TestUserService)).await;
}

2. 背景:为什么 async 方法不能直接写在 trait 里?

原生 async fn 是语法糖,脱糖成返回 impl Future(RPITIT):

trait UserService {
    // 这只是写法,真正签名是:
    fn get_user<'a>(&'a self, id: &'a str)
        -> impl Future<Output = Option<User>> + 'a;   // 匿名类型,无法命名
}

问题在于:trait 要能被当作 dyn UserService(动态分发)用,每个方法的返回类型必须可命名、可写进 vtable。 而 impl Future 是匿名的、大小未知,违反了"对象安全",于是 Box<dyn UserService> 直接报 E0038

async_trait 的解决办法:把返回类型改写成可命名、大小已知的 Pin<Box<dyn Future>>,这样 trait 就能对象化。本文就是手动做这件事。

3. 三个核心概念

3.1 Future 是一台"只能被问进度"的机器

fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<T>
  • Poll::Ready(v) → 完成,给结果
  • Poll::Pending → 未完成,过会儿再来问

Future 不会自己跑,必须由 executor 反复调用 poll。所有 async/await 代码都需要有人驱动,这就是为什么 main 里必须有 runtime(如 #[tokio::main])。

3.2 Box::pin 让"未知大小 + 需要固定"的 future 可用

dyn Future 是胖指针,大小未知,不能直接放栈上、不能直接返回。Box<dyn Future> 把它放到堆上,大小就固定了。Pin 保证它在内存中的地址不被移动——这是 async 状态机的安全前提。

所以 async fn 的返回值一律是 Pin<Box<dyn Future>>(或 async_trait 里的变体)。

3.3 Send + Sync 是跨线程的前提

  • Send:该值可以移动到另一个线程。
  • Sync:该值可以被多个线程同时共享引用

tokio::spawn 要求传入的 future 是 Send。而 future 会借用 &self(&dyn UserService),&T 要 Send 要求 T: Sync。所以:

trait UserService: Send + Sync { ... }

4. 逐行拆解签名

4.1 trait 签名

fn get_user<'a>(&'a self, id: &'a str)
    -> Pin<Box<dyn Future<Output = Option<User>> + Send + 'a>>;
  • 'a 是 &self 的生命周期(这里也顺手让 &str 用同一个 'a)。
  • 返回的 future 带 +'a,意思是:这个 future 借用了 &self,只要 &self 还活着,它就能被 poll。 这是"非 static"的生命周期版本。
  • + Send 让 future 能跨线程传给 tokio::spawn

4.2 impl 签名必须与 trait 完全一致

impl UserService for TestUserService {
    fn get_user<'a>(&'a self, id: &'a str)
        -> Pin<Box<dyn Future<Output = Option<User>> + Send + 'a>> { ... }
}

如果 impl 写成了不带 'a 的 Pin<Box<dyn Future + Send>>,就和 trait 的 + 'a 对不上,报:

impl item signature doesn't match trait item signature

这就是你之前踩的坑:两边生命周期不一致。

4.3 async 块里为什么是 move?

let id = id.to_string();       // 先把借用转成拥有的 String
Box::pin(async move { ... })   // move 把 id 移进闭包

async move 会把用到的变量移动进 future,而不是借用。这样 future 就不再借用任何外部参数,生命周期更宽松。因为 id 已经是 String(owned),所以 future 实际上变成了 'static——虽然我们签名里写了 'a,但 'a 是上界,'static 满足它,合法。

5. 生命周期方案对比

方案签名适用场景
借用 &self(非 static)Pin<Box<dyn Future + Send + 'a>>需要持有 &self 内部字段引用时
不借用(static)Pin<Box<dyn Future + Send>>已在进入 async 块前复制/拥有数据(最常见)

如果你想用"不借用"方案,更简洁:

trait UserService: Send + Sync {
    fn get_user(&self, id: &str)
        -> Pin<Box<dyn Future<Output = Option<User>> + Send>>;
}

impl UserService for TestUserService {
    fn get_user(&self, id: &str)
        -> Pin<Box<dyn Future<Output = Option<User>> + Send>> {
        let id = id.to_string();
        Box::pin(async move { Some(User { id, .. }) })
    }
}

因为 id.to_string() 把数据复制出来了,future 不借用任何东西 → 天然 'static如果你的方法不依赖 &self 内部的借用,优先用这个方案,省去所有生命周期标注。

6. 为什么动态分发 + Send/Sync 一起出现

  • user_service(service: Arc<dyn UserService>) → 用 Arc 做动态分发并共享。
  • tokio::spawn(async move { ... }) → 要求 future Send
  • future 借用了 service,&Arc<dyn UserService> 要 Send → dyn UserService 必须 Sync
  • 所以 trait 声明了 : Send + Sync

去掉任意一环(比如不用 tokio::spawn,或 Arc 改成 Box),就不需要 Sync。这也是为什么错误常在 spawn 处出现。

7. 什么时候该用 async_trait 而不是手写?

手写 Pin<Box<dyn Future>>async_trait
代码量多,要手动管生命周期少,直接写 async fn
出错率高(签名不匹配、生命周期)低,宏自动处理
每调用一次有一次堆分配(Box)同样有堆分配
可读性

结论:除非你在写底层库、追求极致控制,否则推荐 async_trait。手写的价值在于:彻底搞懂 async 方法为什么需要装箱、生命周期怎么标、Send/Sync 从哪来。本文就是这份"底层原理"。

8. 常见错误速查

报错原因修法
E0038: trait ... not dyn compatible返回 impl Future 无法对象化返回 Pin<Box<dyn Future>>
impl item signature doesn't match trait item signatureimpl 和 trait 生命周期不一致让两者签名逐字一致
future cannot be sent between threads safelyfuture 借用 &self,但 dyn 无 Synctrait 加 : Send + Sync
cannot find lifetime 'self / 'str无效生命周期名用 'a 或省略(&self)