Rust in Everything
基于 tauri 2.12.5

Channel

主线位置:调用主线的一条支路。一次 invoke 只能落定一次 Promise,Channel 让命令在返回之后还能不断往 JS 推消息;推送不走 HTTP 响应,而是像事件主线那样 eval 一段 JS,消息大时再回到调用主线发一次 fetch。本页讲它的 id、两条投递路径、顺序,以及和命令返回值、emit 比的吞吐。

sequenceDiagram
  participant J as 页面 JS
  participant W as on_message
  participant C as 命令
  participant Q as ChannelDataIpcQueue
  J->>J: new Channel 登记回调 id
  J->>W: invoke 参数里带 __CHANNEL__:id
  W->>C: CommandArg 解析出 Channel
  C->>J: 小消息 eval runCallback
  C->>Q: 大消息 存入队列 拿到 data id
  C->>J: eval 一段 invoke fetch
  J->>W: fetch 带 Tauri-Channel-Id
  W->>Q: 按 webview 和 id 取出
  Q-->>J: 响应正文 再 runCallback
  C->>J: Channel 释放时 eval end

本页的实验 crate 是 ipc2-app:从模板 base 复制,tauri =2.12.5、--features tauri/custom-protocol,tauri.conf.json 里 withGlobalTauri: true(页面要用 window.__TAURI__.core.Channel 和 window.__TAURI__.event.listen),能力文件给 main 窗口 core:default(listen 是插件命令,要权限)。窗口隐藏,页面按环境变量 MODE 跑一组测试,经 report 命令打印结果。平台 M2 Max、macOS 27、rustc 1.99.0。

一个 Channel 就是一个回调 id

JS 侧的 Channel 类不在 tauri 注入的初始化脚本里,在 @tauri-apps/api 的 core.ts(withGlobalTauri 时由 bundle.global.js 挂到 window.__TAURI__.core,同一份代码)。构造时调 transformCallback 登记一个不是一次性的回调,返回的数字就是 id;序列化时变成字符串 __CHANNEL__:<id>:

  constructor(onmessage?: (response: T) => void) {
    this.#onmessage = onmessage || (() => {})

    this.id = transformCallback<
      // Normal message
      | { message: T; index: number }
      // Message when the channel gets dropped in the rust side
      | { end: true; index: number }
    >((rawMessage) => {
      // ...
    })
  }
  // ...
  [SERIALIZE_TO_IPC_FN]() {
    return `__CHANNEL__:${this.id}`
  }

  toJSON(): string {
    // eslint-disable-next-line security/detect-object-injection
    return this[SERIALIZE_TO_IPC_FN]()
  }

transformCallback 就是 core.js 的 registerCallback,id 是 crypto.getRandomValues 生成的随机 u32,和 invoke 的回调 id 在同一张 callbacks 表里(见 JS 侧:invoke 与回调)。

Rust 侧,命令参数写成 Channel<T>,它的 CommandArg 实现先按 key 反序列化出一个字符串,去掉前缀 IPC_PAYLOAD_PREFIX,解析成数字,再调 JavaScriptChannelId::channel_on 绑到发起调用的 webview 上。通道 id 藏在 JSON 对象更深处时(比如结构体的一个字段),字段类型写 JavaScriptChannelId,再自己 channel_on(webview)。Channel::new(闭包) 则是另一回事:它不连 JS,id 取自全局计数器 CHANNEL_COUNTER,消息交给你的闭包,给插件和移动端用。

#[tauri::command]
fn chan_id(ch: Channel<serde_json::Value>) -> u32 {
  let rust_made = Channel::<serde_json::Value>::new(|_| Ok(()));
  println!("RUST js channel id = {}, Channel::new id = {}", ch.id(), rust_made.id());
  ch.id()
}
  const a = new C(), b = new C();
  out.jsIds = [a.id, b.id];
  out.serialized = JSON.stringify({ ch: a });
  out.rustSees = await inv('chan_id', { ch: a });
