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:窗口与主线程。
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");
}
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 |
MainEventsCleared | RunEvent::MainEventsCleared |
LoopDestroyed | RunEvent::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 的回答闭包就是 wryRequestAsyncResponder::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,就是死锁;
tracingfeature 下eval也会等主线程。 RequestExit必须经 proxy,因为退出要先发ExitRequested问应用;tao 事件到RunEvent的映射在handle_event_loop。create_webview把每个协议都注册成 wry 的异步协议,初始化脚本带着for_main_frame_only原样交下去。