Rust 1.75 稳定了 async fn in trait,终结了多年的 workaround 时代。这篇记录实际使用中的要点和坑。
漫长的等待
Rust 的 async/await 在 1.39 就稳定了,但 trait 中的异步函数一直是个缺口。以前要在 trait 里定义异步方法,只能借助 async-trait 这个 proc macro crate:
use async_trait::async_trait;
#[async_trait]
trait Storage {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
async-trait 的原理是将 async fn 展开为返回 Pin<Box<dyn Future>> 的普通函数。它能用,但代价是每次调用都有一次堆分配。在高频调用的场景(比如存储引擎、网络框架的中间件)这个开销不可忽略。
稳定化后的写法
从 Rust 1.75 开始,可以直接在 trait 中写 async fn:
trait Storage {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
struct MemoryStore {
data: std::collections::HashMap<String, String>,
}
impl Storage for MemoryStore {
async fn get(&self, key: &str) -> Option<String> {
self.data.get(key).cloned()
}
async fn set(&mut self, key: &str, value: String) {
self.data.insert(key.to_string(), value);
}
}
编译器会为每个 async fn 生成一个匿名的 Future 类型,这个 Future 的大小在编译时确定,不需要 Box。
Send bound 的问题
第一个会遇到的问题:当你用 tokio::spawn 时,要求 Future 是 Send 的。但 trait 中 async fn 返回的 Future 默认不保证 Send。
// 这会编译失败
async fn process<S: Storage>(store: &S) {
let handle = tokio::spawn(async move {
store.get("key").await // Future 可能不是 Send
});
}
解决方案是在 trait 定义中显式约束返回的 Future 必须是 Send。Rust 1.75 的写法略显繁琐,需要用 return-position impl Trait 语法:
trait Storage: Send + Sync {
fn get(&self, key: &str) -> impl Future<Output = Option<String>> + Send + '_;
fn set(&self, key: &str, value: String) -> impl Future<Output = ()> + Send + '_;
}
这比 async fn 写起来啰嗦,但语义更精确。社区也在讨论更好的语法糖,比如 async(Send) fn。
trait object 的困境
第二个问题更根本:async fn in trait 目前不支持 dyn Trait。
// 编译失败
fn get_storage() -> Box<dyn Storage> {
Box::new(MemoryStore::new())
}
原因在于 async fn 返回的 Future 类型在每个实现中不同,编译器无法在运行时确定其大小,所以无法构造 vtable。
要使用动态分发,仍然需要回到 Box 的方案。trait-variant crate 提供了一个简洁的处理方式:
use trait_variant::make_variant;
#[make_variant(DynStorage: Send)]
trait Storage {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: String);
}
// DynStorage 是自动生成的 trait,返回 Box<dyn Future>
// 可以用于 dyn DynStorage
fn get_storage() -> Box<dyn DynStorage> {
Box::new(MemoryStore::new())
}
trait-variant 是 Rust 官方维护的 crate,算是过渡期的推荐方案。
实际项目中的应用
在一个数据处理项目中,我定义了一个数据源 trait:
trait DataSource: Send + Sync {
fn fetch_batch(
&self,
offset: u64,
limit: u64,
) -> impl Future<Output = Result<Vec<Record>, Error>> + Send + '_;
fn count(&self) -> impl Future<Output = Result<u64, Error>> + Send + '_;
}
然后分别实现了 PostgreSQL 和 CSV 两个后端。在编译时通过泛型选择后端,避免了动态分发的开销:
async fn process_all<D: DataSource>(source: &D) -> Result<(), Error> {
let total = source.count().await?;
let mut offset = 0;
while offset < total {
let batch = source.fetch_batch(offset, 1000).await?;
for record in batch {
// 处理每条记录
}
offset += 1000;
}
Ok(())
}
这种泛型方案在性能敏感的场景下是首选。只有当需要运行时动态选择后端时,才考虑 trait object。
迁移建议
对于已有项目,从 async-trait 迁移到原生 async fn in trait 需要注意:
- 如果不需要
dyn Trait,可以直接迁移,去掉#[async_trait]宏 - 如果需要 Send bound,改用
-> impl Future + Send的写法 - 如果需要 trait object,暂时保留
async-trait或切换到trait-variant async-trait的?Send变体在原生语法中自然就是默认行为
总的来说,async fn in trait 的稳定化是 Rust 异步生态的重要一步。虽然 trait object 支持还不完善,但对于大多数使用泛型的场景已经足够。