Rust in Everything
基于 tauri 2.12.5

异步运行时

主线位置:调用主线上「同步命令就地 respond,async 命令 respond_async_serialized 丢进 async_runtime」的后半句;启动主线上 tauri:// 协议每个请求的处理也在这里跑。命令包装怎么在两种 respond 之间选在 #[command] 与 generate_handler!,InvokeResolver 怎么把结果编码回去在 参数、返回值与 InvokeResolver。

flowchart TB
  first["第一次 spawn / block_on / handle"] --> init{"RUNTIME 已设置"}
  init -->|否| deflt["default_runtime: TokioRuntime::new"]
  init -->|是| use["用已有的"]
  setcall["async_runtime::set handle"] --> setok{"RUNTIME 已设置"}
  setok -->|否| store["存 handle, runtime 为 None"]
  setok -->|是| boom["panic: runtime already initialized"]
  deflt --> gr["GlobalRuntime"]
  store --> gr
  use --> gr
  gr --> users["async 命令, tauri 协议, asset 协议"]

源码只有一个文件 async_runtime.rs,363 行,其中一半是对 tokio 类型的薄包装。tokio 运行时本身怎么建、多线程调度器怎么分活,见 tokio 卷的 Builder、Runtime 与 Handle 和 多线程调度器。实验程序和平台同 Builder、App 与 AppHandle:M2 Max、macOS 27、rustc 1.99.0,tauri 2.12.5(依赖解析到 tokio 1.54.1),真实 Wry 运行时,隐藏窗口,debug 构建。

一个 OnceLock 里的全局运行时

static RUNTIME: OnceLock<GlobalRuntime> = OnceLock::new();

struct GlobalRuntime {
  runtime: Option<Runtime>,
  handle: RuntimeHandle,
}

GlobalRuntime 有两种状态:

  • runtime: Some(..):tauri 自己建的运行时,归 tauri 所有,活到进程结束。
  • runtime: None:用户用 set 交来一个 tokio::runtime::Handle,运行时归用户所有。

它的每个方法都是「有 runtime 就用 runtime,否则用 handle」。公开的自由函数 spawn、spawn_blocking、block_on、handle 都先 RUNTIME.get_or_init(default_runtime):

fn default_runtime() -> GlobalRuntime {
  let runtime = Runtime::Tokio(TokioRuntime::new().unwrap());
  let handle = runtime.handle();
  GlobalRuntime {
    runtime: Some(runtime),
    handle,
  }
}

default_runtime 用的是 tokio::runtime::Runtime::new(),也就是 Builder::new_multi_thread().enable_all():工作线程数等于 CPU 核数,线程名用 tokio 的默认值,实验输出里是 tokio-rt-worker。

外面那层 Runtime、RuntimeHandle、JoinHandle 都是只有一个 Tokio(..) 变体的枚举。JoinHandle<T> 实现 Future<Output = crate::Result<T>>,把 tokio 的 JoinError 转成 tauri::Error::JoinError。spawn 在 tokio::spawn 之前先 enter(),所以从任何线程(包括没有 tokio 上下文的主线程)调都可以:

  pub fn spawn<F>(&self, task: F) -> JoinHandle<F::Output>
  where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
  {
    match self {
      Self::Tokio(r) => {
        let _guard = r.enter();
        JoinHandle::Tokio(tokio::spawn(task))
      }
    }
  }

tauri 自己在哪里用它

在 crates/tauri/src 里搜 async_runtime::,除了测试只有这几处:

位置做什么
ipc/mod.rs 的 respond_async 和 respond_async_serialized_inner把 async 命令的 future spawn 出去,完成后回应 JS
protocol/tauri.rs每个 tauri:// 请求都 spawn 一个任务去取资源、拼响应
protocol/asset.rsasset:// 请求同样 spawn(文件里另外两处 block_on 在测试里)

respond_async_serialized_inner:

  fn respond_async_serialized_inner<F>(self, task: F)
  where
    F: Future<Output = Result<InvokeResponseBody, InvokeError>> + Send + 'static,
  {
    crate::async_runtime::spawn(async move {
      let response = match task.await {
        Ok(ok) => InvokeResponse::Ok(ok),
        Err(err) => InvokeResponse::Err(err),
      };
      Self::return_result(
        self.webview,
        self.responder,
        response,
        self.cmd,
        self.callback,
        self.error,
      )
    });
  }

