Rust in Everything
基于 tauri 2.12.5

tao:窗口与主线程

主线位置:启动主线上「R::new(Wry 建 tao 的 EventLoop)」和「App::run 进入 tao 的事件循环,RunEvent::Ready」两格。此外,调用主线、事件主线上每一次从别的线程操作窗口或 webview,都是在唤醒这一页讲的这个事件循环。

flowchart TB
  newnode["EventLoop::new 主线程"] --> nsapp["NSApplication 和 delegate"]
  newnode --> obs["两个 run loop observer"]
  runnode["run_return"] --> apprun["NSApp.run"]
  apprun --> wait["run loop 休眠"]
  proxy["其他线程 send_event"] --> chan["crossbeam channel"]
  proxy --> sig["CFRunLoopSourceSignal 和 WakeUp"]
  sig --> wait
  wait --> after["AfterWaiting 发 NewEvents"]
  after --> before["BeforeWaiting 调 cleared"]
  chan --> before
  before --> cb["回调 UserEvent 和 MainEventsCleared"]
  cb --> wait

本页讲 tao 0.37.2(tag tao-v0.37.2)的 src/event_loop.rs 和 src/platform_impl/macos/。实验用一个直接依赖 tao =0.37.2(以及 wry =0.57.1)的小程序,窗口 with_visible(false),程序自己退出事件循环;另一部分在 tauri 应用里(tauri =2.12.5)。平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建。Windows、Linux 的部分只读源码。

tao 和 winit

tao 的源文件开头大多是两行版权:

// Copyright 2014-2021 The winit contributors
// Copyright 2021-2023 Tauri Programme within The Commons Conservancy

tao 仓库的历史从 winit 2014 年的第一个提交开始,版权行在 2021 年换手。README 的说法是「Cross-platform application window creation library in Rust that supports all major platforms like Windows, macOS, Linux, iOS and Android. Built for you, maintained for Tauri.」API 骨架仍是从 winit 继承来的那一套:EventLoop::run(|event, target, control_flow| ..)、ControlFlow::{Wait, Poll, WaitUntil, ExitWithCode}、Event::NewEvents / MainEventsCleared / RedrawEventsCleared / LoopDestroyed、EventLoopProxy::send_event。

tauri 在 tao 上用到的几处平台相关的东西:

  • Linux 后端是 GTK:Cargo.toml 在 Linux / BSD 上依赖 gtk = "0.18",窗口是 gtk::ApplicationWindow,事件循环跑在 GLib 的 MainContext 上。WebKitGTK 的 webview 是一个 GTK 控件,要放进窗口里,tauri-runtime-wry 在 Linux 上把它塞进 tao 给每个窗口准备的 default_vbox。
  • macOS 上应用级的设置:set_activation_policy、set_dock_visibility、set_badge_label、进度条,tauri-runtime-wry 的 Message::SetActivationPolicy、SetDockVisibility 直接转发到这里。
  • 菜单、托盘、全局快捷键不在 tao 里。
📝NOTE
冷知识:tao 原来自带菜单和托盘,0.22.0 一次拆了出去

早期的 tao 有 menu、system_tray、global_shortcut 模块和 Event::MenuEvent、Event::TrayEvent 这些事件,CHANGELOG 里还能看到「On macOS, scale menu item icons height to 18.」「Fix the native Services menu on macOS.」这类修菜单的条目。PR #778(「refactor!: remove menus, system tray and global shortcut features」)把它们全部删掉,CHANGELOG 写的是:「This release contains a number of breaking changes that aimed at removing menus, system-tray and global-shortcuts features which have been moved to different crates, muda, tray-icon and global-hotkey」(原文里三个 crate 名带链接),并列出「Removed Event::MenuEvent, Event::TrayEvent, Event::GlobalShortcutEvent」「Added EventLoopBuilder type.」第一个包含它的版本是 tao 0.22.0。这就是 tauri 2.x 的菜单事件要绕一圈的原因:muda 的事件处理函数把 MenuEvent 包成 EventLoopMessage::MenuEvent,经 EventLoopProxy 送回 tao 的事件循环,见 tauri-runtime 的抽象。

EventLoop::new:只能在主线程

