同步命令里等窗口属性,应用卡死
主线位置:调用主线的「同步命令就地执行」一步,和启动主线最后的事件循环。同步命令占着主线程;窗口 getter 在非主线程上调用时,要把消息送进事件循环、等主线程处理完再回话。
sequenceDiagram participant M as 主线程 同步命令 participant T as 另一个线程 participant L as 事件循环 M->>T: 等它做完 T->>L: getter 发消息 T->>T: rx.recv 等回话 Note over M,L: 事件循环就是主线程 正在等 T
实验 crate pit2-app:从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,MODE 选场景,debug 构建。卡死的场景靠看门狗结束:main 开头起一个线程,WATCHDOG 秒(这里设 8)后打印 TIMEOUT 并 process::exit(2)。平台 M2 Max、macOS 27、rustc 1.99.0。
现象
一:同步命令里 block_on 一个读窗口尺寸的任务。 想在同步命令里用一段 async 代码,最顺手的写法是 spawn 再 block_on:
#[tauri::command]
fn size_via_task(window: WebviewWindow) -> String {
log("size_via_task (sync): spawn a task that reads inner_size, then block_on it");
let task = tauri::async_runtime::spawn(async move {
log("task: calling window.inner_size()");
let s = window.inner_size();
log("task: inner_size returned");
s.map(|s| format!("{}x{}", s.width, s.height)).unwrap_or_default()
});
let r = tauri::async_runtime::block_on(task).unwrap_or_default();
log("size_via_task: block_on returned");
r
}
MODE=getter_task:
0ms [main] main
76ms [main] probe plugin setup
531ms [main] size_via_task (sync): spawn a task that reads inner_size, then block_on it
531ms [tokio-rt-worker] task: calling window.inner_size()
TIMEOUT
exit=2
inner_size 没有返回,block_on 也没有。应用不再响应任何东西,直到看门狗把进程结束。
二:同步命令等一把锁,拿着锁的 async 命令在读窗口标题。 共享状态 Shared(Mutex<u32>),async 命令拿着锁干活,最后顺手读一下窗口标题;同步命令只是读一下这个数:
#[tauri::command]
async fn hold_and_read(window: WebviewWindow, shared: State<'_, Shared>) -> Result<String, String> {
let mut g = shared.0.lock().unwrap();
log("hold_and_read (async): locked Shared, working 300 ms");
std::thread::sleep(Duration::from_millis(300));
*g += 1;
log("hold_and_read: calling window.title() while holding the lock");
let t = window.title().map_err(|e| e.to_string())?;
log("hold_and_read: title returned, unlocking");
Ok(t)
}
#[tauri::command]
fn touch(shared: State<'_, Shared>) -> u32 {
let busy = shared.0.try_lock().is_err();
log(format!("touch (sync): Shared busy = {busy}, locking"));
let v = *shared.0.lock().unwrap();
log("touch: got the lock");
v
}
页面先发 hold_and_read,不等它,接着发 touch。MODE=getter_lock:
0ms [main] main
77ms [main] probe plugin setup
450ms [tokio-rt-worker] hold_and_read (async): locked Shared, working 300 ms
451ms [main] touch (sync): Shared busy = true, locking
752ms [tokio-rt-worker] hold_and_read: calling window.title() while holding the lock
TIMEOUT
exit=2
touch 在主线程上等锁;300 毫秒后 async 命令去读标题,等主线程;两边都不会再前进。单看哪一个命令都没有问题,合在一起才卡死,而且只在两个调用时间上重叠时发生,开发时很可能一次也碰不到。
tauri-runtime-wry 与事件循环里还有第三种写法:同步命令 join 一个调 title() 的线程,结果相同。
出处
窗口和 webview 的 getter(window_getter!、webview_getter!)都建在 getter! 这个宏:
macro_rules! getter {
($self: ident, $rx: expr, $message: expr) => {{
$self.context.send_user_message($message)?;
$rx
.recv()
.map_err(|_| $crate::Error::FailedToReceiveMessage)
}};
}
send_user_message 在主线程上就地处理消息,所以在主线程上调 getter 不会等;在别的线程上,它把消息交给 tao 的 EventLoopProxy,然后在 std::sync::mpsc 的 recv() 上一直阻塞,没有超时。消息要等事件循环下一次处理用户事件时才执行,而事件循环就是主线程。
主线程在什么时候「在等别人」?同步命令里任何阻塞等待都算:block_on、JoinHandle::join、Mutex::lock、mpsc::Receiver::recv、Condvar::wait。只要被等的那一方(直接或间接)调了 getter,就是一个环。上面两个例子里:
| 主线程(同步命令)在等 | 另一方在等 | |
|---|---|---|
| 一 | block_on(task):tokio 任务结束 | 任务里 inner_size():主线程处理消息 |
| 二 | Mutex::lock:async 命令放锁 | async 命令里 title():主线程处理消息 |
会等主线程的不只是 getter。开了 tracing feature 时 eval 也改成等主线程执行完再返回(见 tauri-runtime-wry 与事件循环);AppHandle::run_on_main_thread 本身不等,但如果你在闭包外面用 channel 等它的结果,效果一样。
Windows 上还有一个相关的限制,只读了源码:WebviewWindowBuilder::new 等几处的文档写着「On Windows, this function deadlocks when used in a synchronous command and event handlers, see [the Webview2 issue].」,建议在 async 命令或单独的线程里建窗口。macOS 上在同步命令里建窗口不会卡(tauri-runtime-wry 与事件循环 实测过)。
修法
让「等」发生在主线程以外,环就断了。两个例子各改一处:
#[tauri::command]
async fn size_via_task_async(window: WebviewWindow) -> Result<String, String> {
log("size_via_task_async: spawn a task that reads inner_size, then .await it");
let task = tauri::async_runtime::spawn(async move {
log("task: calling window.inner_size()");
window.inner_size().map(|s| format!("{}x{}", s.width, s.height))
});
let r = task.await.map_err(|e| e.to_string())?.map_err(|e| e.to_string());
log("size_via_task_async: done");
r
}
#[tauri::command]
async fn touch_async(shared: State<'_, Shared>) -> Result<u32, String> {
let busy = shared.0.try_lock().is_err();
log(format!("touch_async: Shared busy = {busy}, locking"));
let v = *shared.0.lock().unwrap();
log("touch_async: got the lock");
Ok(v)
}
MODE=getter_task_fix 和 MODE=getter_lock_fix:
536ms [tokio-rt-worker] size_via_task_async: spawn a task that reads inner_size, then .await it
536ms [tokio-rt-worker] task: calling window.inner_size()
536ms [tokio-rt-worker] size_via_task_async: done
REPORT {
"mode": "getter_task_fix",
"size": {
"ok": "800x600"
}
}
548ms [tokio-rt-worker] hold_and_read (async): locked Shared, working 300 ms
548ms [tokio-rt-worker] touch_async: Shared busy = true, locking
853ms [tokio-rt-worker] hold_and_read: calling window.title() while holding the lock
853ms [tokio-rt-worker] hold_and_read: title returned, unlocking
853ms [tokio-rt-worker] touch_async: got the lock
REPORT {
"mode": "getter_lock_fix",
"r": {
"ok": {
"hold": "pit2",
"touch": 1
}
}
}
- 第一个:命令本身在 tokio 线程上,
.await让出线程,主线程空着,getter 立即得到回答。其实这里不需要spawn,直接在 async 命令里调window.inner_size()就行;拆成spawn只是为了和卡死的版本一一对应。 - 第二个:
touch_async照样等了 300 毫秒的锁(Shared busy = true),但等的是一个 tokio 工作线程,主线程能处理title()的消息,hold_and_read放锁,touch_async接着拿到。 - 更稳妥的是不要拿着锁调 getter:先把要的窗口属性读出来,再上锁改状态。用
std::sync::Mutex还是tokio::sync::Mutex,见 tokio 卷 tokio::sync::Mutex 还是 std::sync::Mutex;换成 tokio 的锁并不能修这个环,只要主线程上有同步代码在等它。
怎么确认没中招
- 卡死时应用完全不响应,没有任何输出,CPU 占用是 0(第一个例子卡住 3 秒时
ps -o %cpu,state给出0.0 SN)。用 macOS 自带的sample <pid> 1抓一次所有线程的栈:第一个例子里主线程(com.apple.main-thread)停在size_via_task→tauri::async_runtime::block_on→ tokio 的CachedParkThread::block_on,一个tokio-rt-worker停在WebviewWindow::inner_size→WryWindowDispatcher的inner_size→std::sync::mpsc::Receiver::recv。主线程在等、另一个线程在 getter 的recv里等,就是这个坑。 - 搜同步命令和事件回调里的
block_on、.join()、.lock()、recv();对每一处问一句:被等的那一方会不会碰窗口或 webview 的 getter。 - 设一个看门狗线程(像本卷的实验那样),在开发构建里把卡死变成一个能看到的退出。
小结
- 非主线程上调窗口 getter,要经事件循环等主线程回话,
recv()没有超时;主线程上调则就地完成。 - 同步命令在主线程上。它等任何一个「会去调 getter 的东西」,就是死锁:
block_on一个任务、join一个线程、等一把被这类代码拿着的锁。 - 修法:等待的一方改成 async 命令;不要拿着锁调 getter;在同步命令里只做不等别人的事。