第二张表格那一行有一个后果:页面加载 index.html 时,全局运行时就已经被建出来了,哪怕应用里一个 async 命令都没有。这直接决定了 set 能在什么时候调用(下文)。

谁在哪个线程上跑

实验程序在每个回调里打印线程名,下表的每一行都来自同一次运行的输出(probe 是程序里的插件,见插件):

  113ms [main] plugin setup
  378ms [main] plugin on_window_ready main
  378ms [main] plugin on_webview_ready main
  379ms [main] app setup
  379ms [main] rust listener ping-evt "from setup"
  379ms [main] plugin on_event Ready
  439ms [main] plugin on_navigation tauri://localhost -> true
  448ms [main] app on_page_load Started main
  448ms [main] plugin on_page_load Started tauri://localhost
  505ms [main] app command sync_cmd
  506ms [tokio-rt-worker] app command async_cmd, emitting ping-evt
  506ms [tokio-rt-worker] rust listener ping-evt "from async_cmd"
  507ms [main] plugin command ping
  507ms [tokio-rt-worker] plugin command ping_async
  508ms [tokio-rt-worker] rust listener ping-evt "from js"
...
 1025ms [main] Resource::close db-conn (strong_count=1)
代码线程原因
Builder::setup、插件 setup主线程插件 setup 在 build 里同步调用;Builder::setup 在事件循环的 Ready 回调里
插件 on_window_ready、on_webview_ready主线程经 run_on_main_thread 派发
插件 on_navigation、on_page_load、on_event,Builder::on_page_load主线程wry / tao 的回调
同步命令(应用的、插件的、内置插件的 resources|close)主线程在 ipc:// 协议的处理函数里就地执行
async 命令(应用的、插件的、内置插件的 event|emit)tokio 工作线程respond_async_serialized 把 future 交给 async_runtime::spawn
Rust 事件监听器调用 emit 的那个线程Listeners::emit_filter 同步调用,见 AppManager 与 Manager trait

同步命令在主线程上,是因为 macOS 上 WKWebView 在主线程回调 WKURLSchemeHandler,tauri 的 ipc:// 处理函数在这个回调里直接走到命令,本页动脑筋答案里的回溯就是这条调用链(见 wry 在 macOS 上 和 参数、返回值与 InvokeResolver)。所以耗时的同步命令会卡住事件循环;要在后台跑,写成 async fn,或者在同步命令里 async_runtime::spawn_blocking。Windows、Linux 上的协议回调线程本卷没有实测。

async_runtime 的模块文档开头写着:「Tauri uses [tokio] Runtime to initialize code, such as [Plugin::initialize] and [crate::Builder::setup] hooks.」上面的输出表明 2.12.5 里这两个钩子都在主线程上同步执行,没有经过 tokio。

set:换成自己的运行时

pub fn set(handle: TokioHandle) {
  RUNTIME
    .set(GlobalRuntime {
      runtime: None,
      handle: RuntimeHandle::Tokio(handle),
    })
    .unwrap_or_else(|_| panic!("runtime already initialized"))
}

set 只收一个 Handle,runtime 字段留空。所以运行时归调用方所有,文档要求「Note that you cannot drop the underlying [TokioRuntime]」:运行时一旦被 drop,Handle 还在,但上面已经没有工作线程,之后 spawn 的任务不会再被执行。

实验在 main 一开头建一个两线程、线程名 rie-rt 的运行时交给 tauri,并让它活到 main 结束:

  let rt_mode = std::env::var("RT").unwrap_or_default();
  // keep the custom runtime alive for the whole process
  let _custom_rt = if rt_mode == "custom" {
    let rt = tokio::runtime::Builder::new_multi_thread()
      .worker_threads(2)
      .thread_name("rie-rt")
      .enable_all()
      .build()
      .unwrap();
    tauri::async_runtime::set(rt.handle().clone());
    log("async_runtime::set(rie-rt)");
    Some(rt)
  } else {
    None
  };
    0ms [main] main
    0ms [main] async_runtime::set(rie-rt)
...
  398ms [main] app command sync_cmd
