Skip to content

异步与并发

正确的抽象取决于工作必须在哪里运行,以及 Rust 运行期间是否需要调用 JavaScript。从能满足任务的最小抽象开始;把同步函数移到另一个线程,并不会让其依赖自动变得线程安全。

决策表

需求 使用 工作运行位置 JavaScript 结果
快速转换或计算 普通 #[napi] fn JavaScript 线程 立即返回值或抛出异常
Rust 异步 I/O 或异步生态 #[napi] async fn NAPI-RS Tokio runtime Promise<T>
使用 Node worker pool 的阻塞/CPU 工作 AsyncTask<T> libuv 线程池;resolve 返回 JS 线程 Promise<T>
从 OS/Tokio 线程调用 JS 函数 ThreadsafeFunction producer 线程,回调在 JS 线程 回调或等待返回值
惰性传递序列 iterator 或 async iterator 拉取模式 for...of / for await...of
使用 Web Streams 传输字节 ReadableStream / WritableStream Tokio 加 JS stream 回调 Web Streams API

每一行都适用两条规则:

  1. 只有 JavaScript 线程可以使用 Env 或原始 napi_value handle。
  2. 跨线程或 await 边界的数据必须被拥有足够长时间;借用的 JavaScript 值仅在函数作用域内有效。

把 buffer、对象或类实例移入后台工作前,请阅读理解生命周期

Tokio async fn

启用 async(它会启用 NAPI-RS Tokio runtime),并只启用 crate 实际使用的 Tokio feature:

Cargo.toml
toml
[dependencies]
napi = { version = "3", features = ["async"] }
napi-derive = "3"
tokio = { version = "1", features = ["fs", "time"] }
src/lib.rs
rust
use napi::bindgen_prelude::*;
use napi_derive::napi;

#[napi]
pub async fn read_config(path: String) -> Result<Buffer> {
  Ok(tokio::fs::read(path).await?.into())
}

future 及其输出会跨线程,因此必须满足 Send + 'static。应优先使用 StringBuffer 和拥有所有权的 typed array 等输入。不要让 JsString<'_>Object<'_>Env 跨越 await 点。

async fn 适用于异步 I/O。其中的长时间同步计算仍会占用 Tokio worker thread;阻塞工作请使用 tokio::task::spawn_blockingAsyncTask

取消不会自动发生

丢弃 JavaScript Promise 不会取消对应的 Rust future。长时间运行的工作需要设计取消协议:

  • 接受显式取消 handle 或操作 ID;
  • 将取消桥接到 Rust 所拥有的原子标志、channel 或库 cancellation token;
  • 取消后停止创建 JavaScript 工作;
  • 所有者关闭时 await 或 abort 每个已生成的 Tokio JoinHandle

napi::tokio::spawn 生成的分离工作不得比环境或它使用的 Rust/JavaScript 资源活得更久。请把 JoinHandle 保存在拥有它的类中,并在关闭路径中 abort 或 await。

AsyncTask 与 libuv worker pool

适合放入 Node 共享 libuv 线程池的有界阻塞工作请使用 AsyncTaskTask::compute 在 JavaScript 线程之外运行;完成后的 resolverejectfinally 在有 Env 可用的位置运行。

src/lib.rs
rust
use napi::bindgen_prelude::*;
use napi_derive::napi;

pub struct HashFile {
  path: String,
}

#[napi]
impl Task for HashFile {
  type Output = Vec<u8>;
  type JsValue = Buffer;

  fn compute(&mut self) -> Result<Self::Output> {
    // Blocking file and CPU work is allowed here. Do not call JavaScript.
    Ok(std::fs::read(&self.path)?)
  }

  fn resolve(&mut self, _env: Env, bytes: Self::Output) -> Result<Self::JsValue> {
    Ok(bytes.into())
  }
}

#[napi]
pub fn hash_file(path: String) -> AsyncTask<HashFile> {
  AsyncTask::new(HashFile { path })
}

AsyncTask::with_signal 接受 AbortSignal,但 Node-API 只能取消尚未开始的工作。compute 开始运行后,调用 AbortController.abort() 不会中断它。如果必须停止正在运行的工作,请将 AbortSignal::on_abort 与自己的协作式标志/channel 结合,并让 compute 定期检查。

libuv pool 与 Node 文件系统、DNS、crypto 和其他原生工作共享。用长时间 CPU 任务塞满它,会延迟无关的应用工作。请在 JavaScript API 层限制并发;如果性能设计需要,则使用专用 Rust pool。

ThreadsafeFunction

Rust 线程需要调度 JavaScript 回调时,请使用 ThreadsafeFunction。producer 发送拥有所有权的 Rust 数据;转换与回调在所属 JavaScript 环境中执行。

请明确选择 queue 行为:

  • NonBlocking 立即返回。使用有界队列时,请把 Status::QueueFull 作为背压处理,不要静默丢弃数据。
  • Blocking 等待队列空间。绝不能从 JavaScript 线程使用;关闭路径中 event loop 可能不再排空,也应避免使用。
  • 队列大小 0 表示无界。它避免 QueueFull,但慢回调可能导致无限内存增长。
  • 强引用 ThreadsafeFunction 会让 event loop 保持活动。如果待处理回调不应阻止进程退出,请用 .weak::<true>() 构建。