macOS 上的 EventLoop::new:

  pub(crate) fn new(_: &PlatformSpecificEventLoopAttributes) -> Self {
    let panic_info: Rc<PanicInfo> = Default::default();
    setup_control_flow_observers(Rc::downgrade(&panic_info));

    let delegate = unsafe {
      if !util::is_main_thread() {
        panic!("On macOS, `EventLoop` must be created on the main thread!");
      }

      // This must be done before `NSApp()` (equivalent to sending
      // `sharedApplication`) is called anywhere else, or we'll end up
      // with the wrong `NSApplication` class and the wrong thread could
      // be marked as main.
      let app: id = msg_send![APP_CLASS.0, sharedApplication];

      let delegate = IdRef::new(msg_send![APP_DELEGATE_CLASS.0, new]);
      let _pool = NSAutoreleasePool::new();
      let _: () = msg_send![app, setDelegate:*delegate];
      delegate
    };
    // ...
  }

is_main_thread 问的是 NSThread isMainThread,即进程的第一个线程,不是「第一个建 EventLoop 的线程」。APP_CLASS 是 tao 派生的 NSApplication 子类,APP_DELEGATE_CLASS 是它的 NSApplicationDelegate,applicationDidFinishLaunching: 里调 AppState::launched,发出第一个 NewEvents(StartCause::Init)。实验在一个叫 not-main 的线程上建事件循环:

  let h = thread::Builder::new()
    .name("not-main".into())
    .spawn(|| {
      let _el = EventLoopBuilder::<()>::with_user_event().build();
    })
    .unwrap();
[main] main/ThreadId(1)

thread 'not-main' (2381826) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tao-0.37.2/src/platform_impl/macos/event_loop.rs:167:9:
On macOS, `EventLoop` must be created on the main thread!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[offmain] join is_err=true

所以 tauri::Builder::run 必须在 main 里调,Runtime::new 的文档写「Must be used on the main thread」;Windows 和 Linux 上 tao 有 with_any_thread(true),tauri-runtime-wry 对应地提供 new_any_thread,macOS 没有这个选项。

run_return 把回调存进全局的 HANDLER,然后 app.run():进入 AppKit 自己的主循环,直到有人 stop。run 是 run_return 之后 process::exit(exit_code),返回类型是 !。tauri 的 App::run 用的是这个,所以 run 之后的代码不会执行,清理工作要放在 RunEvent::Exit 里。

回调由 run loop observer 驱动

tao 不自己写循环,循环是 NSApplication 的。它往主线程的 CFRunLoop 上挂了两个 observer(setup_control_flow_observers),优先级一个最高一个最低:

  • kCFRunLoopAfterWaiting(run loop 刚被唤醒)→ AppState::wakeup:按 ControlFlow 算出 StartCause,发 NewEvents(cause)。
  • kCFRunLoopBeforeWaiting(run loop 要睡了)→ AppState::cleared:
  pub fn cleared(panic_info: Weak<PanicInfo>) {
    let panic_info = panic_info
      .upgrade()
      .expect("The panic info must exist here. This failure indicates a developer error.");
    // Return when in callback due to https://github.com/rust-windowing/winit/issues/1779
    if panic_info.is_panicking() || !HANDLER.is_ready() || HANDLER.get_in_callback() {
      return;
    }
    HANDLER.set_in_callback(true);
    HANDLER.handle_user_events();
    for event in HANDLER.take_events() {
      HANDLER.handle_nonuser_event(event);
    }
    HANDLER.handle_nonuser_event(EventWrapper::StaticEvent(Event::MainEventsCleared));
    for window_id in HANDLER.should_redraw() {
      HANDLER.handle_nonuser_event(EventWrapper::StaticEvent(Event::RedrawRequested(window_id)));
    }
    HANDLER.handle_nonuser_event(EventWrapper::StaticEvent(Event::RedrawEventsCleared));
    HANDLER.set_in_callback(false);
    if HANDLER.should_exit() {
      unsafe {
        let mtm = MainThreadMarker::new().unwrap();
        let app = NSApp(mtm);
        let _pool = NSAutoreleasePool::new();
        app.stop(None);
        // To stop event loop immediately, we need to post some event here.
        post_dummy_event(&app);
      };
    }
    // ...
  }

user event 是在 run loop 每一轮快要睡之前一次性取完的:handle_user_events 对 channel 做 try_iter(),有几个回调几次。窗口事件也在这里交付,但排在 user event 后面:NSWindowDelegate(window_delegate.rs 的 emit_event)和 NSView 的方法在 AppKit 分发时只是 AppState::queue_event 进 pending_events,由 take_events 取出;queue_event 不在主线程调会 panic「Event queued from different thread」。例外是 drawRect: 触发的 RedrawRequested,它直接调回调。in_callback 标志防止重入:回调还没返回时又走到的 wakeup / cleared 直接返回,注释指向 winit 的 issue #1779。ExitWithCode 时 cleared 调 app.stop 并投一个空事件,让 NSApp.run 返回。

