Rust in Everything
基于 tauri 2.12.5

同步命令里等窗口属性,应用卡死

主线位置:调用主线的「同步命令就地执行」一步,和启动主线最后的事件循环。同步命令占着主线程;窗口 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;在同步命令里只做不等别人的事。

💬 关联讨论

发起讨论
加载中...