...
  461ms [rie-rt] app command async_cmd, emitting ping-evt
  461ms [rie-rt] rust listener ping-evt "from async_cmd"
  462ms [main] plugin command ping
  462ms [rie-rt] plugin command ping_async
  463ms [rie-rt] rust listener ping-evt "from js"

所有原来在 tokio-rt-worker 上的东西都换到了 rie-rt 上,主线程上的不变。tauri 的 async 命令和应用自己的任务共用一个运行时,不再有第二组工作线程。

文档里的例子是另一种写法:#[tokio::main] async fn main 里 set(tokio::runtime::Handle::current()),然后在 main 里接着 run。它和上面的区别在于主线程:#[tokio::main] 展开后,main 的函数体跑在 Runtime::block_on 里,tauri 的事件循环就嵌在这个 block_on 之内。实验用 RT=tokiomain 模拟这种结构(rt.block_on(async { start() }),start 里是整个 Builder...run),async 命令照样在 rie-rt 上,但同步命令 block_sync(见下一节)里的 async_runtime::block_on 变成了:

  564ms [main] block_sync: async_runtime::block_on on this thread

thread 'main' (2470140) panicked at src/main.rs:87:3:
Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks. If you are in an async function, use `.await` on the future instead of blocking on it.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fatal runtime error: failed to initiate panic, error 5, aborting
exit=134

主线程已经在一个 block_on 里了,tokio 不允许再嵌套一个。同步命令、插件钩子都在主线程上,所以用 #[tokio::main] 时它们都不能 block_on(最后那行 abort 的原因见本页的动脑筋答案)。

set 用 OnceLock::set,所以只能在任何 spawn、block_on、handle 之前成功一次。「之前」比看上去早:上一节说过,tauri:// 协议处理第一个请求时就会 spawn。

📝NOTE
冷知识:set 是为了和另一个 tokio 运行时共存才加的

最早的 async_runtime 只有一个 static RUNTIME: OnceCell<Runtime>,spawn、block_on、handle 都是 RUNTIME.get_or_init(|| Runtime::new().unwrap()):tauri 总是自己建一个运行时。应用自己也用 #[tokio::main] 时,进程里就有两个 tokio 运行时、两组工作线程。PR #2973(「feat(core): allow running along another tokio runtime, closes #2838」)把它改成本页的 GlobalRuntime:runtime 变成 Option,加了 set 和 spawn_blocking。同一个 PR 的 CHANGELOG 有四条,其中两条是「Added tauri::async_runtime::set method, allowing to share your tokio runtime with Tauri.」和「Avoid async_runtime::block_on panics when used along another tokio runtime.」第一个包含它的 tag 是预发布版 tauri-v1.0.0-rc.0,正式版 1.0.0。

block_on:主线程上能用,async 命令里不能用

block_on 就是 tokio 的 Runtime::block_on / Handle::block_on,规则也一样:当前线程不能正在驱动 tokio 任务。实验里两个命令各调一次:

#[tauri::command]
fn block_sync() -> &'static str {
  log("block_sync: async_runtime::block_on on this thread");
  tauri::async_runtime::block_on(async {
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    log("block_sync: inside block_on");
  });
  "ok"
}

#[tauri::command]
async fn block_async() -> &'static str {
  log("block_async: async_runtime::block_on inside an async command");
  tauri::async_runtime::block_on(async {});
  "ok"
}

JS 给每次调用套一个 500 ms 的超时:

  const timeout = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej('no answer after 500 ms'), 500))]);
  await t('block_sync', () => timeout(I.invoke('block_sync')));
  await t('block_async', () => timeout(I.invoke('block_async')));
  508ms [main] block_sync: async_runtime::block_on on this thread
  521ms [main] block_sync: inside block_on
  530ms [tokio-rt-worker] block_async: async_runtime::block_on inside an async command

