Rust in Everything
基于 tauri 2.12.5

tauri-runtime-wry 与事件循环

主线位置:启动主线上「R::new(Wry 建 tao 的 EventLoop)」「tauri-runtime-wry 用 wry 的 WebViewBuilder 建 WKWebView」「App::run 进入 tao 的事件循环,RunEvent::Ready」三格都在这一页。调用主线上,async 命令在 tokio 线程里调 window.title()、webview.eval(..),事件主线上 emit 给每个 webview 的那段 eval,也都经过这里的 send_user_message。

sequenceDiagram
  participant W as tokio 线程
  participant D as 派发器
  participant P as EventLoopProxy
  participant M as 主线程事件循环
  W->>D: window.title()
  D->>D: 不是 main_thread_id
  D->>P: send_event Message::Window
  D->>D: rx.recv() 阻塞
  P->>M: 唤醒 run loop
  M->>M: handle_user_message
  M-->>D: tx.send(标题)
  D-->>W: Ok(标题)

tauri-runtime-wry 2.12.3 的代码几乎都在 src/lib.rs 一个文件里(5416 行),依赖 wry = "0.57.0"(os-webview、linux-body feature)和 tao = "0.37.0",新项目解析到 wry 0.57.1、tao 0.37.2。上一页的五个 trait 在这里各有一个实现:Wry、WryHandle、WryWindowDispatcher、WryWebviewDispatcher、EventProxy。

实验平台:M2 Max、macOS 27、rustc 1.99.0,debug 构建,tauri =2.12.5 带 --features tauri/custom-protocol,窗口 visible: false。

Wry::init:记下主线程

Runtime::new 用 EventLoopBuilder::<Message<T>>::with_user_event() 建 tao 的事件循环,再进 Wry::init:

  fn init(event_loop: EventLoop<Message<T>>) -> Result<Self> {
    let main_thread_id = current_thread().id();
    let web_context = WebContextStore::default();

    #[allow(clippy::arc_with_non_send_sync)]
    let windows = Arc::new(WindowsStore(RefCell::new(BTreeMap::default())));
    let window_id_map = WindowIdStore::default();

    let context = Context {
      window_id_map,
      main_thread_id,
      proxy: event_loop.create_proxy(),
      main_thread: DispatcherMainThreadContext {
        window_target: event_loop.deref().clone(),
        web_context,
        windows,
        #[cfg(feature = "tracing")]
        active_tracing_spans: Default::default(),
      },
      plugins: Default::default(),
      next_window_id: Default::default(),
      next_webview_id: Default::default(),
      next_window_event_id: Default::default(),
      next_webview_event_id: Default::default(),
      webview_runtime_installed: wry::webview_version().is_ok(),
    };

    Ok(Self {
      context,
      event_loop,
    })
  }

tao 事件循环的 user event 类型不是应用的 T,而是 Message<T>:tauri 自己的「请主线程做某事」和应用的 user event 共用这一条通道。Context 被 WryHandle 和每个派发器各克隆一份,里面分两类东西:

  • 哪个线程都能用的:main_thread_id、tao 的 EventLoopProxy<Message<T>>、几个 AtomicU32 计数器(窗口 id、webview id、事件监听器 id 都在调用线程上当场分配,不用等主线程)。
  • 只该在主线程上碰的:DispatcherMainThreadContext 里的 EventLoopWindowTarget、WebContextStore 和 WindowsStore。后者是 RefCell<BTreeMap<WindowId, WindowWrapper>>,真正的 tao 窗口(Arc<Window>)和 wry webview(Rc<WebView>)都存在这里。

RefCell 和 Rc 都不是 Sync/Send,Context 能跨线程靠的是 unsafe impl<T: UserEvent> Send for Context<T> {} 和同样的 Sync。WryHandle 上的注释写明了这份 unsafe 的前提:「this is safe since the Context usage is guarded by Context::send_user_message」。

send_user_message:两条路

