WASM 与 JS 互操作中的异步模型:Promise 和 Future 的桥梁怎么搭
一、当 Rust 的 async 遇上 JS 的 Promise
用 Rust 写 WASM 模块最让我感到别扭的地方,就是异步模型的不匹配。
Rust 用的是 async/await + Future 这套体系,JS 用的是 Promise + async/await。表面上await这个关键字两边都有,但底层的调度机制完全不同。Rust 的 Future 是惰性的(需要被 poll 才会执行),JS 的 Promise 是热启动的(创建后立即开始执行)。
这种差异带来了一个实际问题:Rust 编译成的 WASM 不能直接await一个 JS 的 Promise。如果你在 Rust 的 async 函数里想调用一个 JS 的异步 API(比如fetch),必须通过某种机制把 Promise 转成 Rust 能理解的 Future,或者反过来。
wasm-bindgen和wasm-bindgen-futures就是做这个转换的桥梁。这篇文章聊聊它们的工作原理和具体用法。
二、Promise 和 Future 之间的转换过程
要理解这个桥梁是怎么工作的,先看看从 Rust 调 JS Promise 时的完整时序:
sequenceDiagram participant Rust as Rust async fn participant Bridge as wasm-bindgen-futures participant JS as JavaScript Promise participant EventLoop as JS 事件循环 Rust->>Bridge: .await 一个 JsFuture Bridge->>JS: 调用 JS 函数,得到 Promise JS->>EventLoop: Promise 进入 pending 状态 Bridge->>Bridge: 注册 .then() 回调 Bridge->>Rust: Future 返回 Poll::Pending Note over Rust: Rust 挂起,控制权交给 JS EventLoop->>EventLoop: 继续处理其他事件 EventLoop-->>JS: Promise resolve JS->>Bridge: 触发 .then() 回调 Bridge->>Rust: wake() 通知 Future 就绪 Rust->>Bridge: 再次 poll Bridge->>Rust: Poll::Ready(结果) Note over Rust: Rust 恢复执行这个流程的关键是:当 Rust 的 Future 还没就绪时(返回Poll::Pending),控制权会还给 JS 的事件循环。等到 JS 那边 Promise resolve 了,再通过回调通知 Rust 侧的 Future 可以继续执行了。
三、代码实战
3.1 从 Rust 调用 JS 的异步 API
最常见的场景是在 Rust 的 WASM 模块里调用浏览器 API,比如fetch:
use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use web_sys::{window, Request, RequestInit, Response}; /// 在 Rust 的 async 函数中调用 JS 的 fetch API pub async fn fetch_json(url: &str) -> Result<JsValue, JsValue> { // 1. 创建 RequestInit 配置对象 let mut opts = RequestInit::new(); opts.method("GET"); opts.mode(web_sys::RequestMode::Cors); // 2. 创建 Request 对象 let request = Request::new_with_str_and_init(url, &opts)?; // 3. 调用 window.fetch() —— 这会返回一个 JS Promise let window = window().ok_or("无法获取 window 对象")?; let promise = window.fetch_with_request(&request); // 4. 将 JS Promise 转换为 Rust Future // JsFuture::from() 是关键,它把 Promise 包装成一个实现了 Future trait 的类型 let response = JsFuture::from(promise).await?; // 5. 解析 Response 对象 let response: Response = response.dyn_into()?; let json_promise = response.json()?; // response.json() 也返回 Promise let json = JsFuture::from(json_promise).await?; Ok(json) } // 在 WASM 入口中使用 #[wasm_bindgen] pub async fn load_user_data() -> Result<JsValue, JsValue> { // 直接在 Rust 中 await,不需要手动处理 Promise let data = fetch_json("https://api.example.com/user/42").await?; // 解析 JSON 数据 let name = js_sys::Reflect::get(&data, &JsValue::from_str("name"))?; console::log_1(&format!("用户名: {}", name.as_string().unwrap_or_default()).into()); Ok(data) }JsFuture::from(promise)是核心。它在内部做了几件事:
- 把 JS Promise 注册到浏览器的微任务队列
- 给 Promise 挂上
.then()和.catch()回调 - 返回一个实现了
Futuretrait 的 Rust 对象 - 当 Promise 完成时,回调会触发 Rust Future 的
wake(),让它可以被重新 poll
3.2 从 JS 调用 Rust 的异步函数
反过来,JS 也可以调用 Rust 的异步函数:
use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; /// 一个耗时的 Rust 异步计算函数 #[wasm_bindgen] pub async fn heavy_computation(input: u32) -> Result<u32, JsValue> { // 模拟耗时操作 let mut result = input; for i in 0..1000 { result = result.wrapping_mul(i + 1).wrapping_add(i); // 主动 yield,让浏览器有时间处理 UI 事件 if i % 100 == 0 { // 使用微任务 yield,避免长时间阻塞主线程 wasm_bindgen_futures::JsFuture::from( js_sys::Promise::resolve(&JsValue::NULL) ).await?; } } Ok(result) } /// 在 Rust 侧启动异步任务(不阻塞 JS 的主线程) #[wasm_bindgen] pub fn start_background_task() { // spawn_local 在浏览器的主线程上启动异步任务 // 任务会自动被浏览器的微任务队列调度 spawn_local(async { match heavy_computation(42).await { Ok(result) => { console::log_1(&format!("计算结果: {}", result).into()); } Err(e) => { console::error_1(&format!("计算失败: {:?}", e).into()); } } }); }JS 侧直接调用:
import init, { heavy_computation, start_background_task } from './pkg/my_wasm.js'; async function main() { await init(); // 方式1: 直接 await Rust 的异步函数 // #[wasm_bindgen] 标记的 async fn 会被自动包装为返回 Promise const result = await heavy_computation(42); console.log('直接调用结果:', result); // 方式2: 不 await,让 Rust 在后台运行 start_background_task(); // 这行会立即执行,不会等 background task 完成 console.log('后台任务已启动,主线程继续执行'); } main();3.3 同时 await 多个 Promise
Rust 的并发能力在 WASM 中也适用:
use wasm_bindgen_futures::JsFuture; use web_sys::window; /// 并发调用多个 API,等所有结果都返回后再继续 pub async fn fetch_multiple() -> Result<Vec<JsValue>, JsValue> { let window = window().ok_or("无法获取 window")?; // 创建三个并发的 fetch 请求 let p1 = window.fetch_with_str("https://api.example.com/users"); let p2 = window.fetch_with_str("https://api.example.com/posts"); let p3 = window.fetch_with_str("https://api.example.com/comments"); // 使用 tokio::join! —— 在 WASM 中表现为并发而非并行 // 注意:WASM 目前不支持真正的多线程(除非使用 Web Workers) let (r1, r2, r3) = tokio::join!( JsFuture::from(p1), JsFuture::from(p2), JsFuture::from(p3), ); Ok(vec![r1?, r2?, r3?]) }3.4 使用 gloo-timers 处理延时
WASM 中不能使用tokio::time::sleep(因为 Tokio 的定时器依赖操作系统),而要用浏览器原生的setTimeout:
use gloo_timers::future::TimeoutFuture; /// 在 WASM 中使用异步延时 pub async fn delayed_greeting(name: &str) { console::log_1(&"3秒后问好...".into()); // 使用浏览器的 setTimeout,不阻塞主线程 TimeoutFuture::new(3000).await; console::log_1(&format!("你好, {}!", name).into()); }四、边界与注意事项
WASM 单线程模型的限制。目前主流的 WASM 运行时(包括浏览器中的)都是单线程的(SharedArrayBuffer 和 Web Workers 除外)。这意味着tokio::join!虽然可以让多个 Future 并发执行,但它们是交替进行的,不是真正并行的。如果一个 Future 里有纯 CPU 计算,它会阻塞其他 Future。
不能在 WASM 中阻塞等待。std::thread::sleep和其他阻塞操作在 WASM 中会冻住整个浏览器页面。所有的"等待"操作都必须通过异步实现,或者使用gloo-timers这类浏览器 API 的封装。
宏任务 vs 微任务。JS 的事件循环区分宏任务(setTimeout、I/O)和微任务(Promise、queueMicrotask)。JsFuture使用的是微任务队列,这通常没问题,但如果有大量并发 Promise,可能需要关注微任务队列的堆积。
文件系统访问。WASM 默认没有文件系统访问权限。如果需要读写文件,必须通过 JS 桥接层使用浏览器的 File API 或 IndexedDB。
异步桥接还有一个性能边界:JsFuture::from()本身有固定的转换开销,大约每次调用几微秒。如果在一个紧密循环里反复调用 JS 异步函数,转换开销会堆积。高频场景下,可以批量收集请求,一次join!多个 Future,减少桥接次数。微优化积累起来,能省不少总耗时。
还有一个序列化边界:wasm-bindgen的JsValue和 Rust 类型互转时,serde_wasm_bindgen::from_value对大对象(>1MB)的解析会很慢。如果数据量超过这个阈值,考虑用SharedArrayBuffer做零拷贝传输。
五、总结
Rust WASM 和 JS 的异步桥接,核心就靠两个 crate:wasm-bindgen(定义桥接接口)和wasm-bindgen-futures(Promise ↔ Future 转换)。
大多数情况下,你只需要把 JS 返回的 Promise 包一层JsFuture::from(),然后正常.await就行。反过来,#[wasm_bindgen]标记的 Rust async 函数会自动在 JS 侧暴露为返回 Promise 的函数。转换过程虽然是自动的,但理解背后的机制(特别是单线程事件循环模型)对排查性能问题很有帮助。
实际开发中我踩过的最大坑是在 WASM 里试图用tokio::time::sleep——这在非 WASM 环境下完全正常,但在浏览器里直接 panic。凡是涉及延时的操作,一律用gloo-timers或其他浏览器 API 封装。