如何返回 async fn in trait?

解读

在国内 Rust 岗位面试中,“trait 里怎么写 async fn” 几乎是中高级岗的必考题。
面试官真正想验证的是:

  1. 你是否知道 async fn 在底层会被编译成一个返回 impl Future 的泛型函数
  2. 你是否明白 trait 里不能直接使用 async fn(直到 1.75 版本才稳定支持)
  3. 你是否能给出 稳定版可落地的三种主流方案(手动 impl Future、async-trait 宏、以及 1.75 之后的 AFIT),并权衡其性能与兼容性。
    回答时务必结合 国内生产环境普遍停留在 1.70 左右 的现状,给出最稳妥的落地姿势,而不是只背语法。

知识点

  1. async fn 的 desugar:编译器会把
    async fn foo() -> T
    翻译成
    fn foo() -> impl Future<Output = T> + '_.
    其中隐藏了一个匿名关联类型,导致 trait 无法直接命名该类型。
  2. object safety:trait 里出现 async fn 后,返回的 impl Future 会让 trait 丧失 object safety,无法生成 trait objectBox<dyn Trait>)。
  3. 稳定版解决方案
    • 手动 impl Future:在 trait 里用关联类型把 Future 显式命名出来,手写 poll 或用 boxed() 擦除类型,兼容 1.56+,零宏依赖,但代码冗长。
    • async-trait 宏:社区使用最广,自动把 async fn 展开成返回 Pin<Box<dyn Future + Send>>> 的普通函数,牺牲一次堆分配换来语法简洁,1.45+ 可用。
    • AFIT(Async Fn In Trait):1.75 起稳定,允许直接在 trait 里写 async fn,编译器自动生成隐式关联类型,零堆分配,但目前仍需 nightly 特性 return_position_impl_trait_in_trait 才能支持 trait object,生产环境需评估工具链版本
  4. Send 约束:国内微服务 99% 跑在 Tokio 多线程调度器上,返回的 Future 必须 Send,否则编译期会报 future cannot be sent between threads。手写或 async-trait 时记得加 Send 边界。
  5. 生命周期:如果 trait 方法带 &self,返回的 Future 会捕获 &self必须显式声明生命周期,否则会出现 cannot infer an appropriate lifetime 错误。

答案

“在 当前国内生产环境普遍使用 1.70 及以下 的情况下,最稳妥的方案是 async-trait 宏
步骤如下:

  1. 在 Cargo.toml 添加
    async-trait = "0.1"
  2. 在 trait 定义处加 #[async_trait],方法直接写 async fn:
    use async_trait::async_trait;
    
    #[async_trait]
    pub trait Cache {
        async fn get(&self, key: &str) -> Option<String>;
    }
    
  3. 实现端同样加 #[async_trait],即可像普通 async fn 一样写逻辑:
    pub struct RedisCache;
    
    #[async_trait]
    impl Cache for RedisCache {
        async fn get(&self, key: &str) -> Option<String> {
            // 异步调用 redis
            todo!()
        }
    }
    
  4. 如果团队已升级到 1.75+ 且无需 trait object,可直接用稳定版 AFIT:
    pub trait Cache {
        async fn get(&self, key: &str) -> Option<String>;
    }
    
    但目前 trait object 仍需 nightly,因此线上服务若需要 Box<dyn Cache>,仍建议回退到 async-trait 或手动 boxed。

一句话总结:稳定版优先 async-trait,1.75 以上可尝鲜 AFIT,务必验证 Send 与生命周期。”

拓展思考

  1. 性能敏感场景(如网关中间件)如何消除 async-trait 的堆分配?
    可以 手写 impl Future + Send,用 std::future::readypoll_fn 拼装状态机,完全零分配,但代码量翻倍,需权衡维护成本。
  2. trait object 的 Send 边界
    Box<dyn Cache> 默认不 Send,需要把 trait 声明为
    pub trait Cache: Send + Sync { ... }
    否则上层 Tokio spawn 会编译失败。
  3. AFIT 与 RPITIT(Return Position Impl Trait In Trait)
    一旦 RPITIT 稳定,trait 里既能写 async fn,又能返回 impl Future,同时支持 trait object,届时 async-trait 宏可能逐步退出历史舞台,但 升级节奏由国内发行版(OpenCloudOS、Anolis OS)的 Rust 包版本决定,面试时可主动提及“我们会跟随操作系统 LTS 节奏评估升级”,体现工程化思维。