Worker 常驻 + 零拷贝:postMessage 的结构化克隆算法与 Transferable 的真实代价

背景:同一篇,百万行透视表。前两篇讲了应用层(砍 30 万 Proxy)和存储层(132MB → 25MB 列式)。这篇讲线程层------Worker 常驻 + Transferable 零拷贝,把数据传输从每次 559ms 的 structuredClone 砍到几乎为零。

本文会讲清楚 postMessage 的 Structured Clone 规范、ArrayBuffer 的 transfer 机制、以及为什么"用完立即释放 Worker"是反优化。

这是这个系列的四篇里的第三篇,讲的是线程层------Worker 常驻加零拷贝传输。前两篇是应用层和存储层,后面还有布局层一篇。


一、先看一个被误解的图

很多人画这样一张优化图:

复制代码
优化前:主线程干所有事 → 卡
优化后:把计算丢给 Worker → 不卡

对一半。Worker 确实解决了"计算阻塞 UI"的问题,但数据传输本身也是阻塞的

graph LR A[主线程 axios GET] --> B[主线程 groupBy] --> C[主线程 S2 渲染] D[主线程 axios GET] --> E[postMessage structuredClone] --> F[Worker groupBy] --> G[回传结果]

看 postMessage 这一段:postMessage 把数据从主线程复制到 Worker,是在主线程同步执行的。30 万行数据克隆一次约 559ms,这段时间主线程不能做其他事。

所以优化的核心不是"把计算挪到 Worker",而是"让数据传输成本降低",比如:只传一次 或 只传必要数据 等等。


二、Worker 创建成本:为什么不能"用完就关"

先回答一个经常被问到的问题:Worker 用完立即释放(terminate),不是更省内存吗?

答案是:立即释放恰恰是反优化

new MyWorker() 不是轻量操作。浏览器收到创建请求后要做四件事:

步骤 说明 耗时
1. 新建 OS 线程 操作系统层面创建线程 ~ms 级
2. 新建 V8 Isolate 独立 JS 堆,与主线程完全隔离 1030ms
3. 加载脚本 获取并解析 Worker JS 文件 ~ms 级
4. 编译执行 V8 JIT 编译 ~ms 级

简单 Worker 约 10~50ms,带复杂逻辑的更贵。而且这个成本每次创建都付。

2.1 为什么 Worker 的堆不走 JS GC

关键点:Worker 线程和 V8 Isolate 是浏览器独立线程,不走 JS 的垃圾回收 。它由浏览器的 Worker 管理器管理,只在调用 terminate() 或页面关闭时释放。

这里有一个深层原理:V8 的 GC 是针对 Isolate 内的对象图设计的。Worker 线程有自己的 V8 Isolate,有自己的内存堆、执行栈和GC机制,主线程的 GC 根本看不到它。所以"不用了就 terminate"不会帮主线程的 JS 堆减负------它只是让浏览器的 Worker 管理器回收一个独立的线程和 Isolate。而已经传进去的数据,如果还没被 Worker 处理完,terminate 会直接丢弃。

2.2 两种模式的对比

scss 复制代码
常驻模式(当前实现):
  new Worker()              ← 付一次创建成本(~30ms)
  setData(100万行)          ← 付一次传输成本(transfer 后 ~0ms)
  groupAndSum               ← 0ms(Worker 已有数据)
  groupAndSum               ← 0ms
  ...(几十次切换)
  组件销毁时 terminate      ← 释放

立即释放模式:
  new Worker() → setData → groupAndSum → terminate   ← 每次都付:创建 + 传输 + 销毁
  new Worker() → setData → groupAndSum → terminate
  new Worker() → setData → groupAndSum → terminate

立即释放 = 创建成本 × N + 传输成本 × N 常驻 = 创建成本 × 1 + 传输成本 × 1

本场景中 Worker 要服务整个组件生命周期的维度切换、拖拽、筛选,可能调用几十上百次。这是典型的"创建贵、使用频繁"场景,正合适常驻。

用一张图来看这个过程的成本:

graph LR A[new Worker 创建成本] --> B[setData transfer 后] --> C[groupAndSum N 次 每次 0ms] --> D[terminate] E[new Worker setData groupAndSum terminate] --> F[new Worker setData groupAndSum terminate] --> G[new Worker ...] --> H[创建成本 x N 传输成本 x N]

2.3 为什么"Worker 无状态"是直觉误区

很多人把 Worker 当成 RPC endpoint------调一次就走。但 Worker 的有状态性正是它的价值:它是一个有内存的工作线程。充分利用它的堆,才能避免反复搬运。

数据对 groupBy 操作具有引用透明性------同样的数据配不同的 dims,数据部分不变。所以数据只需要进 Worker 的堆一次。


三、Structured Clone:postMessage 默认在干什么

要理解 transfer,先理解默认行为。

js 复制代码
worker.postMessage({ type: 'setData', data: 30万行对象数组 });

默认情况下,postMessage 执行 Structured Clone 算法

