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 里。
早期的 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_async | DispatchQueue::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_events | exec_async | 各有一行注释说明对应的 AppKit 调用为什么不能在别的线程直接做 |
order_out_sync、make_key_and_order_front_sync、set_focus、close_async | run_on_main:在主线程上就直接调,否则 exec_sync | orderOut: 「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
问: 同步命令就在主线程上,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,EventLoopmust 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()读到旧值。