每个派发器方法最后都落到 Context::send_user_message:

  fn send_user_message(&self, message: Message<T>) -> Result<()> {
    if current_thread().id() == self.main_thread_id {
      handle_user_message(
        &self.main_thread.window_target,
        message,
        UserMessageContext {
          window_id_map: &self.window_id_map,
          windows: &self.main_thread.windows,
        },
      );
      Ok(())
    } else {
      self
        .proxy
        .send_event(message)
        .map_err(|_| Error::FailedToSendMessage)
    }
  }

当前线程就是 init 时记下的那个,就地调 handle_user_message;否则把消息交给 tao 的 proxy,等事件循环下一次醒来再处理。消息是 Message<T>:

pub enum Message<T: 'static> {
  Task(Box<dyn FnOnce() + Send>),
  #[cfg(target_os = "macos")]
  SetActivationPolicy(ActivationPolicy),
  #[cfg(target_os = "macos")]
  SetDockVisibility(bool),
  RequestExit(i32),
  Application(ApplicationMessage),
  Window(WindowId, WindowMessage),
  Webview(WindowId, WebviewId, WebviewMessage),
  EventLoopWindowTarget(EventLoopWindowTargetMessage),
  CreateWebview(WindowId, CreateWebviewClosure, Sender<Result<()>>),
  CreateWindow(WindowId, CreateWindowClosure<T>, Sender<Result<()>>),
  CreateRawWindow(
    WindowId,
    Box<dyn FnOnce() -> (String, TaoWindowBuilder) + Send>,
    Sender<Result<Weak<Window>>>,
  ),
  UserEvent(T),
}

WindowMessage 有近八十个成员,每个窗口 API 一个;WebviewMessage 对 webview 也一样。run_on_main_thread(f) 就是 Message::Task(Box::new(f))。

setter 发完就返回,set_title 是 send_user_message(Message::Window(id, WindowMessage::SetTitle(title.into())))。getter 要等结果,用的是三个宏,getter! 是核心:

macro_rules! getter {
  ($self: ident, $rx: expr, $message: expr) => {{
    $self.context.send_user_message($message)?;
    $rx
      .recv()
      .map_err(|_| $crate::Error::FailedToReceiveMessage)
  }};
}

macro_rules! window_getter {
  ($self: ident, $message: expr) => {{
    let (tx, rx) = channel();
    getter!($self, rx, Message::Window($self.window_id, $message(tx)))
  }};
}

fn title(&self) 就是 window_getter!(self, WindowMessage::Title):建一个 std::sync::mpsc channel,把 tx 装进消息,然后在 rx.recv() 上阻塞。主线程处理到这条消息时执行 WindowMessage::Title(tx) => tx.send(window.title()).unwrap()。在主线程上调 getter,send_user_message 已经同步执行完、tx 里已经有值,recv 立刻返回;在别的线程上调,调用线程一直阻塞到主线程处理完。

实验:三个地方调同一组 API

实验在三处调用同一个 order_probe(上一页那个:先记 before,run_on_main_thread 里记 inside,返回后记 after):setup 钩子、一个同步命令、一个 async 命令。同步命令里还用 AppHandle 新建了一个隐藏窗口,并读一次标题;async 命令在调用之后、过一会儿各打印一次顺序,再读标题、发一段 eval:

#[tauri::command]
async fn probe_async(window: WebviewWindow) -> String {
  println!("[probe_async] command on {}", th());
  let log = order_probe(&window, "probe_async");
  println!("[probe_async] order right after call={:?}", log.lock().unwrap());
  println!("[probe_async] title before set={:?}", window.title().unwrap());
  window.set_title("set from tokio").unwrap();
  let t0 = Instant::now();
  let title = window.title().unwrap();
  println!("[probe_async] title={title:?} getter took {:?}", t0.elapsed());
  println!("[probe_async] order later={:?}", log.lock().unwrap());
  window
    .eval("window.__evaluated = 'eval from ' + 'tokio'")
    .unwrap();
  th()
}

页面依次 invoke('probe_sync')、invoke('probe_async'),最后把命令返回的线程名和 window.__evaluated 送回 report:

[main] main/ThreadId(1)
[setup] on main/ThreadId(1)
[setup] run_on_main_thread closure on main/ThreadId(1)
[setup] order=["before", "inside", "after"]
...
[run] Ready on main/ThreadId(1)
[probe_sync] command on main/ThreadId(1)
[probe_sync] created window "third" via AppHandle in 62.814375ms
[probe_sync] run_on_main_thread closure on main/ThreadId(1)
[probe_sync] order=["before", "inside", "after"] title="base" getter took 16.416µs
...
[probe_async] command on tokio-rt-worker/ThreadId(15)
[probe_async] order right after call=["before", "after"]
[probe_async] run_on_main_thread closure on main/ThreadId(1)
...
[probe_async] title="set on main" getter took 35.625µs
[probe_async] order later=["before", "after", "inside"]
REPORT sync=main/ThreadId(1) async=tokio-rt-worker/ThreadId(15) evaluated=eval from tokio
[run] Exit on main/ThreadId(1)

逐项看:

  • setup 和同步命令都在 main/ThreadId(1) 上,也就是 Wry::init 记下的线程。run_on_main_thread 的闭包当场执行,顺序是 before、inside、after。同步命令为什么在主线程上,是 IPC 那一侧的事(WebKit 在主线程上调协议处理函数,tauri 当场执行同步命令),见 参数、返回值与 InvokeResolver。
  • async 命令在 tokio 的工作线程上(tauri 的 async_runtime 默认是一个多线程 tokio 运行时,tokio 卷的 多线程调度器)。闭包仍然在 main/ThreadId(1) 上执行,但 order_probe 返回时它多半还没跑:这次是 before、after,过一会儿才变成 before、after、inside。这不是保证,只是竞速:同一个程序跑 10 次,10 次都是 before、after;改动之前的一个版本跑 10 次,有 2 次是 before、inside、after,主线程在 tokio 线程记下 after 之前就处理完了消息。
  • getter 在主线程上 16µs,在 tokio 线程上这次 35µs,前几次运行里见过 650µs 和 5ms:后者要等主线程那一轮跑到这条消息。
  • 在主线程上用 AppHandle 新建窗口花了 62ms,没有卡住。RuntimeHandle::create_window 的实现上方有一行注释「Note that this must be called from a separate thread, otherwise the channel will introduce a deadlock.」,这条注释是 2021 年 5 月 #1855 写的,早于下面冷知识里的主线程捷径;现在主线程上走 handle_user_message 直接建窗口、往 tx 里写结果,rx.recv() 立即返回。
  • evaluated=eval from tokio:async 命令里的 eval 在命令返回之前发出,页面收到 probe_async 的结果时它已经执行了。这也是竞速:eval 走 proxy 进事件循环,命令的结果却是 tokio 线程直接交给 wry 的协议回答闭包(见 wry 在 macOS 上),两条路之间没有先后保证。连同实验早期版本在内跑了十几次,有一次输出的是 evaluated=undefined。
  • 中间省略的几行是关于 set_title 之后马上 title() 的,结果有点反直觉,它是 tao 在 macOS 上的行为,放在 tao:窗口与主线程。
📝NOTE
冷知识:最早,窗口 API 在主线程上调用会直接 panic

