一个同步命令,别的请求全在等
主线位置:调用主线的「
#[command]包装 → 同步命令就地respond」一步。同步命令没有离开ipc://协议处理函数,而 WebKit 在主线程上调这个处理函数,所以命令体就在主线程上跑。
flowchart TB wk["WebKit 主线程 start_task"] --> onmsg["on_message"] onmsg --> sync["同步命令 sleep 1 秒"] greet["另一个 invoke"] -.->|等主线程| wk asset["tauri:// 资源"] -.->|等主线程| wk getter["别的线程 window.title"] -.->|等主线程| loop["事件循环"] sync --> back["返回后主线程才空出来"]
实验 crate pit2-app:从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,用环境变量 MODE 选场景,页面用 report 命令把结果送回来。平台 M2 Max、macOS 27、rustc 1.99.0。本页是 MODE=block,debug 构建。
现象
一个睡 1 秒的同步命令。为了看「窗口操作」会不会受影响,命令开头再起一个线程,100 毫秒后从那个线程读一次窗口标题,记下耗时:
// Another Rust thread asks the window for its title while the command sleeps.
fn probe_getter(window: WebviewWindow) {
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
let t0 = Instant::now();
let _ = window.title();
log(format!("other thread: window.title() took {} ms", t0.elapsed().as_millis()));
});
}
#[tauri::command]
fn sleep_sync(window: WebviewWindow, ms: u64) -> String {
probe_getter(window);
std::thread::sleep(Duration::from_millis(ms));
format!("slept {ms} ms on {}", thread_name())
}
页面发出这个命令后不等它,紧接着再调一次只拼字符串的 greet,再用 fetch 取一次内嵌的 index.html,分别记下从发出到完成的毫秒数:
async function blockOne(cmd) {
const t0 = performance.now();
const p = inv(cmd, { ms: 1000 });
await sleep(0);
const g0 = performance.now();
const gp = inv('greet', { name: 'x' }).then(() => Math.round(performance.now() - g0));
const ap = fetch('tauri://localhost/index.html').then(r => r.text()).then(() => Math.round(performance.now() - g0));
const result = await p;
return { cmd, result, totalMs: Math.round(performance.now() - t0), greetMs: await gp, assetMs: await ap };
}
1467ms [<unnamed>] other thread: window.title() took 902 ms
...
{
"cmd": "sleep_sync",
"result": "slept 1000 ms on main",
"totalMs": 1002,
"greetMs": 1002,
"assetMs": 1007
},
- 命令跑在
main线程上。 greet本身几乎不花时间,却等了 1002 毫秒;取一个内嵌资源等了 1007 毫秒。- 别的线程在命令开始 100 毫秒后读窗口标题,等了 902 毫秒,正好是命令剩下的时间。
用户看到的是:按钮点了没反应(invoke 不回)、图片和脚本加载停住、拖动和缩放窗口卡住。页面里的 JS 本身并没有停,动画照常跑,参数、返回值与 InvokeResolver 用 MessageChannel 数过事件循环的圈数;停住的是应用进程的主线程。
出处
同步命令的包装代码由 body_blocking 生成,取完参数就地调用函数、当场应答:
Ok(quote! {
#maybe_span
let result = $path(#(match #args #match_body),*);
let kind = (&result).blocking_kind();
kind.block(result, #resolver);
return true;
})
这段代码在 generate_handler! 的闭包里,闭包由 Webview::on_message 调用,on_message 又是 ipc:// 协议处理函数 ipc::protocol::get 同步调下来的。最上面是 wry 的 start_task(wry 0.57.1),WebKit 在主线程上回调 WKURLSchemeHandler 时进入它(见 wry 在 macOS 上)。整条链上没有一次 spawn。
三个被拖住的东西,原因各不相同:
| 被拖住的 | 为什么要等主线程 |
|---|---|
另一个 invoke | 它的 ipc:// 请求也要进 start_task,主线程不空,WebKit 交不过来 |
tauri:// 资源 | 资源本来在 async_runtime 上读,但请求同样要先进 start_task |
| 别的线程读窗口属性 | getter 经 EventLoopProxy 发消息给事件循环,在 rx.recv() 上等主线程回话(getter!,见 tauri-runtime-wry 与事件循环) |
主线程同时也是 tao 的事件循环(tao:窗口与主线程),eval、emit 到页面、窗口事件都排在它后面。Windows(WebView2)和 Linux(WebKitGTK)上协议回调在哪个线程,本卷没有实测。
修法
让命令离开主线程。三个版本放在一起比,sleep_sync 就是上面那个:
#[tauri::command(async)]
fn sleep_attr(window: WebviewWindow, ms: u64) -> String {
probe_getter(window);
std::thread::sleep(Duration::from_millis(ms));
format!("slept {ms} ms on {}", thread_name())
}
#[tauri::command]
async fn sleep_offload(window: WebviewWindow, ms: u64) -> Result<String, String> {
probe_getter(window);
tokio::task::spawn_blocking(move || {
std::thread::sleep(Duration::from_millis(ms));
format!("slept {ms} ms on {}", thread_name())
})
.await
.map_err(|e| e.to_string())
}
1467ms [<unnamed>] other thread: window.title() took 902 ms
1573ms [<unnamed>] other thread: window.title() took 0 ms
2578ms [<unnamed>] other thread: window.title() took 0 ms
REPORT {
"mode": "block",
"rows": [
{
"cmd": "sleep_sync",
"result": "slept 1000 ms on main",
"totalMs": 1002,
"greetMs": 1002,
"assetMs": 1007
},
{
"cmd": "sleep_attr",
"result": "slept 1000 ms on tokio-rt-worker",
"totalMs": 1006,
"greetMs": 0,
"assetMs": 0
},
{
"cmd": "sleep_offload",
"result": "slept 1000 ms on tokio-rt-worker",
"totalMs": 1052,
"greetMs": 1,
"assetMs": 1
}
]
}
后两种 greet、资源、getter 都不再等。
#[tauri::command(async)] 标在普通函数上,宏生成的是 async 版本的包装(body_async),把「调用函数」这件事放进交给 respond_async_serialized 的 future 里,于是函数在 tokio 的工作线程上执行。宏给这种组合起的名字是 sync_threadpool,开着 tracing 时会出现在 ipc::request::handler span 的 kind 字段里:
let kind = match attrs.execution_context {
ExecutionContext::Async if function.sig.asyncness.is_none() => "sync_threadpool",
ExecutionContext::Async => "async",
ExecutionContext::Blocking => "sync",
};
两种修法的区别:
#[tauri::command(async)]:主线程空出来了,但这一秒占住的是async_runtime的一个工作线程。短的阻塞(几毫秒的文件读写)这样就够;工作线程数有限,长的阻塞多了,async 命令和tauri://资源也要排队等 worker,道理和 tokio 卷在 async 里阻塞:一个任务卡住整个 worker一样。async fn里用tokio::task::spawn_blocking:阻塞挪进 tokio 的 blocking 线程池(阻塞线程池),工作线程在.await处让出。线程名同样是tokio-rt-worker:tauri 默认的运行时用 tokio 的默认线程名,blocking 池的线程也用这个名字。
改成 async 之后,参数不能再借用(&str、State<'_, T>)除非返回 Result,规则见 #[command] 与 generate_handler!。上面的 sleep_offload 返回 Result 就是这个原因之一(另一个是 JoinError 要有地方去)。
怎么确认没中招
- 列出所有没有
async也没有#[tauri::command(async)]的命令,逐个看函数体:文件和网络 IO、std::thread::sleep、等锁、block_on、算很久的循环,都不该出现在这里。 - 在可疑命令里打印
std::thread::current().name(),是main就在主线程上。 - 开
tauri的tracingfeature 时,ipc::request::handlerspan 的kind是sync的就是在主线程上执行的命令。 - 用上面的办法测:一个慢命令执行期间,另一个只返回常量的命令要等多久。顺序调用测不出来,必须并发。
小结
- 同步命令在
ipc://协议处理函数里当场执行,macOS 上那是主线程。 - 它阻塞多久,别的 invoke、
tauri://资源、其他线程的窗口 getter、事件循环就停多久;WebContent 进程里的 JS 不受影响。 - 修法:
async fn,或在同步函数上写#[tauri::command(async)];阻塞很久的部分再用spawn_blocking挪出 tokio 的工作线程。