丢弃所有 clone 即可释放 ThreadsafeFunction。调用 abort 会立即关闭;之后的调用会报告 Status::Closing

JavaScript 错误与返回值

callee_handled::<true>() 使用 Node 回调约定:Rust 以 Result 调用 ThreadsafeFunction,JavaScript 接收 error-first 回调。设置为 false 时,Rust 调用只接受值,JavaScript 不接收错误参数;调用前请处理可恢复的原生错误。如果 Rust 必须等待回调结果,请使用 call_async;如果 JavaScript 抛出的值属于可恢复故障,请使用能捕获这些值的变体。

绝不要让 JavaScript 异常以未检查 Rust panic 的形式跨越 FFI 回调。请返回或显式处理 napi::Error

AsyncLocalStorage 与请求上下文

ThreadsafeFunction 会注册为独立 Node async resource。不要假设稍后从 Rust 线程调度的回调会继承调用原生 API 时恰好处于活动状态的 AsyncLocalStorage store。如果上下文关系到正确性,请把请求 ID 或上下文对象作为拥有所有权的数据传递,并在 JavaScript 中恢复(例如使用 AsyncResource),不要依赖环境状态。

Promise continuation 与 ThreadsafeFunction 回调保留 JavaScript 异步上下文的方式可能不同。请测试实际发布的 API/运行时组合。

Iterator 与 stream

JavaScript 应一次拉取一个值时使用 iterator;生成下一个值需要异步操作时使用 async iterator。与通过无界 ThreadsafeFunction 队列推送每一项相比,拉取模型通常更容易取消和限流。

使用者需要 Web Streams API 时启用 web_stream feature:

Cargo.toml
toml
[dependencies]
napi = { version = "3", features = ["web_stream"] }

Web Streams 对字节数据的支持最成熟。ReadableStream 中的结构化 Rust 对象仍有未解决的行为报告(napi-rs#2826);将结构化 chunk 作为受支持 API 前,请添加运行时测试。

无论选择哪种抽象,都要定义使用者停止时的行为:

  • 调用 return()cancel()abort() 时取消 producer;
  • 释放 JavaScript 引用和 queue sender;
  • 确保被阻塞的 producer 在关闭时唤醒;
  • 决定交付还是丢弃已缓冲的值。

运行时生命周期

使用 tokio_rt 时,NAPI-RS 会创建一个 Tokio runtime,并在原生模块注册时启动。对于原生 Node 目标,它会在最后一个使用该模块的 Node-API 环境退出后关闭,并且可以为 Electron renderer reload 再次启动。

请为每个环境注册环境特定资源。不要全局缓存一个 Env、JavaScript 引用、类构造函数或 ThreadsafeFunction,再从主线程将其复用于 worker isolate。

自定义 Tokio runtime

请在模块初始化期间、异步导出使用默认 runtime 之前安装自定义 runtime:

src/lib.rs
rust
use napi::create_custom_tokio_runtime;

#[napi_derive::module_init]
fn init() {
  let runtime = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(4)
    .enable_all()
    .build();
  match runtime {
    Ok(runtime) => create_custom_tokio_runtime(runtime),
    Err(err) => eprintln!("failed to create custom Tokio runtime: {err}"),
  }
}

WARNING

自定义 runtime 实例目前只能被消费一次。执行 shutdown_async_runtime() 后再调用 start_async_runtime(),NAPI-RS 会回退到默认 runtime,而不是重建自定义配置。这是未解决的产品限制,不属于受支持的重启契约(napi-rs#3251)。

WASI 的 runtime 拆除限制不同。如果 WASI API 显式启动 runtime 或公开 shutdown 函数,请在实际 WASI 宿主中测试反复启动和关闭。参见 WebAssembly

Worker 关闭协议

不要把突然终止作为原生工作的常规取消机制。稳健的 worker 协议如下:

  1. parent 发送 stop
  2. worker 停止接受原生调用。
  3. 触发 Rust cancellation token。
  4. worker 等待活动 promise,并丢弃 ThreadsafeFunction producer。
  5. worker 回复 stopped 并关闭 message port。
  6. parent 只在超过期限后使用 worker.terminate()

Node worker 生命周期与 Bun worker 生命周期不可互换。异步原生操作期间突然终止仍有一个未解决的 Bun 崩溃报告(napi-rs#2938)。在自己的压力测试证明无误之前,请将该运行时标记为不支持此 API,或强制使用优雅关闭协议。

审查清单

发布异步导出前,请在其文档与测试中回答:

  • 哪个 pool/runtime/线程执行工作?
  • 它能否访问 JavaScript,而且只在正确的环境中访问?
  • 跨越 await 和线程边界的每个值由谁拥有?
  • 队列是否有界,出现背压时会怎样?
  • 调用者如何取消排队中和已运行的工作?
  • 什么让 Node event loop 保持活动?
  • worker 终止、Electron reload 和进程退出时会发生什么?
  • JavaScript 异常与 Rust panic 是否会转换为定义明确的失败?
  • 是否依赖环境异步上下文,还是显式传递上下文?