PAYATHON 2026

仅检查一次能保证 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)]

反例的推理过程如下:

  1. 宏检查闭包时,可以选中 Args = (StaticSend<'static>,),此时参数满足 Send。
  2. 闭包本身接受带不同生命周期的 StaticSend。后续传入 text.as_str() 时,参数中的生命周期受局部变量 text 限制。
  3. 这个实际调用返回的 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 的认证步骤。

备注:内容仅供参考。