实验:在 NewEvents(Init) 时起一个 sender 线程,经 proxy 连发 1、2、3 三个 user event,收到 3 就设 ControlFlow::Exit,回调打印每个事件:

[main] main/ThreadId(1)
[order] #1 NewEvents(Init) on main/ThreadId(1)
[order] sent 1 from sender/ThreadId(3)
[order] sent 2 from sender/ThreadId(3)
[order] sent 3 from sender/ThreadId(3)
[order] #2 RedrawRequested on main/ThreadId(1)
[order] #3 UserEvent(1) on main/ThreadId(1)
[order] #4 UserEvent(2) on main/ThreadId(1)
[order] #5 UserEvent(3) on main/ThreadId(1)
[order] #6 MainEventsCleared on main/ThreadId(1)
[order] #7 RedrawRequested on main/ThreadId(1)
[order] #8 RedrawEventsCleared on main/ThreadId(1)
[order] #9 NewEvents(Poll) on main/ThreadId(1)
[order] #10 LoopDestroyed on main/ThreadId(1)

三个事件在别的线程发出,在主线程同一轮里连续交付,紧跟着 MainEventsCleared,和 cleared 的顺序一致。排在它们前面的 #2 RedrawRequested 就是 drawRect: 直接调回调的那一种。这一轮里设了 Exit,cleared 调 stop,再醒来一次(wakeup 在 ExitWithCode 时报 StartCause::Poll),run_return 收尾发 LoopDestroyed。退出前空转的轮数并不固定:同一个程序连跑三次,有一次在 LoopDestroyed 之前多转了六十多轮 NewEvents(Poll) / MainEventsCleared / RedrawEventsCleared。tauri-runtime-wry 的 handle_event_loop 把这几种事件翻译成 RunEvent::Ready、UserEvent、MainEventsCleared、Exit,见 上一页。

EventLoopProxy:channel 加一个 run loop source

跨平台的 EventLoopProxy 只是包了一层平台实现,macOS 上是 Proxy:

pub struct Proxy<T> {
  sender: Sender<T>,
  source: CFRunLoopSourceRef,
}

unsafe impl<T: Send> Send for Proxy<T> {}
unsafe impl<T: Send> Sync for Proxy<T> {}
// ...
impl<T> Proxy<T> {
  fn new(sender: Sender<T>) -> Self {
    unsafe {
      // just wake up the eventloop
      extern "C-unwind" fn event_loop_proxy_handler(_: *mut c_void) {}

      // adding a Source to the main CFRunLoop lets us wake it up and
      // process user events through the normal OS EventLoop mechanisms.
      let rl = CFRunLoopGetMain();
      let mut context: CFRunLoopSourceContext = mem::zeroed();
      context.perform = Some(event_loop_proxy_handler);
      let source = CFRunLoopSourceCreate(ptr::null_mut(), CFIndex::MAX - 1, &mut context);
      CFRunLoopAddSource(rl, source, kCFRunLoopCommonModes);
      CFRunLoopWakeUp(rl);

      Proxy { sender, source }
    }
  }

  pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed<T>> {
    self
      .sender
      .send(event)
      .map_err(|channel::SendError(x)| EventLoopClosed(x))?;
    unsafe {
      // let the main thread know there's a new event
      CFRunLoopSourceSignal(self.source);
      let rl = CFRunLoopGetMain();
      CFRunLoopWakeUp(rl);
    }
    Ok(())
  }
}

sender 是 crossbeam_channel::unbounded() 的发送端,接收端在 EventLoopWindowTarget 里,被 cleared 取空。source 的 perform 回调是空函数,它存在的意义只是让 run loop 醒来:醒来之后、再睡之前一定会经过 BeforeWaiting,cleared 就把 channel 里的东西处理掉。channel 无界,发送方从不阻塞;proxy 失败只有一种情况,接收端已经没了(事件循环结束),这时得到 EventLoopClosed,tauri-runtime-wry 把它映射成 Error::FailedToSendMessage 或 Error::EventLoopClosed。

Clone for Proxy 不是复制指针,而是 Proxy::new(self.sender.clone()):每克隆一次就往主 run loop 上加一个新的 source,Drop 时 CFRelease。tauri-runtime-wry 的 Context 里存着一个 proxy,Context 每克隆一次(每个派发器各一份,create_ipc_handler 每收到一条 postMessage 也克隆一次)就多一个 source。

