写过八年 Rust async,今天才把「浏览器里根本没有 runtime」这件事彻底想清楚了

在 native Rust 里跑异步,你第一反应是装个 tokio。它帮你管线程池、帮你调 executor、帮你 wake future——但这套逻辑在浏览器里根本不存在。wasm32-unknown-unknown 这个 target 没有线程、没有 OS、没有文件 I/O。那些 tokio::spawn 在 WASM 编译时会静默降级成单线程串行,而你可能到上线都不知道。


第一个真相:block_on 在浏览器里会冻死

你写 native Rust 时 block_on(some_future()) 是标准操作。迁移到浏览器 WASM 时,很多人会想「找个替代品」,然后找到 pollster——结果还是一样的:卡死。

原因很简单:浏览器的 JS 运行在单线程里,event loop 是唯一的调度器。你在 WASM 里同步阻塞等 future 完成,waker 来自 microtask queue,但 microtask queue 需要 event loop 来执行——event loop 被你 block 住了,waker 永远不会被调用,页面冻住。

// ❌ 这段代码会让整个 tab 无响应
use pollster::FutureExt;
let result = async { some_async_work().await }.block_on();

在 WASM 环境里,没有任何等待可以是同步的。所有等待都必须走 async/await + 事件循环。


第二个真相:tokio::spawn 在 WASM 里是假的

你 Cargo.toml 里写了 tokio,然后在 WASM 里调 tokio::spawn(async { ... })——编译不报错,运行时也没 panic。但你期待的多线程并发永远不来。

因为 WASM 目标上 tokio 内部调用的是 spawn_local,所有 future 在单线程上串行执行。不是并行,是交替。tokio::join! 也一样,不是真并发,是协作式轮转。

你以为的 实际发生的
tokio 多线程 thread pool 没有线程,只有单线程
tokio::spawn 真正并发执行 spawn_local 单线程串行
tokio::join! 并发运行多个 future 交替执行,不是并行
async I/O 多路复用 所有 I/O 都是浏览器 API,没有 OS I/O

所以在 WASM 里,与其装 tokio,不如直接用 wasm-bindgen-futures,轻量得多。


第三个真相:浏览器 event loop 就是你的 runtime

这件事反直觉,但说出来其实很顺:

native Rust 需要 runtime(tokio/async-std)来 poll futures。但在浏览器 WASM 里,wasm-bindgen-futures 把 Rust Future 转换成了 JavaScript Promise,直接交给浏览器 event loop 来驱动

每次 .await 都是一次控制权让渡:Rust future 暂停,JS 引擎继续处理 microtask queue;当底层操作(fetch、WebGPU、Timer)完成时,浏览器通过 microtask 触发 Rust 的 waker,future 从暂停处恢复。

这意味着:Cargo.toml 只需要 wasm-bindgen-futures,不需要任何 Rust async runtime

#[wasm_bindgen]
pub async fn init_webgpu(canvas_id: &str) -> Result<(), JsValue> {
    // 每个 .await 都是把控制权还给浏览器的 event loop
    let adapter = instance.request_adapter(&options).await?;
    let (device, queue) = adapter.request_device(&desc, None).await?;
    Ok(())
}

JS 端就是普通的 Promise 调用:

await wasm.init_webgpu("canvas");

第四个真相:长时间计算要主动给浏览器「让座」

这是最实用的一个点。

Rust 在 WASM 里做纯 CPU 计算(比如图像处理、加密、模拟)时,会独占主线程,页面 UI 无响应。解法是定期让出主线程,让浏览器有机会处理渲染和交互事件:

use wasm_bindgen_futures::JsFuture;
use js_sys::Promise;

// 每处理 N 个单元,主动 yield 一次
for i in 0..100_000 {
    result = result.wrapping_mul(i + 1).wrapping_add(i);

    // 每 1000 次,让浏览器喘口气
    if i % 1000 == 0 {
        JsFuture::from(Promise::resolve(&JsValue::NULL)).await?;
    }
}

这个技巧等于在 Rust 的计算循环里插了「让座」语句,让 microtask queue 有机会处理其他任务。这是 wasm-bindgen-futures 最重要的实战用法之一,却很少有人提到。


第五个真相:JsFuture 有开销,别在紧密循环里裸调

wasm-bindgen-futures 三个核心 API:

use wasm_bindgen_futures::{JsFuture, spawn_local, future_to_promise};

// JS Promise → Rust Future
let data: JsValue = JsFuture::from(fetch_promise()).await?;

// Rust Future → JS Promise(导出给 JS 调用)
#[wasm_bindgen]
pub fn run_task() -> Promise {
    future_to_promise(async {
        let result = heavy_computation().await;
        Ok(result.into())
    })
}

// 在当前线程上运行 Rust Future(最常用)
spawn_local(async move {
    let result = some_async_fn().await;
    web_sys::console::log_1(&format!("Done: {}", result).into());
});

注意:JsFuture::from() 每次调用有几微秒的固定开销。如果在紧密循环里高频调用,这个开销会明显堆积。优化方案是批量收集请求,一次 join! 多个 future,减少桥接次数

另一个边界:serde_wasm_bindgen 的 from_value 对大对象(>1MB)解析很慢。传大数据用 SharedArrayBuffer 做零拷贝,而不是序列化和反序列化。


怎么判断该不该把代码搬到 WASM

最后这个问题决定了你值不值得踩这些坑:

  • 纯计算任务(加密、压缩、解析、ML inference、图像处理):Rust WASM 比等效 JS 快 2-5 倍,值得
  • DOM 操作:JS 更快,WASM-to-DOM 的边界开销会吃掉所有收益,别搬
  • 混合任务:用 WASM 处理计算,通过 SharedArrayBuffer 传大数据给 JS 做渲染

下一步

下次在浏览器里跑 Rust 异步代码,先想清楚这三件事:

  1. 不要 block_on——没有替代品,同步等待在 WASM 里就是死路
  2. 不需要 tokio——wasm-bindgen-futures 够用,浏览器 event loop 就是你的 runtime
  3. 长计算要 yield——每 N 次循环插一次 JsFuture::from(Promise::resolve(NULL)).await,页面才不会卡死

理解 WASM 里「没有 runtime」这件事,才是正确使用 async 的起点。

评论区

0 条评论

登录后可评论。

铁锈·Rust工具链 183 阅读