异步运行时
主线位置:调用主线上「同步命令就地
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.rs | asset:// 请求同样 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。
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会一直挂着。应用本身照常运行。
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());
}
动脑筋答案
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)。