另外两个平台(只读源码):Windows 上 send_event 先 PostMessageW 一个注册名为 Tao::WakeupMsg 的消息到事件循环的目标窗口(target_window),成功后才把事件放进 channel;Linux 上 send_event 先送进 channel,再 MainContext::default().wakeup()。三者都是「一个无界 channel 加一个让 UI 线程醒来的信号」。

窗口操作:有的同步,有的异步派发

tao 的 Window 在 macOS 上包着 NSWindow。AppKit 的多数方法只能在主线程调,tao 继承了 winit 的做法,在 util/async.rs 里把它们分成两类:

函数方式源码注释
set_title_asyncDispatchQueue::main().exec_async「setTitle: isn't thread-safe. Calling it from another thread invalidates the window drag regions, which throws an exception when not done in the main thread」
set_content_size_async、set_frame_top_left_point_async、set_level_async、set_maximized_async、toggle_full_screen_async、set_ignore_mouse_eventsexec_async各有一行注释说明对应的 AppKit 调用为什么不能在别的线程直接做
order_out_sync、make_key_and_order_front_sync、set_focus、close_asyncrun_on_main:在主线程上就直接调,否则 exec_syncorderOut: 「Calling it from another thread actually works, but with an odd delay.」

exec_async 那一组在主线程上调用也是异步的:闭包被放进 GCD 主队列,要等当前这段主线程代码返回到 run loop 之后才执行。而 title() 是同步读 ns_window.title()。

在 tauri 里,set_title 经 send_user_message 到了主线程(上一页),再由 tao 的 set_title 交给 set_title_async。上一页的实验里,同步命令(主线程)先读一次标题,然后 set_title("set on main"),紧接着再读;之后 async 命令(tokio 线程)再读一次、set_title("set from tokio")、再读:

[probe_sync] order=["before", "inside", "after"] title="base" getter took 16.416µs
[probe_sync] right after set_title: title="base"
[probe_async] command on tokio-rt-worker/ThreadId(15)
...
[probe_async] title before set="set on main"
[probe_async] title="set on main" getter took 35.625µs
📝NOTE
没有蠢问题

问: 同步命令就在主线程上,set_title 也走了主线程捷径,为什么紧接着 title() 读到的还是 base?async 命令里设完再读,读到的为什么也是上一次的值?

答: 两层都不是「设完即生效」。tauri 这一层在主线程上是同步的,handle_user_message 当场调了 tao 的 set_title;但 tao 在 macOS 上不直接 setTitle:,而是 exec_async 到 GCD 主队列,闭包要等主线程回到 run loop 才执行,而这时同步命令还没返回。tokio 线程那边,set_title 和 title() 是两条按顺序进 channel 的消息,主线程也按顺序处理:处理 SetTitle 只是又一次 exec_async,处理 Title 时同步读 ns_window.title(),实测这时 GCD 闭包还没轮到。等到下一次读(async 命令开头那次),中间隔着一整个 IPC 往返,标题早已更新成 set on main。要拿到确定的新标题,只能在应用里自己记一份,不要依赖设完马上读。

这一组 exec_async 对 tauri 来说是多余的一层:tauri-runtime-wry 已经保证 tao 的方法只在主线程上被调。但 tao 是给所有人用的库,它不能假设调用方已经在主线程上,所以统一走 GCD。

小结

  • tao 是 winit 的分叉,保留了 winit 的 EventLoop / ControlFlow / Event 骨架;Linux 后端是 GTK;菜单、托盘、全局快捷键在 0.22.0(#778)拆成了 muda、tray-icon、global-hotkey。
  • macOS 上 EventLoop::new 不在进程主线程就 panic(实测原文「On macOS, EventLoop must be created on the main thread!」);run_return 就是 NSApp.run(),run 结束后 process::exit。
  • 回调由主 run loop 上的两个 observer 驱动:醒来时发 NewEvents,要睡前 cleared 一次性取完 user event,再发 MainEventsCleared、RedrawEventsCleared;in_callback 防重入。
  • EventLoopProxy = 无界 crossbeam channel + 一个主 run loop 上的 CFRunLoopSource;send_event 从不阻塞,每次克隆新建一个 source。Windows 用 PostMessageW,Linux 用 MainContext::wakeup(只读源码)。
  • tao 在 macOS 上把 setTitle: 等操作 exec_async 到 GCD 主队列,在主线程上也是异步的:实测 set_title 之后马上 title() 读到旧值。

💬 关联讨论

发起讨论
加载中...