async-trait crate 利用过程宏将 async fn 方法转换为返回 Pin<Box<dyn Future<Output = T> + Send + 'static>> 的同步方法。这种转换抹去了 async 块生成的具体未来类型,能够通过 vtable 实现动态分发,并允许 trait 保持对象安全。具体的运行时开销涉及每次方法调用时为了存储未来而进行的 Box 堆分配,以及与 dyn trait 对象分发相关的间接函数调用开销。此外,'static 边界防止未来借用非静态数据,强制所有捕获的引用必须拥有或具有 'static 生命周期。
我们的工程团队正在构建一个高性能的 TCP 服务器,要求具有动态加载连接处理程序的插件架构。我们需要一个 ConnectionHandler trait,包含 async fn handle(&mut self, stream: TcpStream) 来处理 I/O 操作,但 Rust 1.70 不支持 trait 中原生的 async fn。
使用具有 impl Future 返回类型的泛型 traits 替代 async fn 提供了零成本抽象,没有堆分配,并通过单态化进行激进的编译器优化。然而,这种方法从根本上阻止了动态分发,使得无法将异构处理程序存储在 Vec<Box<dyn ConnectionHandler>> 中或在运行时从共享库动态加载它们,而这对于我们的插件架构至关重要。
采用 async-trait crate 提供了与原生 async fn 相同的干净语法,同时通过 Box<dyn ConnectionHandler> 支持动态分发。主要缺点是每个方法必须进行堆分配来包装未来,以及 'static 生命周期要求防止在 await 点之间借用非静态数据,可能迫使额外的数据克隆。
手动实现 trait 返回 Pin<Box<dyn Future>> 而不使用宏,可以完全控制 Send 边界,消除过程宏的编译时开销。不幸的是,这需要极其冗长的样板代码,手动使用 Pin::new_unchecked 进行不安全的固定操作,并且在处理 await 点之间复杂的生命周期约束时极易出错,显著降低了开发速度。
我们最终选择了 async-trait crate 作为我们的解决方案,因为每个方法的堆分配开销在服务器主要是 I/O 密集型而非 CPU 密集型的情况下被认为是可以接受的,而且人性化的好处显著加快了开发速度。插件系统与 Box<dyn ConnectionHandler> 无缝协作,能够在不重新编译的情况下热替换模块,这满足了我们的架构要求。
在将代码库迁移到 Rust 1.75 后,我们系统地将 async-trait 替换为原生 async fn,在不需要动态分发的 trait 中消除了每次调用的堆分配,同时保持相同的干净 API 表面。性能分析确认,虽然遗留版本中存在打包开销,但与网络延迟相比微不足道,验证了我们最初的技术决策。
为什么 async-trait 需要 futures 为 'static,这一约束如何影响在 await 点之间的借用?
'static 边界的出现是因为 async-trait 将未来抹平为 Box<dyn Future + Send + 'static>,而 Rust 中的 trait 对象必须具有包含所有可能执行上下文的定义生命周期。由于执行器可能在线程边界跨越期间无限期持有未来或将其存储在内部队列中,编译器要求未来拥有其捕获的所有数据,或仅持有 'static 引用。这防止在 await 点之间借用局部变量,因为此类引用的生命周期与堆栈帧挂钩,非 'static。候选人经常忽视这一点,这是 trait 对象类型擦除的基本限制,而不仅仅是 crate 作者施加的任意限制。
如何 Pin<Box<dyn Future>> 返回类型与多线程执行器中的 Send 要求交互,当底层未来不是 Send 时会出现什么编译错误?
async-trait 自动为包装的未来添加 Send 边界 (Pin<Box<dyn Future + Send + 'static>>),以确保与像 Tokio 这样的工作窃取执行器之间的兼容性,这些执行器在执行期间可能在线程之间移动任务。为了使未来为 Send,async 块捕获的所有数据必须实现 Send。如果未来捕获了像 Rc 或原始指针这样的非 Send 类型,编译器会生成一个错误,指出未来不能在线程之间安全地发送,因为它实现了 !Send。候选人经常遗漏 Send 边界在多线程上下文中的线程安全性是至关重要的,而 async-trait 默认施加此边界,以防止运行时数据竞争,即使执行器在理论上可能是单线程的。
原生 async fn 在 traits 中(在 Rust 1.75 中稳定)和 async-trait 模拟之间在对象安全性和动态分发方面的根本架构区别是什么?
原生的 async fn 在 traits 中利用 Return Position Impl Trait In Traits (RPITIT),返回特定于每个实现的透明 impl Future 类型。这种方法是零成本的,并通过单态化进行静态分发,但它使 trait 变得不安全对象,因为 impl Trait 隐藏了 vtable 条目所需的具体类型。因此,您不能在原生 async fn 中创建 Box<dyn Trait>,除非您手动将返回值包装在 Box<dyn Future> 中。相比之下,async-trait 通过将未来立即打包为 Pin<Box<dyn Future>> 实现了对象安全性,该类型具有已知的大小,可以存储在 vtable 中,从而以堆分配的代价实现动态分发。候选人经常将两种方法混淆,认为原生的 async fn 自动支持 Box<dyn Trait>,或者认为 async-trait 仅仅是语法糖,而没有在对象安全性和分配策略方面的架构差异。