RUST js channel id = 51725631, Channel::new id = 0
REPORT {
 "mode": "chan",
 "data": {
  "jsIds": [
   51725631,
   3354913994
  ],
  "serialized": "{\"ch\":\"__CHANNEL__:51725631\"}",
  "rustSees": 51725631,

Channel 在 Rust 侧是 Arc<ChannelInner>,克隆便宜,可以存进状态或移到别的线程,命令返回之后接着发。最后一个克隆被释放时,ChannelInner 的 Drop 调 on_drop,往 JS 发一条 { end: true, index },JS 侧据此注销回调。

send:小的 eval,大的 fetch

Channel::send 先 data.body() 把消息编成 InvokeResponseBody(Serialize 类型编成 JSON,InvokeResponseBody::Raw 或 ipc::Response 保持原始字节,规则同命令返回值),再交给 channel_on 里装好的闭包:

      Box::new(move |body| {
        let current_index = counter.fetch_add(1, Ordering::Relaxed);

        if let Some(interceptor) = &webview.manager.channel_interceptor {
          if interceptor(&webview, callback_fn, current_index, &body) {
            return Ok(());
          }
        }

        match body {
          // Don't go through the fetch process if the payload is small
          InvokeResponseBody::Json(json_string)
            if json_string.len() < MAX_JSON_DIRECT_EXECUTE_THRESHOLD =>
          {
            webview.eval(format_raw_js(
              callback_id,
              format!("{{ message: {json_string}, index: {current_index} }}"),
            ))?;
          }
          InvokeResponseBody::Raw(bytes) if bytes.len() < MAX_RAW_DIRECT_EXECUTE_THRESHOLD => {
            let bytes_as_json_array = serde_json::to_string(&bytes)?;
            webview.eval(format_raw_js(callback_id, format!("{{ message: new Uint8Array({bytes_as_json_array}).buffer, index: {current_index} }}")))?;
          }
          // use the fetch API to speed up larger response payloads
          _ => {
            // the webview was closed (or replaced by a new one with the same label),
            // so nothing would ever fetch this data
            let Some(data_id) =
              webview
                .state::<ChannelDataIpcQueue>()
                .insert_if(webview.label(), body, || webview.is_registered())
            else {
              return Ok(());
            };

            webview.eval(format!(
              "window.__TAURI_INTERNALS__.invoke('{FETCH_CHANNEL_DATA_COMMAND}', null, {{ headers: {{ '{CHANNEL_ID_HEADER_NAME}': '{data_id}' }} }}).then((response) => window.__TAURI_INTERNALS__.runCallback({callback_id}, {{ message: response, index: {current_index} }})).catch(console.error)",
            ))?;
          }
        }

        Ok(())
      }),

两条路:

  • 直接 eval:拼一段 window.__TAURI_INTERNALS__.runCallback(<id>, { message: <JSON 原文>, index: <n> })(format_raw_js),消息作为 JS 字面量嵌在脚本里。原始字节先转成数字数组,再在 JS 里 new Uint8Array(..).buffer。
  • 经 fetch:消息存进 ChannelDataIpcQueue,拿到一个 data id;eval 的脚本只是一次普通的 invoke('plugin:__TAURI_CHANNEL__|fetch', null, { headers: { 'Tauri-Channel-Id': id } })。这次 invoke 走完整的调用主线,由内置插件 __TAURI_CHANNEL__ 的 fetch 命令按头里的 id 从队列取出,包成 ipc::Response 返回;JS 拿到响应正文(JSON 用 response.json()、原始字节用 arrayBuffer())后再 runCallback。

分界线是两个常量,注释写着各自的依据:

/// Maximum size a JSON we should send directly without going through the fetch process
// 8192 byte JSON payload runs roughly 2x faster through eval than through fetch on WebView2 v135
const MAX_JSON_DIRECT_EXECUTE_THRESHOLD: usize = 8192;
// 1024 byte payload runs  roughly 30% faster through eval than through fetch on macOS
const MAX_RAW_DIRECT_EXECUTE_THRESHOLD: usize = 1024;

比较的是 json_string.len() 和 bytes.len(),都是字节数,用的是 <。实验里每种大小单独发一条,用一个包装过的 window.fetch 数 URL 里含 __TAURI_CHANNEL__ 的请求(ipc-protocol.js 在调用时才查找全局 fetch,所以能数到):

#[tauri::command]
fn chan_send(ch: Channel<InvokeResponseBody>, raw: bool, len: usize) -> tauri::Result<()> {
  if raw {
    ch.send(InvokeResponseBody::Raw(vec![1u8; len]))
  } else {
    let s = json_of_len("m", len);
    assert_eq!(s.len(), len);
    ch.send(InvokeResponseBody::Json(s))
  }
}
  "threshold": [
   {
    "raw": false,
    "len": 8191,
    "received": "string 8191",
    "fetches": 0
   },
   {
    "raw": false,
    "len": 8192,
    "received": "string 8192",
    "fetches": 1
   },
   {
    "raw": true,
    "len": 1023,
    "received": "ArrayBuffer 1023",
    "fetches": 0
   },
   {
    "raw": true,
    "len": 1024,
    "received": "ArrayBuffer 1024",
    "fetches": 1
   }
  ],

JSON 文本 8191 字节(这里是一个 JSON 字符串,含两端引号)走 eval,8192 字节多了一次 fetch;原始字节的分界在 1024。

📝NOTE
冷知识:2.5.0 之前每条消息都要 fetch 一次

两个阈值是 #13136(「fix: channel callback never cleaned up from window」,首次发布于 tauri 2.5.0)加的,提交说明里的一条是「Improve channel speed on small data」。之前的 channel_on 没有 match:每条消息不论大小都存进队列,再 eval 一段 invoke(FETCH_CHANNEL_DATA_COMMAND ..),回调挂在 window['_' + id] 上。也就是说发一个数字也要一次完整的 ipc:// 往返。CHANGELOG 的两条:「Improve Channel's performance when sending small amount of data (e.g. sending a number)」和「Fix Channel's callback attached to window never cleaned up」,后者就是现在的 end 消息和 on_drop。

队列取数据的命令不经 ACL,这是 on_message 里唯一一个写死的豁免(见 on_message 与分发)。为了不让别的页面趁机取走数据,队列按 webview 标签分开存:HashMap<String, WebviewChannelDataQueue>,每个 webview 有自己从 0 开始的 next_id,fetch 只用发起请求的那个 webview 的标签去查,取一次就删;insert_if 在锁里检查 webview 还注册着,webview 关闭时 remove_webview_entries 把它的整张表丢掉。按 webview 分开的这一版是 tauri 2.12.0 的安全修复(CHANGELOG 的「Security fixes」一节,编号 GHSA-w28w-mhc8-qvjv:「Bind channel data IPC queue entries to the webview they were created for and scope their ids per webview. Queued channel payloads and large invoke responses can no longer be read by other webviews」),之前是一张全局表、全局自增 id。

同一个队列也服务 postMessage 那条 IPC 路径:在 Windows 和 Linux 上,走 postMessage 的调用如果返回 JSON 对象 / 数组或原始字节,ipc/protocol.rs 用 Channel::from_callback_fn 把响应当一条通道消息发回去,macOS 上一律 eval(只读了源码,见 ipc 协议)。

顺序:index 和 JS 侧的缓冲

两条路的到达时间不同:eval 是一次 evaluateJavaScript,fetch 那条要先 eval、再发请求、再等响应。连着发一大一小,小的会先到。每条消息因此带一个 index,来自 channel_on 里那个 AtomicUsize 计数器(同一个 Channel 的所有克隆共用它);JS 侧的 Channel 类按 index 缓冲:

      const message = rawMessage.message
      // Process the message if we're at the right order
      if (index == this.#nextMessageIndex) {
        this.#deliver(message)
        this.#nextMessageIndex += 1

        // process pending messages
        while (this.#nextMessageIndex in this.#pendingMessages) {
          const message = this.#pendingMessages[this.#nextMessageIndex]
          this.#deliver(message)
          // eslint-disable-next-line @typescript-eslint/no-array-delete
          delete this.#pendingMessages[this.#nextMessageIndex]
          this.#nextMessageIndex += 1
        }

        if (this.#nextMessageIndex === this.#messageEndIndex) {
          this.cleanupCallback()
        }
      }
      // Queue the message if we're not
      else {
        // eslint-disable-next-line security/detect-object-injection
        this.#pendingMessages[index] = message
      }

end 消息也带 index(等于已发出的条数)。它可能比前面的大消息先到,这时只记下 #messageEndIndex,等所有消息交付完再注销回调。

📝NOTE
冷知识:顺序缓冲是 2.0 beta 才有的

#9070(「feat(ipc): preserve channel message order」,首次发布于 tauri 2.0.0-beta.9,是 beta 版)给 Rust 侧加了计数器,把 eval 的脚本从 .then(window['_' + id]) 改成 .then((response) => window['_' + id]({ message: response, id: i })),JS 侧的 Channel 类开始按序号缓冲。CHANGELOG:「Added a mechanism to preserve channel message order.」当时每条消息都走 fetch,fetch 完成的先后就是交付的先后。字段名后来在 #13136 里从 id 改成了 index(提交说明「Rename id to index to match js side」)。

实验命令按给定的大小依次发 JSON 消息,每条以自己的序号开头:

#[tauri::command]
fn chan_order(ch: Channel<InvokeResponseBody>, sizes: Vec<usize>) -> tauri::Result<()> {
  for (i, len) in sizes.into_iter().enumerate() {
    ch.send(InvokeResponseBody::Json(json_of_len(&i.to_string(), len)))?;
  }
  Ok(())
}

页面调两次。第一次不用 Channel 类,自己用 transformCallback 登记一个回调、手写 __CHANNEL__:<id> 传过去,看原始的到达顺序;第二次用 Channel 类,看 onmessage 的顺序:

  const sizes = [20000, 10, 10, 20000, 10, 10, 10];
  const rawLog = [];
  // ...
  const rawId = ti.transformCallback((m) => {
    rawLog.push('end' in m ? `end@${m.index}` : `${m.index}`);
    if ('end' in m) rawDone();
  });
  await inv('chan_order', { ch: `__CHANNEL__:${rawId}`, sizes });
  // ...
  const ch2 = new C((m) => { ordered.push(m.split(':')[0]); if (ordered.length === sizes.length) orderedDone(); });
  await inv('chan_order', { ch: ch2, sizes });
❗IMPORTANT
动脑筋:sizes 是 [20000, 10, 10, 20000, 10, 10, 10],0 号和 3 号超过阈值。手写回调记下的 index 序列(包括 end)是什么样?平台 M2 Max、macOS 27,tauri 2.12.5,debug 构建。

吞吐:命令返回、Channel、emit

同样 10 MiB 数据,用三种方式送到 JS:命令的返回值、Channel、emit 事件。release 构建,每项跑 5 次(「8000 B」那项 3 次)取中位数,计时从 JS 发起 invoke 到最后一条数据被回调收到。两种数据:

  • 一个 10 MiB 的 "aaaa…" 字符串(或 10 MiB 原始字节);
  • 140598 条记录 Rec { id, name, score, tags },序列化成 JSON 共 10485764 字节,更像真实数据。

命令和页面的关键部分:

#[tauri::command]
fn big_raw(n: usize) -> Response {
  Response::new(vec![1u8; n])
}

#[tauri::command]
fn chan_big(ch: Channel<InvokeResponseBody>, n: usize, chunk: usize, raw: bool) -> tauri::Result<()> {
  let mut left = n;
  while left > 0 {
    let k = left.min(chunk);
    if raw {
      ch.send(InvokeResponseBody::Raw(vec![1u8; k]))?;
    } else {
      ch.send(InvokeResponseBody::Json(serde_json::to_string(&"a".repeat(k))?))?;
    }
    left -= k;
  }
  Ok(())
}

#[tauri::command]
fn emit_big(app: AppHandle, n: usize, chunk: usize) -> tauri::Result<()> {
  let mut left = n;
  while left > 0 {
    let k = left.min(chunk);
    app.emit("big", "a".repeat(k))?;
    left -= k;
  }
  Ok(())
}

#[tauri::command]
fn rec_return() -> Vec<Rec> {
  records().clone()
}

#[tauri::command]
fn rec_channel(ch: Channel<Vec<Rec>>, chunk: usize) -> tauri::Result<()> {
  for c in records().chunks(chunk) {
    ch.send(c.to_vec())?;
  }
  Ok(())
}

#[tauri::command]
fn rec_emit(app: AppHandle, chunk: usize) -> tauri::Result<()> {
  for c in records().chunks(chunk) {
    app.emit("recs", c)?;
  }
  Ok(())
}
async function recEmit(total, chunk) {
  let got = 0, msgs = 0, done;
  const p = new Promise(r => { done = r; });
  const un = await window.__TAURI__.event.listen('recs', (e) => {
    got += e.payload.length; msgs++;
    if (got >= total) done(`${got} recs in ${msgs} msgs`);
  });
  await inv('rec_emit', { chunk });
  const r = await p;
  un();
  return r;
}

结果(中位数,毫秒;完整输出含最小值、最大值和收到的条数,存在实验目录的 ipc2-app-bench-release.txt、ipc2-app-records-release.txt):

方式10 MiB "aaa…" / 原始字节10 MiB 记录
命令返回 String(JSON)10—
命令返回 Vec<Rec>—53
命令返回 ipc::Response(原始字节)3—
Channel 原始字节,1 条3—
Channel 原始字节,每条 64 KiB,160 条16—
Channel JSON,1 条952
Channel JSON,每条 64 KiB(记录为 879 条一组),160 条2158
Channel JSON,每条 8000 字节,1311 条(全走 eval)21—
emit,1 个事件102401
emit,每个 64 KiB(记录为 879 条一组),160 个8216

几个结论,都只对 macOS 上的 WKWebView 测过:

  • 全是 a 的字符串测不出差别。 10 MiB 的字符串字面量,不管是 eval 进去还是 fetch 回来,WebKit 都在 10 毫秒左右处理完,emit 和 Channel 看起来一样快。
  • 结构化数据上 emit 慢一个数量级以上。 原因在事件主线的最后一步(emit 的整体流程见 AppManager 与 Manager trait):emit 把 payload serde_json::to_string 之后,emit_js_script 把它原样拼进一段 JS 源码(fn({event: '..', payload: <JSON>}, ids)),WebKit 要把 10 MiB 的对象字面量当 JS 代码解析。Channel 超过阈值时走 fetch,JSON 由 response.json() 解析;命令返回值也是 response.json()。同样 10485764 字节,前者 2.4 秒,后两者 50 毫秒出头。把 emit 拆成 160 个小事件降到 216 毫秒,仍是 Channel 的 4 倍。
  • 命令返回值和单条 Channel 消息基本一样快,因为大消息的 Channel 走的就是一次命令返回(fetch 命令返回 ipc::Response)。Channel 的价值是「命令返回之后还能发」和「分多次发」,不是更快的管道。
  • 原始字节最快,ipc::Response 和原始字节的 Channel 都是 3 毫秒,没有 JSON 编码和解析。
  • 切成很多小消息有固定开销:原始字节从 1 条 3 毫秒到 160 条 16 毫秒;JSON 每条 8000 字节、1311 次 eval 是 21 毫秒,和 160 次 fetch 差不多。

所以「用事件传大数据很慢」要说准确:慢的是把大的结构化 payload 塞进一个事件,代价在 JS 引擎解析源码;要传大数据、尤其要分批传,用 Channel,能用原始字节就用原始字节。

动脑筋答案

  "rawArrival": [
   "1",
   "2",
   "4",
   "5",
   "6",
   "end@7",
   "0",
   "3"
  ],
  "channelOnmessage": [
   "0",
   "1",
   "2",
   "3",
   "4",
   "5",
   "6"
  ],
  • 小消息 1、2、4、5、6 按发送顺序先到:命令在主线程上同步执行,每条小消息是一次 eval,eval 按调用顺序进入页面。
  • end@7 也在 0 和 3 之前:命令返回时 ch 被释放,on_drop 立即 eval 一条 { end: true, index: 7 },而 0 和 3 还在等各自的 fetch。
  • 0 和 3 最后到。它们各自的 eval 脚本只是发起一次 invoke(fetch),这次 invoke 本身要进主线程上的 ipc:// 处理函数,而主线程正忙着跑 chan_order;等命令返回、主线程空出来,两次 fetch 才被处理。
  • 用 Channel 类时,onmessage 收到的是 0 到 6,因为它把早到的 1、2 等按 index 缓冲起来,等 0 到了才依次交付;end 的 index 7 等到 6 交付后才触发注销。手写回调没有这层缓冲,看到的就是真实到达顺序。

小结

  • Channel 是 JS 侧一个常驻的回调 id,序列化成 __CHANNEL__:<id> 作为普通参数传给命令;Rust 侧 CommandArg 把它解析回来、绑到发起调用的 webview。Channel::new 是不连 JS 的另一种,id 来自全局计数器。
  • send 编码后分两路:JSON 不到 8192 字节、原始字节不到 1024 字节时直接 eval runCallback;更大的存进按 webview 隔开的队列,eval 一段 invoke('plugin:__TAURI_CHANNEL__|fetch') 让 JS 取回。取数据的命令是 ACL 唯一的硬编码豁免。
  • 每条消息带 index,JS 的 Channel 类按序交付;大消息和 end 消息都可能比后面的小消息晚到或早到,绕过 Channel 类自己接回调就得自己排序。
  • 10 MiB 结构化数据:命令返回和 Channel 约 50 毫秒,单个 emit 2.4 秒(payload 被当成 JS 源码解析);原始字节约 3 毫秒。

💬 关联讨论

发起讨论
加载中...