仅检查一次能保证 AsyncFnMut 始终返回 Send Future 吗?
结论
不能。这个宏无法保证返回的闭包在后续每次调用时都产生 Send Future。使用编译器生成的 async closure 就能构造反例,无须手写 AsyncFnMut 实现。
检查时,check_thread_safe 会推断借用 self 的生命周期,也会推断 Args。当闭包参数包含生命周期时,检查和后续调用可能选中不同的 AsyncFnMut<Args> 实例。
宏本身只包含安全操作,不会直接造成未定义行为,但它不能作为后续 unsafe 包装所需的安全性证明。
检查遗漏了参数中的生命周期
把关联类型写全,就能看到实际检查的对象:
<F as AsyncFnMut<Args>>::CallRefFuture<'a>: Send
这里要区分两处生命周期:
'a是借用闭包自身的生命周期。Args是调用参数元组,其中也可能含有独立的生命周期。
AsyncFnMut 的定义将 Args 放在 trait 参数中,将借用自身的生命周期放在 CallRefFuture<'a> 中,二者没有自动相等的要求。
即使关于隐藏的 self 借用生命周期的推理成立,也没有证明其他参数生命周期下的调用结果满足 Send。宏返回的仍是原来的 F,后续调用不受检查时选中的 Args 限制。
实际 async closure 的反例
可以构造一个只为 'static 实例提供 Send 实现的类型:
use std::marker::PhantomData;
struct StaticSend<'env>(&'env str, PhantomData<*const ()>);
// SAFETY: 只有共享字符串引用和不持有实际指针的标记,
// 没有线程相关资源,也没有解引用指针的操作。
unsafe impl Send for StaticSend<'static> {}
PhantomData<*const ()> 阻止类型自动获得 Send,显式实现则只覆盖 StaticSend<'static>。这一设计没有依赖错误的 unsafe impl:类型可以保守地只为一部分实例提供实现。原始指针与 Send 自动推导的关系可参见 Rustonomicon。
将下面的代码与题中的宏及辅助函数放在同一个 crate 中:
fn require_send<T: Send>(_: &T) {}
fn main() {
let mut callback = certify_thread_safe!(
async |item: StaticSend<'_>| {
drop(item);
}
);
let text = String::from("temporary input");
let future = std::ops::AsyncFnMut::async_call_mut(
&mut callback,
(StaticSend(text.as_str(), PhantomData),),
);
// 取消注释后,要求这个实际调用产生的 Future 实现 Send。
// require_send(&future);
drop(future);
}
示例沿用题中涉及的实验性接口,需要支持相应功能的 nightly。crate 根部通常需要以下 feature 声明,也可以使用 Edition 2024:
#![feature(async_fn_traits, unboxed_closures, tuple_trait)]
反例的推理过程如下:
- 宏检查闭包时,可以选中
Args = (StaticSend<'static>,),此时参数满足Send。 - 闭包本身接受带不同生命周期的
StaticSend。后续传入text.as_str()时,参数中的生命周期受局部变量text限制。 - 这个实际调用返回的 Future 捕获了非
'static的参数,无法满足示例中只为'static实例提供的Send约束。
闭包类型参考文档明确说明,async closure 会捕获所有输入参数。因此,即使函数体中直接 drop(item),调用刚返回、尚未被 poll 的 Future 仍需要保存该输入。
取消 require_send(&future) 的注释,就可以让编译器检查实际 Future。相关约束会要求局部字符串的借用满足 'static,从而产生生命周期错误;具体诊断文字取决于工具链。
这个反例使用了 async_call_mut,不依赖按值调用闭包时的其他行为。它利用的是 Args 的变化,并不单独证明“固定 Args 时,只检查一次 self 借用生命周期”是否成立,但足以否定当前宏的完整承诺。
如何建立可靠的 Send 保证
在产生 Future 的位置检查
如果消费端能直接接触每次调用的结果,就把约束放在那里:
use std::future::Future;
fn require_send_future<F>(future: F) -> F
where
F: Future + Send,
{
future
}
// 对每一次实际调用的结果进行检查:
// let future = require_send_future(callback(argument));
这样检查的是实际参数对应的 Future,不会将一次推断的结果推广到其他调用。Send 与 'static 是不同的约束;这里没有额外要求 Future 为 'static。
让自定义 trait 直接承诺返回 Send Future
如果可以调整接口,可以在 trait 方法的返回类型中表达要求。例如,对接受字符串引用的回调:
use std::future::Future;
trait SendCallback {
fn call<'a>(
&'a mut self,
input: &'a str,
) -> impl Future<Output = ()> + Send + 'a;
}
在这个接口示例中,编译器会验证每个实现的返回值是否满足 Send,同时允许返回值借用 self 和输入。实际参数、输出以及生命周期之间的关系,应按业务接口定义。不能仅凭原宏的检查,为任意 AsyncFnMut 自动提供这样的实现。
如果仍通过关联类型约束设计泛型接口,就需要覆盖接口允许的所有参数生命周期及 self 借用生命周期。HRTBs 表达的是对所有生命周期成立的约束。遇到相关表达能力或隐含 'static 限制时,需要调整接口,不能用一次检查替代完整证明。
unsafe 包装还需要注意什么
CallRefFuture 的 Send 与闭包值本身的 Send、Sync 是不同要求,应根据实际跨线程方式分别约束。如果包装器允许按值调用,还必须考虑 AsyncFnOnce::CallOnceFuture。它是独立的关联类型,原检查没有覆盖它。
在这些要求由类型系统或完整安全性论证覆盖之前,不应把 certify_thread_safe! 当作 unsafe API 的认证步骤。
备注:内容仅供参考。