3.1 根因:两个 Isolate,地址空间互不相干

主线程和 Worker 是两个独立的 V8 Isolate,各自有自己的堆,地址空间互不相干。这是 postMessage 必须复制数据的根本原因------不是浏览器"选择"复制,而是物理上没办法传指针。主线程的堆地址对 Worker 的 V8 实例没有任何意义。

用图来看这个隔离:

graph LR A[apiBaseData 对象数组] --> B[postMessage] C[postMessage 接收] --> D[Structured Clone 重建对象图] B --> E[序列化 字节流] --> C

3.2 Structured Clone 的实际流程

scss 复制代码
postMessage(msg) = serialize(msg) → transfer_bytes → deserialize

serialize 递归遍历 msg 的对象图,对每个对象:

  1. 分配新对象
  2. 建隐藏类(transition)
  3. 逐属性递归序列化

代价 = O(对象总数 + 字符串总长 + 数字总数)。

30 万行 × 17 字段 = 510 万个属性需要序列化。实测约 559ms,而且在主线程同步执行,期间 UI 冻结。

3.3 为什么 Structured Clone 特别慢

Structured Clone 的慢不是"复制字节"那么简单。它要做的是在目标线程重建一个等价的对象图,这意味着:

  • 每个对象都要分配新内存块
  • 每个对象都要建立隐藏类(V8 的 transition tree 需要走一遍)
  • 每个字符串都要分配新内存、复制 UTF-16 字节
  • 每个数字都要复制 8 字节
  • 还要处理循环引用(维护 SharedValueMap 去重)

对比一下:普通内存复制(如 memcpy)是 O(字节数) 的裸操作。Structured Clone 是 O(对象数 + 字符串总长) 的语义级重建,中间夹杂着分配、隐藏类构建、哈希计算。对于 30 万行 × 17 字段的对象数组,这相当于在主线程上同步做一次完整的"对象图重建"。

3.4 隐蔽限制

Structured Clone 还有一个限制:不能克隆函数、DOM 节点、闭包变量。所以 Worker 通信只能传纯数据。这对我们的场景不是问题(只传数据和字段名),但意味着如果未来需要传复杂对象,这条路走不通。


四、Transferable Objects:零拷贝是怎么做的

Transferable Objects 规范提供了另一种方式:

js 复制代码
worker.postMessage(message, transferList);

transferList 是一个 ArrayBuffer 数组。 transfer 的语义是:

scss 复制代码
Transferable 模式:
postMessage(msg, transferList) = serialize(msg \ transferList) → transfer(transferList) → deserialize

transfer 对 transferList 中的每个 ArrayBuffer:移动 [[ArrayBufferData]] 指针和 [[ArrayBufferByteLength]],标记源为 detached

代价 = O(transferList 长度),与 buffer 大小无关。

4.1 为什么 ArrayBuffer 可以 transfer

ArrayBuffer 是固定长度的裸内存块:

  • 没有内部结构(没有属性、没有原型链)
  • 没有引用关系(不指向其他对象)
  • 没有隐藏类

所以可以安全地把底层内存指针从一个线程挪到另一个线程,不会破坏任何东西。

普通 JS 对象不行------有原型链、有交叉引用、有隐藏类,挪走就坏了。

4.2 transfer 的底层机制

[[ArrayBufferData]] 是 ArrayBuffer 内部对底层内存块的引用(一个原生指针)。transfer 时,浏览器做的不是"复制字节",而是:

  1. 把源 ArrayBuffer 的 [[ArrayBufferData]] 指针和 [[ArrayBufferByteLength]] 复制到目标线程的克隆对象上
  2. 把源 ArrayBuffer 标记为 detached(byteLength 变 0,后续通过原 view 访问会抛 TypeError 或读到 0)
  3. 目标线程拿到同一个底层内存块的指针

这就像把一个仓库的钥匙从一个人手里交到另一个人手里------仓库本身没动,也没有复制。代价只是"交接钥匙"这个动作,跟仓库大小无关。

4.3 transfer 后还能用吗

不能。 transfer 后主线程的 typed array view 被 detached(byteLength 变 0)。

但主线程不再需要------apiBaseData(原始对象数组,markRaw 持有)还在,明细 table 用它;Worker 拿到列式数据做后续所有聚合。这是 transfer 安全的前提:转移后发送方不再访问

graph LR A[原始对象数组 apiBaseData] --> B[packColumnar 产出 typed array] B --> C[postMessage] C --> D[buffer 指针转移到 Worker 主线程 view detached] D --> E[Float64Array Uint32Array 直接操作裸内存] E --> F[groupBy sum 零堆分配]

4.4 为什么不用 SharedArrayBuffer

SharedArrayBuffer(SAB)方案让主线程和 Worker 共享同一块内存,彻底消除拷贝。

预期收益:setData 从 616ms 降到 ~80ms(7.7×)。

