用事件传大数据很慢
主线位置:事件主线的最后一步。
AppManager::emit序列化 payload,对每个有 JS 监听器的 webview 生成一段脚本并eval,页面里的事件分发函数再找到监听器回调。
flowchart TB emit["app.emit recs"] --> ser["serde_json 序列化"] ser --> script["拼进 eval 脚本源码"] script --> parse["WebKit 解析 10 MiB 源码"] parse --> cb["监听器回调"] chan["Channel send"] --> fetchit["JS fetch 取回"] fetchit --> jp["response.json 解析"] jp --> onm["onmessage"]
实验 crate pit2-app:从模板 base 复制,tauri =2.12.5,--features tauri/custom-protocol,withGlobalTauri 打开(页面用 window.__TAURI__.event.listen 和 window.__TAURI__.core.Channel),隐藏窗口,release 构建,MODE=bigemit。平台 M2 Max、macOS 27、rustc 1.99.0。数据和 Channel 那一页的实验相同:140598 条 Rec { id, name, score, tags },序列化后约 10 MiB。
现象
#[tauri::command]
fn rec_emit(app: AppHandle) -> tauri::Result<u128> {
let t0 = Instant::now();
app.emit("recs", records())?;
Ok(t0.elapsed().as_millis())
}
页面在发起前开始用 MessageChannel 给自己连续发消息,记下 JS 事件循环两圈之间最长的间隔,收到数据后停止;同时记下从 invoke 到监听器拿到数据的总时间,以及 Rust 侧 emit 调用本身花了多久:
async function recEmit(total, cmd = 'rec_emit') {
let resolveIt;
const got = new Promise(r => { resolveIt = r; });
const un = await window.__TAURI__.event.listen('recs', (e) => resolveIt(e.payload.length));
const stop = {};
const t0 = performance.now();
const sp = spin(10000, stop);
const rustMs = await inv(cmd);
const n = await got;
const ms = Math.round(performance.now() - t0);
stop.done = true;
const busy = await sp;
un();
return { how: cmd, recs: n, totalMs: ms, rustCallMs: rustMs, ...busy };
}
三轮中的一轮(完整输出在实验目录的 pit2-bigemit-release.txt):
"idle": {
"turns": 16257,
"maxGapMs": 1
},
...
{
"how": "rec_emit",
"recs": 140598,
"totalMs": 2389,
"rustCallMs": 15,
"turns": 895,
"maxGapMs": 2374
},
- 从发出到收到 2389 毫秒,三轮分别是 2389、2404、2399。
- Rust 侧的
emit调用只花了 15 毫秒:序列化、拼脚本、交给 webview,就返回了。慢的不在 Rust。 - 页面的 JS 有一次连续 2374 毫秒没有转过一圈(空闲时最长间隔 1 毫秒)。也就是说这 2.4 秒里页面上的点击、输入、动画全部停住,用户看到的是界面卡死,而不只是「数据来得慢」。
出处
AppManager::emit 先把 payload 序列化成字符串(EmitArgs),然后 emit_js 对每个注册了这个事件 JS 监听器的 webview 调 webview.emit_js,最后才是 Rust 侧监听器(顺序见 AppManager 与 Manager trait)。每个 webview 拿到的是 emit_js_script 生成的一段脚本:
pub(crate) fn emit_js_script(
event_emit_function_name: &str,
emit_args: &EmitArgs,
serialized_ids: &str,
) -> crate::Result<String> {
Ok(format!(
"(function () {{ const fn = window['{}']; fn && fn({{event: '{}', payload: {}}}, {ids}) }})()",
event_emit_function_name,
emit_args.event,
emit_args.payload,
ids = serialized_ids,
))
}
payload: {} 那里填进去的是 payload 的 JSON 文本,原样成为 JS 源码的一部分。10 MiB 的数组字面量要由 JavaScriptCore 当程序解析、编译、执行,这一步发生在页面的主线程上,和页面自己的 JS 是同一个线程,所以页面停住。
Channel 发同样大的消息时不走这条路:超过阈值(JSON 8192 字节)的消息存进队列,只 eval 一小段脚本让 JS 发一次 fetch 取回,响应体由 response.json() 解析成数据,不经过 JS 编译器(阈值和取回的过程见 Channel)。
emit 的代价还有两处会放大:
- 每个监听了这个事件的 webview 都各收到一份完整的脚本,各自解析一遍。多窗口应用里
emit一次大数据,每个窗口都卡一次;只发给一个窗口用emit_to。 - 纯文本并不慢。Channel 那一页测过,10 MiB 全是
a的一个字符串,emit也只要 10 毫秒左右:一个字符串字面量解析起来很便宜,贵的是成千上万个对象、数组、数字。
修法
用 Channel:
#[tauri::command]
fn rec_channel(ch: Channel<&'static Vec<Rec>>) -> tauri::Result<u128> {
let t0 = Instant::now();
ch.send(records())?;
Ok(t0.elapsed().as_millis())
}
async function recChannel(total) {
let resolveIt;
const got = new Promise(r => { resolveIt = r; });
const ch = new window.__TAURI__.core.Channel();
ch.onmessage = (m) => resolveIt(m.length);
const stop = {};
const t0 = performance.now();
const sp = spin(10000, stop);
const rustMs = await inv('rec_channel', { ch });
const n = await got;
const ms = Math.round(performance.now() - t0);
stop.done = true;
const busy = await sp;
return { how: 'channel', recs: n, totalMs: ms, rustCallMs: rustMs, ...busy };
}
同一次运行里的三轮:
{
"how": "channel",
"recs": 140598,
"totalMs": 49,
"rustCallMs": 11,
"turns": 720,
"maxGapMs": 35
},
...
{
"how": "channel",
"recs": 140598,
"totalMs": 50,
"rustCallMs": 11,
"turns": 20,
"maxGapMs": 36
},
...
{
"how": "channel",
"recs": 140598,
"totalMs": 55,
"rustCallMs": 11,
"turns": 495,
"maxGapMs": 32
},
总时间 49 到 55 毫秒,页面最长停顿 32 到 36 毫秒(response.json() 解析 10 MiB 的那一下,仍然在页面主线程上,只是快得多)。
| 方式 | 总时间(毫秒) | Rust 调用(毫秒) | 页面最长停顿(毫秒) |
|---|---|---|---|
emit | 2389 / 2404 / 2399 | 15 / 14 / 15 | 2374 / 2390 / 2381 |
Channel | 49 / 50 / 55 | 11 / 11 / 11 | 35 / 36 / 32 |
选哪个:
- 一次性的结果:命令的返回值就够了,和单条 Channel 消息一样快(Channel 里测过,53 毫秒)。
- 命令返回之后还要陆续推送(进度、日志、流式结果):
Channel。 - 二进制数据:
ipc::Response或Channel<InvokeResponseBody>发Raw,不经过 JSON。 - 事件留给小的通知:「数据变了」「任务完成」,接到通知后前端再用命令去取数据。
- 必须用事件时,把数据切小(Channel 里 160 个约 64 KiB 的事件共 216 毫秒),并用
emit_to只发给需要的窗口。
问: 2.3.0 加了 emit_str,可以直接传一个已经序列化好的 JSON 字符串。是不是用它就省掉了序列化的开销,emit 就快了?
答: 省掉的只是 Rust 侧那十几毫秒。同一次运行里还跑了一个预先序列化好、用 app.emit_str("recs", json.clone()) 发出的版本:
{
"how": "rec_emit_str",
"recs": 140598,
"totalMs": 2350,
"rustCallMs": 4,
"turns": 283,
"maxGapMs": 2334
},
Rust 调用从 15 毫秒降到 3 到 4 毫秒,总时间三轮是 2350、2358、2409 毫秒,页面照样停 2.3 秒以上。emit_str 进的是 EmitArgs::new_str,后面同样经 emit_js_script 拼进脚本,贵的那一步没变。
怎么确认没中招
- 搜
.emit(、.emit_to(、.emit_str(,看 payload 的类型:Vec<_>、HashMap、整份文档、整张表的,都该换。 - 在页面里放一个
MessageChannel或requestAnimationFrame计时器,记录最长的一次间隔;收到大事件时出现上百毫秒的空洞,就是这个坑。(隐藏窗口里setTimeout/setInterval被 WebKit 节流,不能用来测。) - 在 Rust 侧计时
emit调用是测不出来的:它在页面解析之前就返回了。
小结
emit把 payload 的 JSON 当 JS 源码拼进eval脚本,每个有监听器的 webview 各解析一遍;大的结构化数据解析很慢,而且解析期间页面的 JS 完全停住。- 10 MiB 记录:
emit约 2.4 秒、页面停 2.4 秒;Channel约 50 毫秒、最长停 36 毫秒。Rust 侧两者都只有十几毫秒。 emit_str只省 Rust 侧的序列化,不解决问题。- 修法:大数据用命令返回值或
Channel,二进制用Raw;事件只发小通知。