2021 年 5 月 create_window 进入 AppHandle 时(#1855),tauri-runtime-wry 的每个窗口操作都无条件 proxy.send_event 发消息,建窗口和 getter 还要在 channel 上等结果;在主线程上调,主线程自己阻塞在 recv 上,事件循环永远处理不到那条消息。tauri 当时 create_window 的文档直接写着「Panics when called on the main thread, usually on the run closure.」,建议「spawn a task to use the API using crate::async_runtime::spawn or std::thread::spawn」(原文里这几个名字是 rustdoc 内链,这里去掉了链接)。同年 9 月的 PR #2659(「feat(core): window, shortcut and clipboard API calls on main thread」)和 PR #2668(「feat(tauri-runtime-wry): allow window creation and closing on the main thread」)加上了 current_thread().id() == context.main_thread_id 的判断,在主线程上就地调 handle_user_message,并删掉了那两段 Panics 文档。tauri-runtime-wry 0.3.0 的 CHANGELOG 记的是「Allow window, global shortcut and clipboard APIs to be called on the main thread.」,对应 tauri 1.0.0-rc.0(预发布)。函数后来在 tauri 2.12.0 带来的 #15960 里从自由函数挪成了 Context 的方法,判断原样保留。

什么时候会死锁

捷径只解决了「主线程等自己」。主线程在等别的线程,而那个线程在等主线程,照样死锁。最容易写出来的是同步命令:它就在主线程上执行。

#[tauri::command]
fn deadlock(window: WebviewWindow) {
  println!("[deadlock] sync command on {}, spawning a thread that calls title()", th());
  let h = thread::spawn(move || {
    println!("[deadlock] helper thread {} calling window.title()", th());
    let t = window.title();
    println!("[deadlock] helper got {t:?}");
  });
  h.join().unwrap();
  println!("[deadlock] joined");
}
❗IMPORTANT
动脑筋:页面一开始先 invoke('deadlock')。程序最后打印的三行是什么?退出码是多少?(main 里有一个 15 秒后 eprintln!("TIMEOUT") 并 process::exit(2) 的看门狗线程。)

同样的道理,block_on 一个会去调 getter 的 future、在主线程上持锁而另一个线程拿着同一把锁调 getter,都是这个形状。async 命令不在主线程上,在里面调 getter 没问题,代价只是那几十微秒到几毫秒的往返。

tracing feature 打开时还有一个变化:eval_script 和 eval_script_with_callback 不再用 send_user_message 发完就走,而是改用 getter!,等主线程执行完 evaluate_script 再返回,源码注释是「use a channel so the EvaluateScript task uses the current span as parent」。所以开了 tracing 之后,在别的线程上调 webview.eval 会阻塞到主线程处理这条消息为止。

事件循环:tao 事件变成 RunEvent

Runtime::run 是 self.event_loop.run(make_event_handler(..))。tao 的 run 不返回,事件循环结束后直接 process::exit;要拿回控制权用 run_return。make_event_handler 先把事件交给运行时插件(WryHandle::plugin 注册的,返回 true 就拦下),再交给 handle_event_loop。它是一个大 match:

tao 事件处理
NewEvents(StartCause::Init)callback(RunEvent::Ready)
NewEvents(StartCause::Poll)RunEvent::Resumed
MainEventsClearedRunEvent::MainEventsCleared
LoopDestroyedRunEvent::Exit
WindowEvent { window_id, .. }用 window_id_map 把 tao 的窗口 id 换成 tauri 的 WindowId,转成 RunEvent::WindowEvent,再调这个窗口的监听器;CloseRequested 走关闭流程,Destroyed 从 WindowsStore 删掉窗口,删空了发 ExitRequested
UserEvent(Message::RequestExit(code))发 ExitRequested { code: Some(code), tx },没人 Prevent 就 ControlFlow::ExitWithCode(code)
UserEvent(Message::UserEvent(t))RunEvent::UserEvent(t)
其余 UserEvent(message)handle_user_message,和主线程捷径是同一个函数

RequestExit 只能走 proxy。request_exit 直接 self.context.proxy.send_event(..),注释是「request_exit cannot use Context::send_user_message because it accesses the event loop callback」;handle_user_message 里对应的分支是 panic!("cannot handle RequestExit on the main thread")。退出要先问应用(ExitRequested 回调),回调只有 handle_event_loop 手里有,handle_user_message 没有。所以 app.exit(0) 即使在主线程上调,也要等事件循环下一轮才真正退出:实验里 report 命令打印 REPORT ... 之后还能看到 [run] Exit。

run_iteration 给想把 tauri 嵌进自己循环的应用用:它用 run_return 跑 tao,但每个事件都把 control_flow 设成 Wait,遇到 MainEventsCleared 设成 Exit,处理完一轮就返回。

create_webview:PendingWebview 交给 wry

主线程上真正建 webview 的是自由函数 create_webview,它把 PendingWebview 拆开,一项一项喂给 wry 的 WebViewBuilder。和 IPC、协议有关的是最后几步:

  webview_builder = webview_builder.with_ipc_handler(create_ipc_handler(
    window_id_for_ipc,
    id,
    context.clone(),
    label.clone(),
    ipc_handler,
  ));

  for script in webview_attributes.initialization_scripts {
    webview_builder = webview_builder
      .with_initialization_script_for_main_only(script.script, script.for_main_frame_only);
  }

  for (scheme, protocol) in uri_scheme_protocols {
    // on Linux the custom protocols are associated with the web context
    // and you cannot register a scheme more than once
    #[cfg(any(
      target_os = "linux",
      target_os = "dragonfly",
      target_os = "freebsd",
      target_os = "netbsd",
      target_os = "openbsd"
    ))]
    {
      if web_context.registered_custom_protocols.contains(&scheme) {
        continue;
      }

      web_context
        .registered_custom_protocols
        .insert(scheme.clone());
    }

    webview_builder = webview_builder.with_asynchronous_custom_protocol(
      scheme,
      move |webview_id, request, responder| {
        protocol(
          webview_id,
          request,
          Box::new(move |response| responder.respond(response)),
        )
      },
    );
  }