thread 'tokio-rt-worker' (2439474) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.54.1/src/runtime/scheduler/multi_thread/mod.rs:89:9:
Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks. If you are in an async function, use `.await` on the future instead of blocking on it.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
...
block_sync ok: "ok"
block_async err: no answer after 500 ms
  • 同步命令在主线程上,主线程不是 tokio 的工作线程,block_on 正常返回;代价是这 13 ms 里事件循环停着。
  • async 命令在工作线程上,block_on 触发 tokio 的检查,panic(这句 panic 的来历见 tokio 卷的 Cannot start a runtime from within a runtime)。panic 发生在 respond_async_serialized_inner 那个 spawn 出来的任务里,tokio 接住 panic、结束这个任务,return_result 永远不会被调用:JS 的 Promise 既不兑现也不拒绝,要不是自己加了超时,await 会一直挂着。应用本身照常运行。
❗IMPORTANT
动脑筋:页面加载完后再调 set,会怎样?

实验程序设 RT=late 时,report 命令(同步命令,最后一个被调用)在 app.exit(0) 之前执行:

  if std::env::var("RT").as_deref() == Ok("late") {
    log("calling async_runtime::set after the page loaded");
    let rt = tokio::runtime::Runtime::new().unwrap();
    tauri::async_runtime::set(rt.handle().clone());
  }
假设应用里一个 async 命令也没有,结果会不同吗?进程的退出码是多少?

动脑筋答案

  853ms [main] calling async_runtime::set after the page loaded

thread 'main' (2368571) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/async_runtime.rs:282:25:
runtime already initialized
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fatal runtime error: failed to initiate panic, error 5, aborting
exit=134

set panic:runtime already initialized。即使没有任何 async 命令,tauri:// 协议在加载 index.html 时已经 spawn 过,RUNTIME 早已初始化。

退出码是 134(SIGABRT),不是普通 panic 的 101,exit(0) 也没执行到。加 RUST_BACKTRACE=1 再跑一次,回溯是这样的(长类型名截断):

   4: tauri::async_runtime::set
   5: app_life::report
   6: app_life::start::{closure#3}::{closure#0}
   7: app_life::start::{closure#3}
   8: <alloc::boxed::Box<dyn core::ops::function::Fn<(tauri::ipc::Invoke,), Output = bool> + core::marker::Send + core::marker::Sync> as core::ops::function::Fn
   9: <tauri::manager::AppManager>::run_invoke_handler
  10: <tauri::webview::Webview>::on_message
  11: tauri::ipc::protocol::get::<tauri_runtime_wry::Wry<tauri::EventLoopMessage>>::{closure#0}
...
  15: tauri_runtime_wry::create_webview::<tauri::EventLoopMessage>::{closure#8}
  16: wry::wkwebview::class::url_scheme_handler::start_task
  17: <unknown>
...
fatal runtime error: failed to initiate panic, error 5, aborting

同步命令 report 是从 wry 的 url_scheme_handler::start_task(WebKit 回调 WKURLSchemeHandler 的入口)一路同步调下来的,再往下是 WebKit 的 Objective-C 栈帧(<unknown>)。panic 没能展开过这些栈帧,Rust 运行时打印「failed to initiate panic, error 5, aborting」后 abort。对照上一节:同样是 panic,在 tokio 工作线程上的 async 命令里只结束一个任务,在主线程的同步命令里会带走整个进程。

小结

  • async_runtime 是 OnceLock<GlobalRuntime>:默认在第一次使用时用 TokioRuntime::new() 建一个多线程运行时并持有它;set(handle) 只存 handle,运行时由调用方持有,不能 drop。
  • tauri 自己在 async 命令的回应、tauri:// 和 asset:// 协议处理里用它;页面第一次加载时运行时就已经建好,所以 set 至少要赶在第一个页面请求之前,最稳妥是放在 main 开头;晚了会 panic「runtime already initialized」。用 #[tokio::main] 时主线程在 block_on 里,同步命令和插件钩子都不能再 block_on。
  • macOS 上实测:插件 setup、Builder::setup、所有插件钩子、所有同步命令都在主线程;async 命令在 tokio 工作线程(默认线程名 tokio-rt-worker);Rust 监听器在调用 emit 的线程上。模块文档说 setup 和 Plugin::initialize 在 tokio 上跑,与 2.12.5 的行为不符。
  • 主线程上可以 block_on(但会卡住事件循环);async 命令里 block_on 会 panic,任务被结束,JS 的 Promise 永远不落定。主线程同步命令里的 panic 让进程 abort(134)。

💬 关联讨论

发起讨论
加载中...