Rust:async trait稳定化与实战

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 需要注意:

  1. 如果不需要 dyn Trait,可以直接迁移,去掉 #[async_trait]
  2. 如果需要 Send bound,改用 -> impl Future + Send 的写法
  3. 如果需要 trait object,暂时保留 async-trait 或切换到 trait-variant
  4. async-trait?Send 变体在原生语法中自然就是默认行为

总的来说,async fn in trait 的稳定化是 Rust 异步生态的重要一步。虽然 trait object 支持还不完善,但对于大多数使用泛型的场景已经足够。