三点:

  • 每个协议都用 wry 的 with_asynchronous_custom_protocol 注册,tauri 的回答闭包就是 wry RequestAsyncResponder::respond 外面包一层。运行时这一层没有同步处理函数;tauri:// 的处理函数甚至不在主线程上读资源,而是 async_runtime::spawn 一个任务,在 tokio 线程上算好响应再回答(见 tauri:// 协议与 CSP)。
  • 初始化脚本原样带着 for_main_frame_only 交下去,tauri 自己的脚本都是主 frame(见 初始化脚本),wry 在 macOS 上怎么注入在下一页。
  • ipc_handler 是 window.ipc.postMessage 的接收端。create_ipc_handler 每收到一条消息就现做一个 DetachedWebview(带一个新的派发器),连同 Request<String> 交给 tauri;tauri 怎么用它见 ipc 协议处理。

函数开头还有一个 WebContext 的存储:wry 的 WebContext 按 data_directory 做键存在 WebContextStore 里,注释说「the context must be stored on the HashMap because it must outlive the WebView on macOS」。同一个数据目录的多个 webview 共用一个 context;Linux 上自定义协议注册在 context 上,同一个 scheme 只能注册一次,所以上面那段要跳过已经注册过的。

动脑筋答案

[run] Ready on main/ThreadId(1)
[deadlock] sync command on main/ThreadId(1), spawning a thread that calls title()
[deadlock] helper thread -/ThreadId(16) calling window.title()
TIMEOUT
exit=2

deadlock 在主线程上 join 辅助线程;辅助线程的 window.title() 不在主线程上,经 proxy 发出 WindowMessage::Title 后在 rx.recv() 上等;那条消息要主线程回到事件循环才处理,而主线程卡在 join。两边都不动,[deadlock] helper got 和 [deadlock] joined 都没有打印,15 秒后看门狗退出,退出码 2。

小结

  • Wry::init 把当前线程记成主线程;Context 里能跨线程的只有 proxy 和计数器,窗口和 webview 存在只给主线程碰的 RefCell 里,unsafe impl Send/Sync 的前提是所有访问都经过 send_user_message。
  • send_user_message:主线程上就地 handle_user_message,别的线程上 proxy.send_event(Message)。setter 发完即走;getter 带一个 mpsc 的 tx,在 rx.recv() 上等主线程回答。
  • 实测:setup、同步命令在主线程,run_on_main_thread 当场执行;async 命令在 tokio-rt-worker,闭包稍后在主线程执行,和调用方之间的先后是竞速。eval 和 IPC 回答也是两条互不排序的路。
  • 主线程等别的线程、那个线程又调 getter,就是死锁;tracing feature 下 eval 也会等主线程。
  • RequestExit 必须经 proxy,因为退出要先发 ExitRequested 问应用;tao 事件到 RunEvent 的映射在 handle_event_loop。
  • create_webview 把每个协议都注册成 wry 的异步协议,初始化脚本带着 for_main_frame_only 原样交下去。

💬 关联讨论

发起讨论
加载中...