目标:理解为什么"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 { ... })→ 要求 futureSend。- 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 signature | impl 和 trait 生命周期不一致 | 让两者签名逐字一致 |
future cannot be sent between threads safely | future 借用 &self,但 dyn 无 Sync | trait 加 : Send + Sync |
cannot find lifetime 'self / 'str | 无效生命周期名 | 用 'a 或省略(&self) |