没选的原因

  1. COOP + COEP 安全头要求 :SAB 需要 Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp。这要求服务器配置响应头,开发环境(vite)和生产环境(Nginx)都要配。Electron 环境默认支持,但 H5 环境需要额外配置。

  2. 复杂度增加 :SAB 方案需要序列化器(sabSerializer.js)+ SAB 版 Worker(dataWorker.sab.js),代码量比 transfer 多约 200 行。

  3. transfer 已满足需求:transfer 已将 setData 从 616ms 降到 ~0ms(pack 99ms + transfer 0ms),初始化总耗时从 27.4s 降到 2.9s。

  4. 降级路径清晰 :SAB 方案有自动降级到 transfer 的路径(isSABSupported() 检测),但多一套代码就多一个维护成本。

建议:先用 transfer 上线(已实现,零风险),观察是否满足性能要求。如果初始化仍不可接受,再上 SAB。


五、完整实现路径

5.1 主线程:打包 + transfer

js 复制代码
// Sheet.vue
import { packColumnar, collectTransferList } from "./columnar.js";

function sendSetData(data, fields, dims, sums) {
  dataVersion++;
  workerDataLoaded = false;

  const packed = packColumnar(data, fields, dims, sums);
  const transferList = collectTransferList(packed);

  worker.value.postMessage({
    type: "setData",
    dataVersion,
    columns: packed.columns,
    n: packed.n,
    fields,
  }, transferList);
}

5.2 Worker:保持列式不反序列化

js 复制代码
// dataWorker.js
let columnarData = null;  // 常驻,不释放

self.onmessage = (e) => {
  if (e.data.type === "setData") {
    columnarData = { columns: e.data.columns, n: e.data.n };
    // 预计算唯一值(从字典直接提取)
    // ...
  }
  if (e.data.type === "groupAndSum") {
    const result = doGroupAndSumColumnar(columnarData, dims, sums);
    self.postMessage({ type: "groupAndSum", dataVersion, requestId, result });
  }
};

Worker 拿到的是 ArrayBuffer 指针(不是复制的字节),直接在 typed array 上做 groupBy + sum,不反序列化成 JS 对象。

5.3 效果

操作 优化前(structuredClone) 优化后(transfer)
30 万行传输 559ms(同步阻塞主线程) ~0ms(指针移动)
groupBy+sum 321ms(lodash 双遍) 222ms(列式单遍)
初始化总耗时 27.4s 2.9s

六、Worker 常驻的真实内存成本

常驻 Worker 的内存占用是固定的:列式数据约 25MB @ 100 万行。

但这笔内存本来就要花------数据在主线程也有一份(apiBaseData),Worker 里是另一份列式副本。优化空间不在"早点释放",而在:

优化手段 效果 状态
transfer 零拷贝 Worker 拿到的是 ArrayBuffer 指针转移,不是复制字节 ✅ 已实现
字典编码压缩 100 万行从 132MB 对象数组压到 25MB 连续内存 ✅ 已实现
不重复传输 setData 只一次,后续只发几个字符串 ✅ 已实现

6.1 内存账

  • 主线程:apiBaseData 原始对象数组约 132MB(markRaw,不进响应式)
  • Worker:列式数据约 25MB(transfer 过来的 typed array)
  • 合计:约 157MB

如果不用常驻方案,每次切换都要在主线程 structuredClone 一份 132MB 的临时对象数组,虽然 GC 会回收,但峰值内存会更高,而且 clone 本身是 CPU 开销。常驻方案用 25MB 的固定额外占用,换来了零拷贝的维度切换。


七、这一层没有做的事

  • 没有用 Atomics 做同步:Worker 和主线程不共享内存(transfer 方案),不需要原子操作。如果上了 SAB 方案,需要 Atomics 避免数据竞争。
  • 没有用 importScripts 动态加载 :Worker 脚本在 build 时通过 ?worker&inline 内联,避免了动态加载的额外网络开销。
  • 没有做数据分页:后端全量下发,前端只能全量接收。
相关推荐
JunjunZ1 小时前
Naive UI 虚拟级联选择器适配 Element Plus 风格
前端·javascript·vue.js
ynchyong2 小时前
微信小程序启动顺序遇到的一个坑
前端·微信小程序
用户233376852182 小时前
接口卡死排查实录-缺失return的UB死循环
前端·后端
光影少年2 小时前
如何实现RN 多环境、多渠道打包
前端·react native·react.js
闲坐含香咀翠3 小时前
百万行数据透视表,我是怎么把 Vue 响应式开销砍到零的
前端·vue.js·性能优化
xiaopang3 小时前
小红书小组件(miniwidget)开发实战:单页面viewState切换架构
前端
labixiong3 小时前
告别scroll 监听:CSS 滚动驱动动画,主线程堵死也仍跟手
前端·css·html
云析赢指标公式网43 小时前
文华WH6布林轨道均线强弱共振指标公式
前端·算法
许彰午3 小时前
34-安全复盘96个问题
前端·vue.js·安全