这个问题的历史可以追溯到 Rust 1.36 中 std::task::Waker 的稳定化,该版本引入了一个标准化机制,使执行器能够通知 futures 是否准备好。在此之前,异步框架依赖于框闭或自定义通知特征,这带来了分配开销,并妨碍了与 C 库的无缝集成。 RawWaker API 的设计旨在通过允许开发者从原始指针和函数指针表 (RawWakerVTable) 构造 Waker 实例,来支持零成本抽象,类似于 C++ 的虚函数表,但符合 Rust 的安全要求。
问题在于 RawWaker 的构造完全绕过了 Rust 的所有权和借用系统。程序员必须手动确保四个关键不变式:数据指针在所有 Waker 克隆的生命期内必须保持有效(不仅仅是原始的),四个 vtable 函数(clone、wake、wake_by_ref、drop)必须是线程安全的(Send 和 Sync),即使执行器是单线程的,并且 clone 函数必须返回一个新的 RawWaker 以引用相同的底层任务状态。此外,vtable 必须使用 extern "C" ABI 以确保 FFI 兼容性和跨 Rust 版本的稳定调用约定。
解决方案要求严格遵守 unsafe 不变式。数据指针通常应该引用 'static 数据,或被包装在 Arc 中,以便在克隆之间管理共享所有权。vtable 函数必须正确实现引用计数语义:clone 应该增加计数,drop 应该减少计数,wake 应该在通知后减少计数(消耗 Waker)。违反 ABI 合同——例如使用 Rust 调用约定而不是 extern "C"——会导致在执行器调用这些指针时发生未定义行为,包括堆栈损坏、参数未对齐或跳转到无效内存地址。
use std::sync::Arc; use std::task::{RawWaker, RawWakerVTable, Waker}; struct TaskState { id: u64, } unsafe fn clone_waker(data: *const ()) -> RawWaker { let arc = Arc::from_raw(data as *const TaskState); let _ = Arc::clone(&arc); let _ = Arc::into_raw(arc); // 防止 drop 的泄漏 RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // 删除 Arc,释放引用 } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // 在这里处理唤醒逻辑,然后防止泄漏 let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // 隐式删除释放内存 } static VTABLE: RawWakerVTable = RawWakerVTable::new( clone_waker, wake_waker, wake_by_ref, drop_waker, ); fn create_waker(state: Arc<TaskState>) -> Waker { let ptr = Arc::into_raw(state) as *const (); unsafe { Waker::from_raw(RawWaker::new(ptr, &VTABLE)) } }
考虑开发一个高频交易系统,其中 Rust 异步运行时必须与一个传统的 C++ 市场数据源库进行接口。C++ 库提供了一个注册函数,接受 void* 上下文和一个函数指针,当价格更新到达时调用回调。工程挑战需要创建一个 Waker,在不引入每条消息分配开销的情况下,在 Rust futures 和这个 C++ 回调机制之间建立桥梁,因为延迟要求要求在微秒以下的唤醒时间。
一个解决方案涉及将 Box<dyn Fn() + Send> 闭包存储为 Waker 的数据指针。这个方法通过 Rust 的所有权系统提供了内存安全,且集成简单。然而,它为每个市场数据订阅引入了不可接受的堆分配延迟和违反系统零拷贝架构的虚拟调度开销。此外,在 FFI 边界上管理盒装闭包的生命周期被证明是危险的,因为 C++ 库的异步清理可能会留下一些悬空指针,如果 Rust 侧在 C++ 库停止调用回调之前删除了 Waker。
另一种方法利用一个全局静态哈希映射,将整数 ID 映射到任务句柄,作为 void* 上下文传递。这消除了分配并提供了 O(1) 的唤醒操作期间查找。然而,如果任务完成而没有从数据源注销,这会造成内存泄漏危险,并且静态映射需要 Mutex 同步,这在高市场数据吞吐量下成为一个争用瓶颈,有效地在所有 CPU 核心之间串行化唤醒通知。
所选解决方案实现了一个自定义 RawWaker,其中数据指针包含一个 Arc<TaskState>,其中包含 C++ 回调上下文和一个完成标志。 RawWakerVTable 函数作为 unsafe extern "C" 垫片实现,安全地将 void* 转换回 Arc 指针,确保跨 FFI 边界的正确引用计数。这个设计通过重用 Arc 结构消除了每条消息的分配,通过 Arc 的原子操作维持线程安全,并确保内存安全,仅在最后一个 Waker 克隆被删除时减少引用计数。结果是在保持 Rust/C++ 边界的内存安全保证的同时,实现了亚微秒的唤醒延迟,成功通过 Miri 的未定义行为检测和涉及数百万个并发价格更新的压力测试。
为什么即使执行器是单线程的,RawWakerVTable 函数也必须是线程安全的(Send + Sync)?
Waker 类型实现了 Clone、Send 和 Sync,允许它跨线程边界迁移,无论执行器的线程模型如何。当一个 future 持有一个 Waker 并将其传递给一个 spawn_blocking 任务或一个 std::sync::mpsc 通道时,Waker 可能会在与创建它的线程不同的线程中被调用。如果 vtable 函数假设单线程访问 - 例如,通过使用 Rc 或未同步的静态可变 - 当 wake() 同时被调用时,它们会造成数据竞争。此外,像 Tokio 或 async-std 这样的异步运行时可能会在工作线程之间迁移任务以进行负载均衡,这意味着 Waker 可能在与其创建位置不同的线程上被克隆和删除。线程安全要求确保通知机制在程序的任何地方共享 Waker 时仍然有效。
如果 clone 函数返回一个具有与原始对象不同的 vtable 的 RawWaker,会发生什么灾难性故障?
Waker 合同要求所有 Waker 的克隆表示相同的底层任务,并在调用时表现相同。如果 clone 返回一个指向不同 vtable 的 RawWaker - 可能是与不同任务相关联的 vtable 或包含空函数指针的 vtable - 执行器可能在通知任务时调用错误的唤醒逻辑。这会导致唤醒一个不相关的任务(逻辑损坏)或跳转到无效内存(段错误)。具体而言,执行器通常在内部队列中存储 Waker 克隆;当事件发生时,它在这些存储的句柄上调用 wake()。不匹配的 vtable 意味着数据指针(任务上下文)通过错误的函数签名进行解释,导致在 vtable 函数将指针转换为错误类型或访问错误偏移量时立即出现未定义行为。
为什么 extern "C" ABI 对 vtable 函数是强制性的,而不是默认的 Rust ABI?
RawWakerVTable 指定 extern "C" 函数指针,以保证 FFI 兼容性和 ABI 稳定性。 Rust ABI 在编译器版本或优化级别之间并不稳定;函数签名可能会根据编译器内部、内联决策或目标架构而变化。使用 extern "C" 确保调用约定遵循平台的 C 标准,使 vtable 兼容 C 代码,并防止在编译器为函数指针生成代码时出现未定义行为。此外,extern "C" ABI 强制特定寄存器使用和堆栈清理规则,允许 Waker 安全地跨语言边界传递。没有这个约束,与动态库链接或升级 Rust 编译器可能会改变函数调用约定,导致在执行器调用 wake() 或 clone() 时发生堆栈损坏或参数未对齐。