Rust in Everything
基于 tauri 2.12.5

用事件传大数据很慢

主线位置:事件主线的最后一步。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 调用(毫秒)页面最长停顿(毫秒)
emit2389 / 2404 / 239915 / 14 / 152374 / 2390 / 2381
Channel49 / 50 / 5511 / 11 / 1135 / 36 / 32

选哪个:

  • 一次性的结果:命令的返回值就够了,和单条 Channel 消息一样快(Channel 里测过,53 毫秒)。
  • 命令返回之后还要陆续推送(进度、日志、流式结果):Channel。
  • 二进制数据:ipc::Response 或 Channel<InvokeResponseBody> 发 Raw,不经过 JSON。
  • 事件留给小的通知:「数据变了」「任务完成」,接到通知后前端再用命令去取数据。
  • 必须用事件时,把数据切小(Channel 里 160 个约 64 KiB 的事件共 216 毫秒),并用 emit_to 只发给需要的窗口。
📝NOTE
没有蠢问题

问: 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;事件只发小通知。

💬 关联讨论

发起讨论
加载中...