背景:同一篇,百万行透视表。前两篇讲了应用层(砍 30 万 Proxy)和存储层(132MB → 25MB 列式)。这篇讲线程层------Worker 常驻 + Transferable 零拷贝,把数据传输从每次 559ms 的 structuredClone 砍到几乎为零。
本文会讲清楚 postMessage 的 Structured Clone 规范、ArrayBuffer 的 transfer 机制、以及为什么"用完立即释放 Worker"是反优化。
这是这个系列的四篇里的第三篇,讲的是线程层------Worker 常驻加零拷贝传输。前两篇是应用层和存储层,后面还有布局层一篇。
一、先看一个被误解的图
很多人画这样一张优化图:
优化前:主线程干所有事 → 卡
优化后:把计算丢给 Worker → 不卡
对一半。Worker 确实解决了"计算阻塞 UI"的问题,但数据传输本身也是阻塞的。
看 postMessage 这一段:postMessage 把数据从主线程复制到 Worker,是在主线程同步执行的。30 万行数据克隆一次约 559ms,这段时间主线程不能做其他事。
所以优化的核心不是"把计算挪到 Worker",而是"让数据传输成本降低",比如:只传一次 或 只传必要数据 等等。
二、Worker 创建成本:为什么不能"用完就关"
先回答一个经常被问到的问题:Worker 用完立即释放(terminate),不是更省内存吗?
答案是:立即释放恰恰是反优化。
new MyWorker() 不是轻量操作。浏览器收到创建请求后要做四件事:
| 步骤 | 说明 | 耗时 |
|---|---|---|
| 1. 新建 OS 线程 | 操作系统层面创建线程 | ~ms 级 |
| 2. 新建 V8 Isolate | 独立 JS 堆,与主线程完全隔离 | |
| 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 要服务整个组件生命周期的维度切换、拖拽、筛选,可能调用几十上百次。这是典型的"创建贵、使用频繁"场景,正合适常驻。
用一张图来看这个过程的成本:
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 实例没有任何意义。
用图来看这个隔离:
3.2 Structured Clone 的实际流程
scss
postMessage(msg) = serialize(msg) → transfer_bytes → deserialize
serialize 递归遍历 msg 的对象图,对每个对象:
- 分配新对象
- 建隐藏类(transition)
- 逐属性递归序列化
代价 = 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 时,浏览器做的不是"复制字节",而是:
- 把源 ArrayBuffer 的
[[ArrayBufferData]]指针和[[ArrayBufferByteLength]]复制到目标线程的克隆对象上 - 把源 ArrayBuffer 标记为 detached(
byteLength变 0,后续通过原 view 访问会抛 TypeError 或读到 0) - 目标线程拿到同一个底层内存块的指针
这就像把一个仓库的钥匙从一个人手里交到另一个人手里------仓库本身没动,也没有复制。代价只是"交接钥匙"这个动作,跟仓库大小无关。
4.3 transfer 后还能用吗
不能。 transfer 后主线程的 typed array view 被 detached(byteLength 变 0)。
但主线程不再需要------apiBaseData(原始对象数组,markRaw 持有)还在,明细 table 用它;Worker 拿到列式数据做后续所有聚合。这是 transfer 安全的前提:转移后发送方不再访问。
4.4 为什么不用 SharedArrayBuffer
SharedArrayBuffer(SAB)方案让主线程和 Worker 共享同一块内存,彻底消除拷贝。
预期收益:setData 从 616ms 降到 ~80ms(7.7×)。
没选的原因:
-
COOP + COEP 安全头要求 :SAB 需要
Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp。这要求服务器配置响应头,开发环境(vite)和生产环境(Nginx)都要配。Electron 环境默认支持,但 H5 环境需要额外配置。 -
复杂度增加 :SAB 方案需要序列化器(
sabSerializer.js)+ SAB 版 Worker(dataWorker.sab.js),代码量比 transfer 多约 200 行。 -
transfer 已满足需求:transfer 已将 setData 从 616ms 降到 ~0ms(pack 99ms + transfer 0ms),初始化总耗时从 27.4s 降到 2.9s。
-
降级路径清晰 :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内联,避免了动态加载的额外网络开销。 - 没有做数据分页:后端全量下发,前端只能全量接收。