Worker 与 OffscreenCanvas

一、技术难点

Day31 的结尾留了一句:「Day30 管传输,Day31 管该传多少,Day32 管传到了之后在哪个线程算」。

这一篇就是那句话的展开。而它有三个流传极广的「常识」,我都是在把它们写成断言、再用脚本打脸之后,才敢往文章里写:

  1. 「postMessage 传的是引用」 ------错,而且错得离谱。默认路径是结构化克隆 :一块 64MB 的 buffer 往来一次要付 56.14ms (去程一次克隆、回程再克隆一次,单程约 28ms)。同一块 buffer 换成所有权转移 (transfer),同样的往返只要 0.18ms ------差 312 倍。而这两条路的行为差异(源 buffer 会不会被抽干、回来的还是不是原来那个对象)没有任何编译期检查,也没有任何运行时警告。
  2. 「搬到 worker 里就快了」 ------计算量一样时,总墙钟一点没变 :主线程一口气跑 610.6ms ,搬到 worker 里跑 611.4ms 。同理,同一套 12000 个图元的场景,主线程 canvas 每帧 13.62ms ,worker 里的 OffscreenCanvas 每帧 13.86ms ------绘制成本一分钱没省。搬走的不是算力,是**「这次占用发生在哪个线程」**。
  3. 「worker 越多越快」 ------8 个 worker 跑 8 个纯算术任务,加速比 7.80 倍 (效率 0.97,几乎线性);换成 8 个素数筛任务(每任务要写 16MB),加速比 2.64 倍 ,效率掉到 0.33 ,单任务耗时从 72.2ms 涨到 250.1ms(3.33 倍劣化) 。同一台机器、同样 8 个 worker,一个接近线性一个几乎不涨------天花板不是核心数,是内存带宽。

还有三条「经验」也在实测里翻了车:

  • 「structuredClone 比 JSON 快」 :5 万条记录的数组,structuredClone 40.87ms ,JSON.parse(JSON.stringify()) 16.39ms ------JSON 快 2.5 倍 。而把同样的数据换成 SoA 的四个 TypedArray 直接转移,0.885ms ,比 JSON 快 18.5 倍 、比 structuredClone 快 46 倍。
  • 「用切片代替 worker 就够了」 :setTimeout(0) 切片确实做到 0 掉帧,但墙钟从 610.6ms 涨到 1174.4ms(+92%) ;换成 scheduler.yield() 保住吞吐(614.1ms),却换来 105.05ms 的最大帧间隔和 29 个错过帧。两条路都不等价于 worker。
  • 「SharedArrayBuffer 要开 flag」 :现在不需要 flag,它要的是两个响应头 。同一份代码,有 COOP/COEP 时 new SharedArrayBuffer(8) 正常,抽掉这两个头之后直接 ReferenceError(实测对照组)。

本篇所有数字都来自 5 组真实浏览器基准 + 1 组隔离对照 ,共 25 条断言 (其中 2 条在两个场景上各跑一次,实际执行 27 次检查 ),跑在真实 Chrome 上(CDP 驱动,零依赖),失败即 exit 1。全部是实测,没有模型推演------这一篇比 Day31 幸运,因为线程与画布的行为都能在浏览器里直接量到。边界在文末「运行环境」逐条写清。

难点一:postMessage 有三条所有权语义,而它们长得一模一样

同样一行 worker.postMessage(data),可能是三种完全不同的搬运方式,代价差三个数量级:

语义 写法 往返拷贝 数据在哪 64MB 实测(往返中位)
结构化克隆 postMessage(data) 2 份 两边各一份,互不影响 56.14ms
所有权转移 postMessage(data, [buf]) 0 份 搬到对面,原处分离 0.18ms
共享内存 postMessage(sab) 0 份 同一块物理内存 0.015ms

完整刻度(同一块 Uint8Array,往返一次,med of N):

字节 clone ms transfer ms SAB ms clone 吞吐 MB/s clone/transfer
1KB 0.015 0.02 0.015 68.3 0.8
16KB 0.03 0.015 0.01 546.1 2.0
256KB 0.125 0.015 0.015 2097.2 8.3
1MB 0.625 0.02 0.015 1677.7 31.3
4MB 2.205 0.02 0.015 1902.2 110.3
16MB 11.735 0.03 0.02 1429.7 391.2
64MB 56.14 0.18 0.015 1195.4 311.9

这张表要分三段读,每一段对应一种错误的直觉:

  1. 1KB 以下的三条通道全是 15µs ------比不出任何差异,因为此时开销不在数据上,在消息调度上。实测的这条「地板」直接否掉了「把任务切碎成小消息更灵活」的做法:15µs ≈ 每秒 6.6 万条消息的硬上限,切得越碎,越多时间花在调度而不是计算上。
  2. 256KB~16MB 是 transfer 的绝对主场 :clone 线性涨(12002100MB/s,注意这不是常数,见下),transfer 稳在 0.015 0.03ms 不动。搬运一份数据的成本,和「数据有多大」在 transfer 通道上是无关的------这才是「零拷贝」的真实含义。
  3. 64MB 时 transfer 也涨到 0.18ms (此前 0.02~0.03),clone 的吞吐同时从 1429.7 掉到 1195.4 MB/s。原因不在协议,在内存子系统:页表、内存映射与 GC 记账在大对象上开始显形。「零拷贝」不等于「零成本」,它只是把成本从「与字节数成正比」压成了「与页数弱相关」。

clone 的吞吐也不是常数------1KB 时 68.3MB/s(被 15µs 地板支配),256KB4MB 稳定在 1700 2100MB/s,64MB 掉到 1195MB/s。任何「postMessage 大约 1GB/s」的经验值,只在特定体积区间成立。

三个坑,全部实测确认(7 个尺寸的流转检查 100% 命中):

坑一:transfer 出去的视图永久分离,回来的不是原来那个对象。

js 复制代码
// 实测:7 个尺寸(1KB → 64MB)全部满足
const sent = new Uint8Array(1024);
const r = await rtt(worker, { payload: sent, transferBack: true }, [sent.buffer]);
sent.byteLength;            // 0        ------ 发出去的视图已分离,不能再用
r.data.payload === sent;    // false    ------ 回来的是新对象,哪怕只是转了一圈

这条「往返后换了个身份」的性质,直接决定了两件事:

  • 缓冲池不能只按尺寸复用 。想做到稳态零分配,必须把回包里的新 buffer 当作下一轮的输入(ping-pong):
js 复制代码
// 稳态零分配的关键:把回包里的 buffer 接过来继续用
let cur = new Uint8Array(size).fill(2);
for (let i = 0; i < iters; i++) {
  const sent = cur;
  const r = await rtt(worker, { cmd: 'ping', payload: sent, transferBack: true }, [sent.buffer]);
  cur = r.data.payload;   // 不是 sent!sent 已经是 0 字节的空壳
}
  • 任何持有该 buffer 的旧引用都会变成「0 字节尸体」。项目中「transfer 之后 length 变 0」的诡异 bug,全部源于此。

坑二:转移列表里只能放 buffer,不能放视图。

js 复制代码
const u8 = new Uint8Array(1024);
port.postMessage(u8, [u8]);              // THROW DataCloneError(实测)
port.postMessage(u8, [u8.buffer]);       // 正确:放 buffer,视图也会被一起带走

这条错误的杀伤力在于「一半对一半错」:postMessage(u8) 不传转移列表是完全合法的(走克隆),只有把视图 塞进转移列表才炸。所以代码里必须有一个统一的摊平函数,把嵌套结构里的所有 ArrayBuffer / ArrayBufferView 找出来:

js 复制代码
export function transferListOf(...values) {
  const out = [];
  const walk = (v, depth = 0) => {
    if (!v || depth > 6) return;
    if (v instanceof ArrayBuffer) out.push(v);
    else if (ArrayBuffer.isView(v)) out.push(v.buffer);      // 视图 → 它的 buffer
    else if (Array.isArray(v)) for (const x of v) walk(x, depth + 1);
    else if (typeof v === 'object') for (const k of Object.keys(v)) walk(v[k], depth + 1);
  };
  for (const v of values) walk(v);
  return [...new Set(out)];   // 同一个 buffer 出现两次 → DataCloneError
}

「去重」这行不是洁癖:实测同一个 buffer 在转移列表里出现两次,同样抛 DataCloneError。

坑三:一个 buffer 只能被转移一次。

js 复制代码
const b = new ArrayBuffer(1024);
port1.postMessage(b, [b]);
port2.postMessage(b, [b]);   // THROW DataCloneError(实测)

而 SharedArrayBuffer 恰好相反:它不能被转移 (转移列表里塞 SAB 直接抛 DataCloneError),因为它不需要转移------克隆之后仍是同一块内存:

js 复制代码
const sab = new SharedArrayBuffer(8);
const copy = structuredClone(sab);
new Uint8Array(copy)[0] = 42;
new Uint8Array(sab)[0];      // 42 ------ 「拷贝」是假象,它是引用同一块内存

克隆的语义边界也一并固化成断言(左列是行为,右列是实测结果):

探针 实测 含义
克隆函数 THROW DataCloneError 函数不能过线程边界
克隆 DOM 节点 THROW DataCloneError DOM 只属于主线程
克隆类实例 false/undefined 原型丢了 :instanceof 为 false,方法变 undefined
Map / Set / Date / RegExp 1/true/true/true 这些内建类型被正确重建
克隆 Error true/TypeError/boom 类型、name、message 都保留
Symbol 作键 ["keep"] Symbol 键被静默丢弃,只有字符串键留下
循环引用 true(c.self === c) 环在副本内部闭合,不指回原对象
TypedArray 视图 Float32Array/4/2/16 offset=4、length=2、底层 buffer=16,视图几何完整保留
转移两次 THROW DataCloneError 一次性所有权
转移 SAB THROW DataCloneError SAB 不需要转移

「类实例丢原型」这条最阴险:DTO 传过去之后 instanceof 悄悄变 false,方法调用才炸,而错误现场离病因可能隔了几百行。

难点二:搬走计算不减少总耗时,改变的是「单次占用时长」与「每帧间隙」

我把同一份计算量(24 批 × 约 25ms ≈ 600ms)用五种姿势各跑一遍,同时记录主线程的帧间隔与长任务:

姿势 墙钟 ms 最大帧间隔 ms p95 帧间隔 ms 错过帧数 TBT ms
main_block 一口气跑完 610.6 611.45 16.74 36 561
main_batch_yield 每批之间 setTimeout(0) 713.6 40.18 29.57 23 0
main_chunked 每批再切 5ms 片 1174.4 21.83 19.17 0 0
main_chunked_yield 切片 + scheduler.yield() 614.1 105.05 105.05 29 0
worker 全丢给 worker 611.4 23.46 16.77 0 0

「错过帧数」= 每个 >16.67ms 的间隔折算成少画了几帧(Σ max(0, round(gap/16.67) − 1)),这个口径比「掉帧次数」诚实得多:main_block 只掉「1 次」,但实际少画了 36 帧。

四个结论,每一个都反直觉:

① worker 没有让总时间变快,一毫秒都没有。 610.6ms → 611.4ms。它的全部价值在最后两列:最大帧间隔 611.45 → 23.46ms,错过帧 36 → 0,TBT 561 → 0。你要的从来不是「更快的 CPU」,而是「没有任何一次 JS 占用超过 16.7ms」。

② setTimeout(0) 切片是拿吞吐换帧率,汇率极差:+92% 墙钟。 600ms 的计算量,切碎之后跑了 1174.4ms。原因很朴素:24 批 × 5 片 = 120 次让出,每次最少被 setTimeout 的 4ms 下限拖住------让出本身比计算还贵 。所以「切片代替 worker」在一个 600ms 的总量上就已经不成立;如果一个页面每天都在做这种量级的计算,切片方案的隐性成本是 把用户等待时间翻倍。

③ scheduler.yield() 保住了吞吐,却把帧率打穿了。 614.1ms 几乎等于理想值(610.6ms),但最大帧间隔 105.05ms 、错过 29 帧 ------比不切片的 main_block 略好一点,比 setTimeout 切片差得多。原因是 yield() 让给的是同优先级的任务队列 而不是渲染:当队列里永远有下一片工作要跑时,渲染被无限期后推,于是出现「CPU 一直很忙、画面一直不动」这种最难解释的卡顿形态。这条是我在做这个实验之前完全预期错的 ------我以为 scheduler.yield() 是切片的正确姿势,实测它是「吞吐版的假切片」。

④ 只有 worker 同时吃下吞吐与帧率。 611.4ms / 0 错过帧 / TBT 0。代价是:启动 4.9~5.6ms/worker、每个 worker 一份独立堆(见难点四)、以及「数据要过边界」的全部复杂度。

再补一刀,把「worker 是万灵药」也否掉:如果单次占用本来就只有 3~4ms,把它搬走几乎什么也买不到。 同一个 OffscreenCanvas 场景,缩到每帧 3000 个图元时,主线程 canvas 每帧 3.675ms 、主线程响应延迟中位 4.41ms ------此时 worker 版本的主线程延迟是 0.04ms ,改善看起来很夸张(110 倍),但用户感知不到差别:两者都是 0 错过帧、都是 60fps。收益只有在「单次绘制成本逼近或超过帧预算」之后才出现:

场景 每帧绘制 主线程响应中位 p95 主线程帧间隔 错过帧 fps
3000 图元 · 主线程 canvas 3.675 4.41 4.84 16.66 1 59.9
3000 图元 · worker canvas 3.355 0.04 0.05 16.66 1 ---
12000 图元 · 主线程 canvas 13.62 17.07 21.37 17.11 3 54.6
12000 图元 · worker canvas 13.86 0.03 0.04 16.68 1 ---

判据就在这里 :绘制成本从 3.675 涨到 13.62ms(3.7 倍),主线程响应延迟从 4.41 涨到 17.07ms(3.9 倍),fps 从 59.9 掉到 54.6------过了这条线,用户开始能感觉到「拖拽发黏」。同样的代码,worker 版本把这条线彻底移走:无论每帧多贵,主线程响应延迟都是 0.03~0.04ms(因为主线程根本不在这个循环里)。

难点三:OffscreenCanvas 的一次性所有权交接

transferControlToOffscreen() 不是「共享一个 canvas」,而是把这个 canvas 的所有权整个交给另一个线程 。实测交接本身很便宜(0.08~0.26ms),但这笔交易是不可逆、且附带一堆沉默的规则:

① 交接之后,主线程对这块画布一无所知。 不能 getContext、不能 toDataURL、不能 toBlob、不能设 width/height------它们会抛错或静默失效。这不是缺陷,是设计:如果主线程还能读,就必须同步两边的绘制状态。

② 尺寸与 DPR 必须显式同步。 worker 里没有 resize 事件、没有 devicePixelRatio 的变化通知、没有布局。实测的字体探针还顺带确认了更基础的一条:

js 复制代码
// worker 内实测
typeof document                              // 'undefined'
typeof self.fonts                            // 'object'(FontFaceSet 在 worker 里是有的!)

也就是说:worker 里没有 DOM,但有字体集合 。所以自定义字体必须在 worker 内自己 new FontFace(...).load() 再 self.fonts.add()------@font-face 那条 CSS 路径对 OffscreenCanvas 完全无效。不加载的后果是静默 fallback :实测同样一段 Hello 前端,bold 24px monospace 量出 134.7px ,写一个不存在的字体名 bold 24px NoSuchFontXYZ 量出 113.86px ------相差 15.5%,画出来是错的排版,而且没有任何报错 。这正是 Day31「度量覆盖」那条结论在线程边界上的镜像:字体是每个渲染环境各自一份的资源。

③ worker 里可以用 requestAnimationFrame。 这是 OffscreenCanvas 能成立的前提:渲染循环可以完全搬过去。但要注意,headless 环境下 worker 的 rAF 不受 vsync 限制 (实测同一场景 worker 侧 1013ms 画完 120 帧 ≈ 118fps,主线程侧 1992ms ≈ 60fps)------所以本篇从不比较两者的 fps,只比较「每帧成本」和「主线程是否被占」。这是一个必须预先声明的口径。

④ 交互要自己搬回去。 worker 里没有事件系统,pointerdown 的坐标、命中测试、光标样式,全部得由主线程转发;命中测试所需的几何索引也只能在 worker 内自建。这是 OffscreenCanvas 方案真正的工作量所在------渲染搬走的复杂度是 O(1),交互搬走的复杂度是 O(你的图形结构)。

难点四:并行度的天花板是内存带宽,不是核心数

同一台机器(navigator.hardwareConcurrency = 11)、同样 8 个任务、同样 1/2/4/8 个 worker,只换任务的内存特征:

负载 worker=1 worker=2 worker=4 worker=8 8 worker 加速比 效率
纯算术(ALU 受限) 577.7ms 294.6ms 148.5ms 74.1ms 7.80× 0.97
素数筛(每任务写 16MB) 671.0ms 457.3ms 307.5ms 254.3ms 2.64× 0.33

单看加速比还不够刺眼,看单任务耗时:

负载 1 worker 2 workers 4 workers 8 workers 劣化倍数
纯算术 71.1ms 74.5ms 76.4ms 73.3ms 1.04×
素数筛 72.2ms 109.5ms 153.3ms 250.1ms 3.33×

纯算术负载下,单任务耗时几乎不动(71.1 → 73.3ms),8 个 worker 就换来 7.80 倍------这才是「并行」本该有的样子 。素数筛负载下,同样的任务在 8 路并发时自己慢了 3.33 倍 ,于是 8 个核心只买到 2.64 倍:任务本身变慢了,加速比自然被吃掉。

原因不是 CPU 不够,是每个任务都要把 16MB 写穿一遍(筛法在大跨度上做 stride 写)。8 个任务并发时,L3 与内存控制器的带宽被 8 路同时挤占,每个任务都在等内存。这也解释了为什么「加 worker」在这类负载上是负收益:第 9 个 worker 抢不到带宽,只抢到调度开销。

配套的三条代价,也都是实测的:

  • 启动成本 4.87~5.63ms/worker (1 个 4.9ms、4 个 22.5ms、8 个 40.2ms)。所以池必须预热并复用 ,不能「任务来了再 new Worker」------一次 20ms 的交互式操作,配一个 5ms 的启动,25% 的开销。
  • 每个 worker 有独立隔离堆 :在 worker 里吃 200MB ,主线程 performance.memory.usedJSHeapSize 从 0.7MB 到 0.7MB,delta = 0 。这意味着两件事:worker 的内存问题在主线程指标里完全隐形(监控要单独接),以及「池大小 × 单 worker 工作集」才是真实内存预算------共享一份 50MB 查表数据的池,8 个 worker 就是 400MB,除非用 SAB 或每 worker 只加载切片。
  • 消息调度的地板是 15µs (难点一)。任务粒度低于这个量级时,池化带来的收益会被消息开销吃掉------每个任务至少要跑够 1ms 才值得过一次线程边界。

二、完整解法

2.1 先做减法:什么值得搬过线程边界

搬线程不是免费的(启动 5ms、消息 15µs、每 worker 一份堆、数据要过克隆/转移),所以先要一张判据表。下表每一行都能在本文的实测数字里找到依据:

特征 搬? 依据
单次占用 > 8ms 且会重复发生 搬 8ms 已占掉半个帧预算,超过它就必然和渲染抢
单次占用 < 3ms 不搬 实测 3.675ms/帧的主线程场景,用户无感(0 错过帧)
触碰 DOM / 布局 / 事件坐标 只能留在主线程 document 在 worker 里不存在(实测)
纯像素绘制(无布局依赖) 搬(OffscreenCanvas) 绘制成本不变,但主线程响应 17.07 → 0.03ms
数据体量 > 256KB 且可转移 搬 + transfer ≥256KB 时 clone 至少慢 5 倍,64MB 慢 312 倍
数据体量 < 16KB 搬了也没坏处 三条通道都是 15µs 地板
高频小消息(> 1 万次/秒) 先批处理 15µs/条 ≈ 6.6 万条/秒上限
多任务且内存密集 限量(池 ≤ 2~4) 内存型负载 8 worker 效率 0.33、单任务劣化 3.33 倍
多任务且 ALU 密集 池 ≈ 核数 8 worker 效率 0.97

一句话版本:搬「占用长的、重复的、不碰 DOM 的」;不搬「短的、一次性的、要读布局的」。

2.2 传输层:先定语义,再定体积

三条通道各有自己的「正确场景」,不能混用:

场景 通道 理由
一次性大 buffer 递给 worker,主线程不再要 transfer 0 拷贝,代价与体积无关
大 buffer 反复来回(视频帧、像素缓冲) SAB 或 ping-pong transfer SAB 连「换身份」都省了;ping-pong 用回包复用 buffer 做稳态零分配
结构化小对象、配置、事件 clone 简单、安全,15µs 地板够用
对象数组(几万条记录) 改成 SoA + transfer 实测 40.87ms → 0.885ms
双工、多路复用 MessagePort 一条通道隔离一组会话,便于 close()

SoA 那条值得单独展开,因为它是本篇收益最大的一次重构,而且和线程无关------它只是「换个内存布局」:

js 复制代码
// Before:5 万条记录的对象数组
const records = Array.from({ length: 50000 }, (_, i) => ({
  id: i, x: rand(), y: rand(), name: 'n' + (i % 97),
}));
worker.postMessage({ records });              // 实测 40.87ms(一次克隆)

// After:同样的数据,4 个 TypedArray(Struct-of-Arrays)
const soa = {
  id:      new Int32Array(N),      // 200KB
  x:       new Float32Array(N),    // 200KB
  y:       new Float32Array(N),    // 200KB
  nameIdx: new Uint8Array(N),      //  50KB
};
worker.postMessage(soa, transferListOf(soa));  // 实测 0.885ms
方案 实测耗时 相对 搬运字节
structuredClone 5 万条记录 40.87ms 1× ≈3.5MB(JSON 口径)
JSON.parse(JSON.stringify()) 16.39ms 2.5× 快 ≈3.5MB
SoA + transfer 0.885ms 46× 快 650KB

三个附带好处,都是刚需级的:

  1. 字节数直接砍到 1/5.4:对象数组要为每条记录付 4 个属性名 + 指针 + 装箱开销;SoA 只有 650KB 的裸数组。
  2. worker 侧拿到的是「已经就位」的连续内存,可以直接喂给 WebGL / WebGPU / WASM,不需要再整理。
  3. GC 压力转移:SoA 是「一次分配、长期复用」,对象数组是「每次克隆都造 5 万个对象」。

代价要说清:SoA 让代码可读性变差(x[i] 而不是 r.x),并且字符串必须换成索引 + 字典 (本例 nameIdx 只存 0~96 的字典下标)。这是典型的「性能换可读性」交易,只在边界处做一次,内部照旧用对象。

2.3 RPC 层:把 postMessage 变成函数调用

裸 postMessage 在真实项目里活不过两周------因为每条消息都要手写一次 seq 配对、错误分支、超时、转移列表 。所以第二层是一个约 130 行的 RPC。下面这份是本文实测通过的完整实现(rpc.mjs),它把前面所有坑都收进了封装里:

js 复制代码
// rpc.mjs --- 双侧共用:主线程 createRpc(worker) / worker 侧 exposeRpc(handlers)
const TYPE = { REQ: 1, RES: 2, ERR: 3, PROGRESS: 4, CANCEL: 5 };

/** 摊平可转移对象(转移列表里只能放 buffer,见难点一坑二) */
export function transferListOf(...values) {
  const out = [];
  const walk = (v, depth = 0) => {
    if (!v || depth > 6) return;
    if (v instanceof ArrayBuffer) out.push(v);
    else if (ArrayBuffer.isView(v)) out.push(v.buffer);
    else if (Array.isArray(v)) for (const x of v) walk(x, depth + 1);
    else if (typeof v === 'object') for (const k of Object.keys(v)) walk(v[k], depth + 1);
  };
  for (const v of values) walk(v);
  return [...new Set(out)];                 // 重复 buffer 会抛 DataCloneError
}

const serializeError = (e) => ({ name: e?.name || 'Error', message: String(e?.message ?? e), stack: e?.stack ?? null });
const deserializeError = (o) => {
  const e = new Error(o.message);
  e.name = o.name;                          // 保住 RangeError / TimeoutError 这些语义
  if (o.stack) e.stack = o.stack;
  return e;
};

export function createRpc(target, { timeout = 30000 } = {}) {
  const pending = new Map();
  let seq = 0;

  target.addEventListener('message', (ev) => {
    const d = ev.data;
    if (!d || typeof d !== 'object') return;
    const p = pending.get(d.id);
    if (!p) return;                          // 迟到的 / 已取消的,直接丢弃
    if (d.t === TYPE.RES) { pending.delete(d.id); p.resolve(d.v); }
    else if (d.t === TYPE.ERR) { pending.delete(d.id); p.reject(deserializeError(d.e)); }
    else if (d.t === TYPE.PROGRESS) p.onProgress?.(d.v);
  });

  function call(method, ...rawArgs) {
    const id = ++seq;
    // 最后一个参数若带 signal/onProgress,是「本地控制项」:绝不参与序列化
    const last = rawArgs[rawArgs.length - 1];
    const isCtrl = last && typeof last === 'object' && !ArrayBuffer.isView(last) && ('signal' in last || 'onProgress' in last);
    const ctrl = isCtrl ? last : {};
    const args = isCtrl ? rawArgs.slice(0, -1) : rawArgs;

    return new Promise((resolve, reject) => {
      const timer = timeout > 0
        ? setTimeout(() => {
            pending.delete(id);
            target.postMessage({ t: TYPE.CANCEL, id });
            reject(Object.assign(new Error(`rpc timeout: ${method}`), { name: 'TimeoutError' }));
          }, timeout)
        : null;
      const settle = (fn) => (v) => { if (timer) clearTimeout(timer); fn(v); };

      if (ctrl.signal) {
        if (ctrl.signal.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
        ctrl.signal.addEventListener('abort', () => {
          target.postMessage({ t: TYPE.CANCEL, id });
          pending.delete(id);
          if (timer) clearTimeout(timer);
          reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
        }, { once: true });
      }

      pending.set(id, { resolve: settle(resolve), reject: settle(reject), onProgress: ctrl.onProgress });
      target.postMessage({ t: TYPE.REQ, id, m: method, a: args }, transferListOf(args));
    });
  }

  return { call, pendingCount: () => pending.size };
}

/** worker 侧 */
export function exposeRpc(handlers, scope = self) {
  const inflight = new Map();

  scope.addEventListener('message', async (ev) => {
    const d = ev.data;
    if (!d || typeof d !== 'object') return;

    if (d.t === TYPE.CANCEL) {
      inflight.get(d.id)?.();                 // 触发协作式取消
      return;
    }
    if (d.t !== TYPE.REQ) return;

    const ctx = { cancelled: false };
    inflight.set(d.id, () => { ctx.cancelled = true; });   // 供业务代码查询/挂回调
    try {
      const fn = handlers[d.m];
      if (!fn) throw new Error(`no such method: ${d.m}`);
      const v = await fn(...d.a, {
        id: d.id,
        progress: (v) => scope.postMessage({ t: TYPE.PROGRESS, id: d.id, v }),
        get cancelled() { return ctx.cancelled; },
        onCancel: (cb) => { inflight.set(d.id, () => { ctx.cancelled = true; cb?.(); }); },
      });
      if (ctx.cancelled) return;               // 已取消:不要回包(主线程那边已 reject)
      scope.postMessage({ t: TYPE.RES, id: d.id, v }, transferListOf(v));
    } catch (e) {
      scope.postMessage({ t: TYPE.ERR, id: d.id, e: serializeError(e) });
    } finally {
      inflight.delete(d.id);
    }
  });
}

worker 侧的业务代码(workers/rpc-worker.mjs):

js 复制代码
import { exposeRpc } from '../rpc.mjs';

function kernel(iters, seed) {
  let x = seed;
  for (let i = 0; i < iters; i++) x = (x * 31 + i) % 2147483647;
  return x;
}

exposeRpc({
  add: (a, b) => a + b,

  // 就地算,结果用 transfer 交回主线程(零拷贝回去)
  scale: (arr, k) => {
    const out = new Float32Array(arr.length);
    for (let i = 0; i < arr.length; i++) out[i] = arr[i] * k;
    return out;                    // 返回的值会被 transferListOf(v) 自动转移
  },

  boom: () => { throw new RangeError('算不了'); },

  // 注意签名:ctx 永远排在业务参数最后一位
  async longTask(n, ctx) {
    for (let i = 0; i < n; i++) {
      kernel(20000, i);
      if (i % 10 === 0) ctx.progress({ done: i, total: n });
      if (ctx.cancelled) throw Object.assign(new Error('cancelled'), { name: 'AbortError' });
      if (i % 5 === 0) await new Promise((r) => setTimeout(r, 0));  // 让 CANCEL 消息有机会被处理
    }
    return n;
  },
});

// 就绪信号:主线程据此决定何时开始派活(宁可多一条消息,也不要「发出去不知道谁接了」)
self.postMessage({ cmd: 'boot' });

主线程侧(rpc.html 里实测过的调用姿势):

js 复制代码
import { createRpc } from './rpc.mjs';

const worker = new Worker(new URL('./workers/rpc-worker.mjs', import.meta.url), { type: 'module' });
const rpc = createRpc(worker, { timeout: 5000 });

await rpc.call('add', 2, 3);                    // 5

const src = new Float32Array([1, 2, 3, 4]);
const scaled = await rpc.call('scale', src, 10); // "10,20,30,40"
src.byteLength;                                  // 0 ------ 参数已被转移(想保留就别传可转移对象)

try { await rpc.call('boom'); } catch (e) { e.name; }   // 'RangeError',message 原样带回

const ac = new AbortController();
setTimeout(() => ac.abort(), 30);
await rpc.call('longTask', 400, { onProgress: (p) => console.log(p), signal: ac.signal });  // AbortError

实测断言(6 条,全部通过):

行为 实测结果
普通调用 add(2,3) → 5
返回值自动转移 scale 返回 16 字节 Float32Array,回包即转移
参数被转移 调用后 src.byteLength === 0
错误保真 RangeError/算不了(name + message 都保留)
超时 TimeoutError(20ms 超时打在长任务上)
协作式取消 AbortError,且取消前已收到 progress
并发压力 2000 次并发调用后 pendingCount() === 0(无泄漏)

最后一条是我最在意的:RPC 封装最容易出的不是功能 bug,是 pending map 泄漏 ------超时、取消、worker 崩溃三条路径上只要有一条忘记 delete,长时间运行的页面就会缓慢吃内存。所以 pendingCount() 必须是被断言的公开 API。

2.4 OffscreenCanvas 渲染管线:交接、尺寸、字体、交互

一个能上生产的 worker + OffscreenCanvas 需要四件套:交接、尺寸同步、字体、交互转发 。下面把四件套写成最小完整形态;跑基准用的完整版在 workers/render-worker.mjs(多了场景几何与逐帧计时),两者是同一条管线。

主线程侧(交接 + 尺寸同步 + 交互转发):

js 复制代码
const canvas = document.createElement('canvas');
canvas.style.cssText = 'width:800px;height:600px';
const offscreen = canvas.transferControlToOffscreen();   // 实测 0.17~0.25ms,一次性且不可逆

const worker = new Worker(new URL('./workers/render-worker.mjs', import.meta.url), { type: 'module' });
worker.postMessage(
  { cmd: 'init', canvas: offscreen, dpr: devicePixelRatio, w: 800, h: 600, n: 12000 },
  [offscreen],                                            // 必须转移,否则拿不到所有权
);

// worker 里没有 resize 事件:尺寸变化必须由主线程算好再发过去
const ro = new ResizeObserver(([entry]) => {
  const { width, height } = entry.contentRect;
  worker.postMessage({ cmd: 'resize', w: width, h: height, dpr: devicePixelRatio });
});
ro.observe(canvas);

// 命中测试在 worker 里:主线程只负责把坐标搬过去
canvas.addEventListener('pointerdown', (e) => {
  worker.postMessage({ cmd: 'hit', x: e.offsetX, y: e.offsetY, pointerId: e.pointerId });
});

worker 侧(渲染循环 + 尺寸/字体):

js 复制代码
let ctx, W, H, DPR, N, frames = 0;

self.onmessage = async (e) => {
  const d = e.data;

  if (d.cmd === 'init') {
    DPR = d.dpr; W = d.w; H = d.h; N = d.n;
    d.canvas.width = Math.round(W * DPR);
    d.canvas.height = Math.round(H * DPR);

    // worker 里有 self.fonts,没有 document.fonts / @font-face
    // (本篇资产里没有自定义字体文件,这段是写法示范;难点三那个 15.5% 的 fallback 差异是用
    //  「monospace vs 不存在的字体族」实测出来的)
    const face = new FontFace('SceneFont', 'url(/fonts/scene.woff2)');
    await face.load();
    self.fonts.add(face);                 // 不加这一步 → 静默 fallback(实测宽度差 15.5%)

    ctx = d.canvas.getContext('2d', { alpha: false });
    requestAnimationFrame(loop);          // worker 里的 rAF 是可用的
  }

  if (d.cmd === 'resize') {
    DPR = d.dpr; W = d.w; H = d.h;
    d.canvas.width = Math.round(W * DPR);   // 注意:worker 侧持有的是 OffscreenCanvas
    d.canvas.height = Math.round(H * DPR);
  }

  if (d.cmd === 'hit') {
    // 命中测试只能基于 worker 自己维护的几何索引
    const picked = index.at(d.x * DPR, d.y * DPR);
    self.postMessage({ cmd: 'picked', pointerId: d.pointerId, id: picked?.id ?? null });
  }
};

function loop(t) {
  const t0 = performance.now();
  drawScene(ctx, t, W, H, DPR, N);
  frameCost.push(performance.now() - t0);   // 这个数组就是本文 n3000/n12000 那张表的来源
  requestAnimationFrame(loop);
}

需要单独点出来的两个细节:

  • canvas.width 必须乘 DPR ,而 CSS 尺寸由主线程负责。worker 里改 width 会清空画布并重置变换矩阵 ------所以 resize 之后要重新 setTransform,否则整个场景会被缩放两次。
  • 渲染循环里不要在每帧 postMessage 汇报状态 。实测消息地板 15µs,60fps 下每帧一条汇报就占掉 0.9% 的主线程时间;正确做法是攒一批再报(或者只把「要展示的东西」写到 SAB 的固定槽位里)。

2.5 Worker 池:拉取模型 + 预热 + 任务粒度

由难点四的实测,池的设计规则是硬的:

决策 规则 依据
池大小(ALU 型) ≈ hardwareConcurrency,封顶到任务数 8 worker / 8 任务效率 0.97
池大小(内存型) 2~4,不要跟着核数走 8 worker 效率 0.33 ,单任务劣化 3.33×
创建时机 页面空闲时预热,不要任务来了再建 每 worker 4.87~5.63ms
派发方式 拉取(worker 空闲就领下一个) 推模型在大任务上会饿死尾部
任务粒度 每个任务 ≥ 1ms 消息地板 15µs
单 worker 工作集 计入「池大小 × 工作集」 worker 堆对主线程完全不可见

拉取模型的最小实现(本文实测用它跑出上面所有池数据):

js 复制代码
class Pool {
  #workers = [];
  #idle = [];
  #queue = [];
  #size;
  #url;

  constructor(size, url, { warm = 0 } = {}) {
    this.#size = Math.min(size, navigator.hardwareConcurrency || 4);
    this.#url = url;
    // 预热:空闲时先建好,别把 5ms 启动成本算到用户交互上
    for (let i = 0; i < (warm || this.#size); i++) this.#spawn();
  }

  #spawn() {
    const w = new Worker(new URL(this.#url, import.meta.url), { type: 'module' });
    w.__job = null;
    w.__ready = false;

    w.onmessage = (e) => {
      // 第一步必须是握手:worker 发 {cmd:'boot'} 才算可用。
      // (我第一版没做握手,预热 worker 的 boot 回包被当成了任务结果 ------ 池直接死锁)
      if (!w.__ready) {
        if (e.data && e.data.cmd === 'boot') {
          w.__ready = true;
          this.#idle.push(w);
          this.#drain();
        }
        return;
      }
      const job = w.__job;
      if (!job) return;                 // 迟到 / 非任务回包,丢弃而不是错配
      w.__job = null;
      this.#idle.push(w);
      job.resolve(e.data);
      this.#drain();
    };

    // worker 崩了要能让调用方知道,否则 Promise 永远悬着、池慢慢失血
    w.onerror = (err) => {
      const job = w.__job;
      w.__job = null;
      job?.reject(new Error('worker crashed: ' + (err.message || 'unknown')));
      this.#workers = this.#workers.filter((x) => x !== w);
      w.terminate();
      if (this.#workers.length < this.#size) this.#spawn();   // 补人,否则并发度悄悄降级
      this.#drain();
    };

    this.#workers.push(w);
  }

  #drain() {
    while (this.#queue.length && this.#idle.length) {
      const w = this.#idle.pop();
      const job = this.#queue.shift();
      w.__job = job;
      w.postMessage(job.msg, job.transfer || []);
    }
  }

  run(msg, transfer) {
    return new Promise((resolve, reject) => {
      this.#queue.push({ msg, transfer, resolve, reject });
      this.#drain();
    });
  }

  destroy() {
    this.#workers.forEach((w) => w.terminate());
    this.#workers = [];
    this.#idle = [];
    this.#queue.forEach((j) => j.reject(new Error('pool destroyed')));
    this.#queue = [];
  }
}

四个「不写就出事故」的地方:

  1. 握手 。worker 的第一条 {cmd:'boot'} 必须先被认出来,否则它会被当成某个任务的结果------本文的测试就是这样把池跑死的(预热 4 个 worker,boot 回包被当成任务回包,同一个 worker 被重复塞进空闲列表,20 个任务里有一半的 Promise 永远不 settle)。
  2. onerror 必须 reject 当前 job。否则 Promise 悬空,调用方永远等,池慢慢失血。
  3. 崩溃后要补一个新 worker,否则并发度悄悄降级------这是最隐蔽的一种退化:功能全对,只是越来越慢。
  4. 每个 worker 只挂一个 __job 。postMessage 无序竞争时会错配,表现为「结果串了」,极难排查。

实测验收(pool-test.html,20 个任务 / 4 worker):20 个任务全部完成、去重后 worker 实例数 ≤ 4、序号无错配、故意让 worker 抛未捕获错误后 job 正确 reject 且池自动补人并继续服务。

2.6 SharedArrayBuffer:真正需要它的两种场景

SAB 的门槛不是 flag,而是两个响应头:

js 复制代码
// server.mjs:不开这两个头,SharedArrayBuffer 直接不存在
res.writeHead(200, {
  'Cross-Origin-Opener-Policy': 'same-origin',
  'Cross-Origin-Embedder-Policy': 'require-corp',
});

实测对照(同一份页面代码,只差这两个头):

条件 crossOriginIsolated typeof SharedArrayBuffer new SharedArrayBuffer(8)
有 COOP+COEP true 'function' 成功
无 COOP+COEP false 'undefined' ReferenceError

这个失败模式很讨厌:它不是「性能退化」,是功能直接不存在 ,而且只在线上(CDN/网关去掉了头)才暴露。所以 SAB 代码必须写成能力探测 + 降级,不能假设它有。

两条额外的硬规则(实测):

  • 主线程不能用 Atomics.wait() :TypeError。主线程只能用 Atomics.waitAsync()(返回 {async: true, value: Promise}),worker 里则可以同步阻塞。
  • 纯信号往返 10µs (中位;min 5µs、p95 20µs)。这就是「跨线程一次同步」的地板,比 15µs 的消息地板略快,但在同一量级------所以不要指望用 SAB 做「高频小同步」。

四条通道做同一份活(把 4MB / 100 万个 float 逐个 sqrt 后拿回结果)的端到端实测:

通道 中位 ms p95 ms 往返拷贝 往返搬运
clone 往返 3.645 12.305 2 份 8.4MB
transfer 往返 0.835 2.13 0 0
SAB 原地改写 0.825 0.855 0 0

读法要小心:这里的 0.825ms 不是「传输成本」,而是「计算 + 同步 」成本(1M 次 sqrt 本身就要这个量级)。它和 clone 3.645ms 的差(≈2.8ms)才是两次克隆的开销,与难点一表里「4MB 克隆往返 2.205ms」互相印证。

transfer 和 SAB 在单次 往返上打平(0.835 vs 0.825,在噪声内)。SAB 真正不可替代的是另外两件事:

  1. 双工、无所有权。transfer 是「一方交出、另一方接手」,同一块 buffer 同一时刻只有一边能用;SAB 是「两个人同时读写同一块内存」,天生适合「主线程持续写、worker 持续读」的像素/音频管道。
  2. 不需要「换身份」。transfer 后对象身份变了(难点一坑一),SAB 的引用永远稳定------可以安全地长期持有、放进 Map、被多个 worker 共享。

生产形态通常是这样(双缓冲 + 序号,避免撕裂):

js 复制代码
// 主线程:写第 buf 号缓冲,写完后用序号告诉 worker「这一帧好了」
const slots = new Int32Array(sab, 0, 4);           // [seq, ready, w, h]
const pixels = new Uint8ClampedArray(sab, 16);     // RGBA

function pushFrame(imageData) {
  pixels.set(imageData.data);
  Atomics.store(slots, 1, 1);                      // ready = 1
  const seq = Atomics.add(slots, 0, 1) + 1;        // 帧序号,单调递增
  Atomics.notify(slots, 0);                        // 唤醒 worker(≈10µs)
  return seq;
}

// worker:等新帧 → 处理 → 回序号(绝不要用「清零」做同步,会丢帧)
Atomics.wait(slots, 0, lastSeq);                   // worker 里可以同步阻塞
lastSeq = Atomics.load(slots, 0);
processInPlace(pixels);

双缓冲是必须的:单缓冲时主线程写下一帧会覆盖 worker 正在读的那一帧,症状是「画面周期性撕裂」,而这类 bug 在本地几乎复现不出来(因为本地机器太快,竞态窗口小)。

2.7 验收:把上面全部变成断言

本文的 25 条断言分四层,全部在 measure.mjs 里,失败即 exit 1:

语义层(6 条)------不涉及性能,只固化行为:

js 复制代码
ok(c.semantics.clone_function.startsWith('THROW'), '函数不能过 structuredClone');
ok(c.semantics.clone_class_proto_lost === 'false/undefined', 'structuredClone 会丢原型');
ok(c.semantics.clone_sab_same_memory === 42, 'SAB 克隆后仍是同一块内存');
ok(c.semantics.transfer_detaches_source === 0, 'transfer 后源 buffer 必须分离');
ok(c.semantics.transfer_twice_throws.startsWith('THROW'), '同一个 buffer 不能转移两次');
ok(c.semantics.clone_typed_view_geometry_kept === 'Float32Array/4/2/16', '视图的 offset/length 会被保留');
// ... 以及 clone_cycle_kept / clone_map_set_date_regex / clone_error_type_kept / transfer_raw_view_in_list ...

通道层(4 条)------钉住「方向」,不钉绝对值(换机器绝对值会变,方向不会):

js 复制代码
ok(c.sizes.every((r) => r.transfer_ms < 1), 'transfer 应在 1ms 以内(与体积无关)');
ok(c.sizes.filter((r) => r.bytes <= 16384).every((r) => r.clone_ms < 0.05 && r.transfer_ms < 0.05),
   '小包时三条通道都在 50µs 以内');                       // 消息地板
ok(c.sizes.filter((r) => r.bytes >= 262144).every((r) => r.clone_over_transfer > 5),
   '≥256KB 时 clone 至少慢 5 倍');
ok(c.sizes.at(-1).clone_over_transfer > 100, '64MB 时 clone 应比 transfer 慢 100 倍以上');

帧与主线程层(7 条)------主线程到底有没有被占用(这是本篇真正的验收对象,因为它是唯一能区分「换线程」和「换实现」的指标):

js 复制代码
ok(m.worker.wall_ms < m.main_block.wall_ms * 1.15, 'worker 不该比主线程慢(它省的不是时间)');
ok(m.worker.max_gap_ms < m.main_block.max_gap_ms / 10, 'worker 必须显著改善最大帧间隔');
ok(m.main_chunked.wall_ms > m.main_block.wall_ms * 1.3, '切片会拖慢总时长');
ok(o.font_probe.hasDocument === false, 'worker 里没有 document');
ok(s.channels.sab_inplace.result_check === true, 'SAB 原地改写的结果必须可见');
ok(s.isolation.crossOriginIsolated === true, '本页应是 crossOriginIsolated');
ok(String(s.isolation.canAtomicsWaitOnMain).startsWith('THROW'), '主线程不能 Atomics.wait');

池与隔离层(6 条):

js 复制代码
ok(pl.loads.cpu.configs[1].speedup > 1.2, 'cpu 负载 2 worker 应有加速');
ok(pl.loads.cpu.configs[3].speedup > pl.loads.cpu.configs[1].speedup, '8 worker 快于 2 worker');
ok(pl.hardwareConcurrency >= 4, '本机应有多核');
ok(pl.heap_isolation.main_delta_mb === 0, 'worker 堆不应计入主线程堆');
ok(control.crossOriginIsolated === false, '对照组不应是 crossOriginIsolated');
ok(String(control.canConstructSAB).startsWith('THROW'), '没有隔离头时 SharedArrayBuffer 应不可用');

不变量层(2 条,在两个场景上各跑一次 = 4 次检查)------跨场景必须成立的性质:

js 复制代码
// 搬线程不改变算力:worker 的绘制成本应与主线程相当(实测比 0.91~1.02)
ok(ratio > 0.8 && ratio < 1.25, `${scene}: 绘制成本应与主线程相当`);
// 主线程响应延迟不得更高
ok(rec.worker_offscreen.latency.median_ms <= rec.main_canvas.latency.median_ms * 1.1 + 0.05,
   `${scene}: worker 模式下主线程响应延迟不得更高`);

三、应用场景

场景一:数据可视化大屏 / Canvas 图表

这是本篇数字直接对应的场景。判据来自那张 3000 vs 12000 图元的表:3000 图元时 worker 版本毫无必要 (两者都 0 错过帧,主线程延迟 4.41 vs 0.04ms 但用户无感);12000 图元时主线程版本开始掉 fps(54.6)且输入延迟 17.07ms,这时 worker 是唯一出路。

落地要点:

  • 图表交互(hover 提示、缩放拖拽)必须留在主线程,它要读 DOM 做 tooltip 定位;渲染搬走之后,两者通过「主线程算好视口矩阵 → 发给 worker」通信,而不是让 worker 猜。
  • 动画状态别用 postMessage 逐帧同步 。60fps 下每帧一条消息就是 15µs × 60 = 0.9ms/s 的主线程税,而且会引入一帧延迟(消息在下一次任务里才被处理)。正确做法是把动画参数写进 SAB 的固定槽位,worker 每帧自己读。
  • 如果图表要能导出图片,注意交接之后主线程不能再 toDataURL() (难点三)。导出要走「worker 内 convertToBlob() → 转移 ImageBitmap/Blob 回主线程」这条路。

场景二:图像 / 视频处理(滤镜、缩略图、证件照)

这里 SAB 与 transfer 的分工最清晰:

  • 一次性处理 (用户选一张图 → 出结果):createImageBitmap → 转移进 worker → 处理 → 转移回来。走 transfer,主线程在等待期间完全自由。
  • 连续帧处理 (摄像头预览、视频滤镜):必须走 SAB 双缓冲。transfer 的单所有权语义在这里是致命的------你没法在 worker 处理第 N 帧的同时让主线程准备第 N+1 帧,除非复制(4MB 一帧、30fps = 吞吐 120MB/s 的纯浪费)。
  • 像素布局用 Uint8ClampedArray + 索引 ,一行一个连续的 stride,别用 {r,g,b,a} 对象(一条 1080p 帧是 200 万个对象,GC 直接崩盘)。

场景三:富文本 / 代码编辑器

Monaco / CodeMirror 已经把语法高亮放进了 worker,但只放了一部分,原因就在难点三:worker 里没有 DOM、没有布局、没有选区 API。可搬的与不可搬的分得很干净:

能力 线程 原因
词法分析、语法树、格式化、diff worker 纯文本进、纯结构出,SoA 式的数据边界
全文搜索、模糊匹配、符号索引 worker + 池 ALU 型负载,实测接近线性(0.97 效率)
光标、选区、滚动、IME 主线程 依赖布局与事件
高亮结果回填 主线程应用 worker 只回 [{start,end,token}],别回 DOM/HTML

这里最容易被忽视的是输入延迟 :语法分析单次 8~15ms,如果在主线程做,每敲一个字符就吃掉一个帧。这和本篇 main_batch_yield 那行是同一个病(最大帧间隔 40.18ms)------打字黏手不是因为打包慢,是因为键盘事件后面排着一个 15ms 的分析任务。

场景四:表格 / 虚拟滚动 / 大数据筛选

表格的典型形态是「10 万行 × 20 列,用户每次改动筛选条件都要重算聚合」。这类负载有三个特征,正好踩中本篇的三条实测结论:

  • 单次占用 30~200ms → 必须搬(多搬一秒都是白屏);
  • ALU 型、任务可切分(按行块切)→ 池可以跟着核数走,实测 8 worker 效率 0.97;
  • 数据是「一份大表 + 多个查询」 → 大表用 SoA + transfer 只传一次,之后每个任务只传「行区间 + 条件」这种几十字节的小对象(此时 15µs 消息地板完全可以接受)。

反例:别把「每行一个任务」派下去 。1000 个任务 × 15µs = 15ms 纯调度开销,而每个任务本身可能只要 0.1ms------任务粒度低于 1ms 是池化方案的第一个死因。

场景五:地图 / 游戏 / 大画布渲染

这是 OffscreenCanvas 收益最大的场景,因为它同时满足「每帧成本高」和「持续渲染」两个条件:

  • 每帧成本高:本篇 12000 图元的场景(13.62ms/帧)已经让主线程 fps 掉到 54.6;真实地图应用的每帧成本常在 10~30ms 区间,主线程版本必然是「拖动地图时按钮点不动」。
  • 持续渲染 :worker 的 rAF 循环可以一直跑,主线程只在「视口变了」「数据变了」时发一条消息。主线程被 300ms 同步任务占住时,实测 worker 仍然画完了全部 120 帧(1013.3ms,每帧 3.335ms)------这就是「主线程卡死但画面还在动」的能力,也是它和一切主线程方案的代差分界。

配套要做的是命中测试索引 :worker 里没有 DOM 命中测试,pointerdown 的坐标要靠主线程转发,worker 自己的空间索引(四叉树 / 网格哈希)来做拾取。这部分工作量常常比渲染搬运本身更大------这是 OffscreenCanvas 最容易被低估的成本。


四、总结

Day31 的结论是「图片和字体的优化对象不是字节,是缺口」。Day32 沿同一条思路往下走,得到的结论更硬,因为它可以逐条量化:

「搬到 worker」买的从来不是速度,是「不再占用主线程」。

  • 计算量一样时,总墙钟 610.6ms → 611.4ms,一毫秒没省;
  • 绘制成本一样时,每帧 13.62ms → 13.86ms,一分钱没省;
  • 换来的是:最大帧间隔 611.45ms → 23.46ms 、错过帧 36 → 0 、TBT 561ms → 0 、主线程响应延迟 17.07ms → 0.03ms。

所以判断「该不该搬」的标准,不是「这段代码慢不慢」,而是「这段代码单次占用多久、多久发生一次」。3.675ms/帧不值得搬(实测用户无感),13.62ms/帧必须搬。

具体到可复用的判断:

  1. 三条通道选错,代价是三个数量级 。64MB 往返:clone 56.14ms 、transfer 0.18ms 、SAB 0.015ms 。而三者的 API 只差一个可选参数------这一行代码没有任何检查能救你 ,只能靠封装(2.3 的 transferListOf)和断言。
  2. transfer 是「一次性所有权」,不是「复制快一点」。发出去的视图变 0 字节、回来的不是同一个对象(7 个尺寸 100% 命中)。缓冲池要 ping-pong 复用,不能按尺寸缓存视图。
  3. 结构比通道更值钱 。5 万条记录:structuredClone 40.87ms → JSON 16.39ms → SoA + transfer 0.885ms (46 倍 ),字节数同时从 3.5MB 降到 650KB。先把数据布局改对,再谈传得有多快。
  4. structuredClone 不比 JSON 快(40.87 vs 16.39ms)。它的价值是语义完整(Date/Map/Set/Error/循环引用/视图几何),不是速度。纯数据过边界时,JSON 或 SoA 都可能更快。
  5. 消息有 15µs 的地板,纯信号同步 10µs。这两个数决定了「任务粒度 ≥ 1ms」「别逐帧 postMessage」这两条工程纪律------它们不是风格建议,是量级结论。
  6. 切片不是 worker 的替代品 。setTimeout(0) 切片换来 0 掉帧但 +92% 墙钟 ;scheduler.yield() 保住吞吐却把最大帧间隔打到 105.05ms (比不切片的 611ms 好,但远差于 worker 的 23.46ms)。只有 worker 同时吃下吞吐与帧率。
  7. 并行的天花板是带宽,不是核数 。ALU 型 8 worker 7.80×(效率 0.97) ;内存型同样 8 worker 只有 2.64×(效率 0.33) ,单任务自己就慢了 3.33 倍。所以池大小的正确问法不是「我该开几个 worker」,而是「我的任务是不是在抢内存」。
  8. 每个 worker 是一份独立堆 :worker 里吃 200MB,主线程堆 delta 0。这既是优势(隔离、不拖累主线程 GC),也是陷阱(监控看不见、池 × 工作集才是真实内存预算)。
  9. 每个门槛都要有能力探测和降级 。SAB 不是 flag 问题,是 COOP/COEP 问题:缺头时 SharedArrayBuffer 直接 undefined(ReferenceError),这是功能缺失 而不是性能退化。同理 OffscreenCanvas 不可用时要有「主线程 canvas」的整条回退路径。

本篇没有覆盖的 (免得被当成全集):Transferable Streams(把 ReadableStream 转移过线程,替代「先攒完再传」)、WebCodecs 的 VideoFrame 转移、SharedWorker 与跨标签页共享、AudioWorklet 的实时音频线程、WASM threads 与 SharedArrayBuffer 的组合、Service Worker 里的计算、OffscreenCanvas 在 WebKit 上的差异、COEP 对第三方资源的影响(credentialless)、以及 worker 的调试(DevTools 的 worker target 与 --enable-logging)。这些都在同一片战场,每一个都够单独写一篇。

下一篇 Day33 换到另一条更硬的维度:WASM------如果说 Worker 是「换个线程算」,WASM 就是「换套指令算」。那里第一条要打掉的直觉是「WASM 一定比 JS 快」,和本篇「worker 一定比主线程快」是同一类错误的另一个版本。


运行环境与文件清单

php 复制代码
Day32-Worker与OffscreenCanvas.md   本篇
day32-assets/
├── cdp.mjs              零依赖 CDP 客户端(Node 22 内置 WebSocket,不用 puppeteer)
├── server.mjs           静态服务器,可开关 COOP/COEP(对照组用)
├── measure.mjs          一键跑完 5 组基准 + 25 条断言,失败 exit 1
├── bench-clone.mjs      三条通道 × 7 个体积 + 启动成本 + 14 项语义探针
├── bench-jank.mjs       同一计算量的 5 种执行姿势(含 scheduler.yield)
├── bench-offscreen.mjs  主线程 canvas vs worker OffscreenCanvas(3000 / 12000 图元)
├── bench-sab.mjs        四条通道做同一份活的端到端对比 + 隔离条件对照
├── bench-pool.mjs       worker 池:ALU 型 vs 内存型,1/2/4/8 worker
├── rpc.mjs              RPC 层(文章版)------ 转移/错误/超时/取消/2000 并发无泄漏
├── pool-lib.mjs         文章版 Pool 类(含握手、崩溃补人)
├── test-article-code.mjs 验证「文章里贴的代码」真能跑:12 条断言
├── rpc.html · pool-test.html   上面两个的测试页
├── workers/echo.mjs · echo-classic.js · render-worker.mjs · sab-worker.mjs
│         · pool.mjs · hog.mjs · rpc-worker.mjs
├── *.html               6 个基准页
└── bench-results.json   本次全部原始数据

全部是实测。这一篇没有模型推演,因为线程与画布的行为都能在浏览器里直接量。

另外,文章中贴出的 RPC 层与 Pool 类本身也是被验证过的 :node test-article-code.mjs 会跑 12 条断言(普通调用、参数/返回值自动转移、错误保真、超时、协作式取消、2000 并发无泄漏、20 任务/4 worker 无错配、worker 崩溃后 reject 并自动补人)。写这篇文章的过程中,这套测试抓出了两个真 bug------Pool 没做握手导致预热 worker 的 boot 回包被当成任务结果(池死锁) ,以及 RPC 把 signal/onProgress 这类控制字段一起塞进了 postMessage(DataCloneError)。这两个坑都已经写进正文。

部分 口径
通道耗时、启动成本、语义矩阵 真实 Chrome,往返中位值,iters 随体积自适应(每档至少搬 128MB)
帧间隔 / 长任务 / TBT requestAnimationFrame 序列 + PerformanceObserver('longtask')
主线程响应延迟 MessageChannel 每 50ms 发一次,测「排队多久被处理」
OffscreenCanvas 每帧成本 场景内部 performance.now() 包住绘制调用
池的加速比 拉取模型派发,8 个等长任务,1/2/4/8 worker 各跑一遍
隔离对照 同一份页面代码,一份带 COOP/COEP、一份不带

几点必须说清的边界:

  • 绝不比较 worker 与主线程的 fps。headless 环境下 worker 的 rAF 不受 vsync 限制(同一场景 worker 侧 1013ms 画完 120 帧,主线程侧 1992ms),所以本篇只比「每帧成本」与「主线程是否被占用」。真实浏览器里两者都会被 vsync 钉住。
  • 绝对毫秒会随机器状态漂移,比值不会 。同一台机器上,素数筛单任务的绝对值在不同轮次间从 75ms 漂到 448ms(热降频与内存压力),但「8 worker 效率 0.33」这个结论每一轮都成立。所以断言只钉方向与量级,不钉绝对值。
  • 本地静态服务器,没有网络延迟 。worker 脚本的获取在真实项目里要过一次网络(type: 'module' 的模块图可能有多层),启动成本会高于实测的 4.87~5.63ms 。冷缓存下模块 worker 比经典 worker 多约 1.5ms(独立跑过一次:6.97 vs 5.44ms),预热后几乎相等(2.095 vs 1.925ms)。
  • transferControlToOffscreen 每个 canvas 只能调一次,本文的基准里用的是「每次新建 canvas」,真实项目要做的是「一个 canvas 一块 worker」的长期绑定。
  • 25 条断言不是全集 ,只是本篇讨论过的那些性质。真实项目的验收清单还应包含:worker 崩溃恢复、unhandledrejection 传播、SAB 双缓冲撕裂检测、降级路径回归。

复现:

bash 复制代码
cd day32-assets
node measure.mjs            # 5 组基准 + 25 条断言(约 3 分钟,失败 exit 1)
node test-article-code.mjs  # 文章里贴的 RPC / Pool 代码:12 条断言

(Node v22.23.3 / Chrome for Testing 149.0.7827.55 / macOS / headless=new,2026-10-23)


附录:完整代码

以下文件均来自 day32-assets/(完整源码,非片段)。bench-*.mjs 通过 cdp.mjs 驱动真实 Chrome,test-article-code.mjs 断言正文中的每一条结论。

pool-lib.mjs ------ Worker 池核心(并发槽、任务队列、取消)

javascript 复制代码
class Pool {
  #workers = [];
  #idle = [];
  #queue = [];
  #size;
  #url;

  constructor(size, url, { warm = 0 } = {}) {
    this.#size = Math.min(size, navigator.hardwareConcurrency || 4);
    this.#url = url;
    // 预热:空闲时先建好,别把 5ms 启动成本算到用户交互上
    for (let i = 0; i < (warm || this.#size); i++) this.#spawn();
  }

  #spawn() {
    const w = new Worker(new URL(this.#url, import.meta.url), { type: 'module' });
    w.__job = null;
    w.__ready = false;

    w.onmessage = (e) => {
      // 第一步必须是握手:worker 发 {cmd:'boot'} 才算可用。
      // (我第一版没做握手,预热 worker 的 boot 回包被当成了任务结果 ------ 池直接死锁)
      if (!w.__ready) {
        if (e.data && e.data.cmd === 'boot') {
          w.__ready = true;
          this.#idle.push(w);
          this.#drain();
        }
        return;
      }
      const job = w.__job;
      if (!job) return;                 // 迟到 / 非任务回包,丢弃而不是错配
      w.__job = null;
      this.#idle.push(w);
      job.resolve(e.data);
      this.#drain();
    };

    // worker 崩了要能让调用方知道,否则 Promise 永远悬着、池慢慢失血
    w.onerror = (err) => {
      const job = w.__job;
      w.__job = null;
      job?.reject(new Error('worker crashed: ' + (err.message || 'unknown')));
      this.#workers = this.#workers.filter((x) => x !== w);
      w.terminate();
      if (this.#workers.length < this.#size) this.#spawn();   // 补人,否则并发度悄悄降级
      this.#drain();
    };

    this.#workers.push(w);
  }

  #drain() {
    while (this.#queue.length && this.#idle.length) {
      const w = this.#idle.pop();
      const job = this.#queue.shift();
      w.__job = job;
      w.postMessage(job.msg, job.transfer || []);
    }
  }

  run(msg, transfer) {
    return new Promise((resolve, reject) => {
      this.#queue.push({ msg, transfer, resolve, reject });
      this.#drain();
    });
  }

  destroy() {
    this.#workers.forEach((w) => w.terminate());
    this.#workers = [];
    this.#idle = [];
    this.#queue.forEach((j) => j.reject(new Error('pool destroyed')));
    this.#queue = [];
  }
}

export { Pool };
javascript 复制代码
// rpc.mjs --- 双侧共用:主线程 createRpc(worker) / worker 侧 exposeRpc(handlers)
const TYPE = { REQ: 1, RES: 2, ERR: 3, PROGRESS: 4, CANCEL: 5 };

/** 摊平可转移对象(转移列表里只能放 buffer,见难点一坑二) */
export function transferListOf(...values) {
  const out = [];
  const walk = (v, depth = 0) => {
    if (!v || depth > 6) return;
    if (v instanceof ArrayBuffer) out.push(v);
    else if (ArrayBuffer.isView(v)) out.push(v.buffer);
    else if (Array.isArray(v)) for (const x of v) walk(x, depth + 1);
    else if (typeof v === 'object') for (const k of Object.keys(v)) walk(v[k], depth + 1);
  };
  for (const v of values) walk(v);
  return [...new Set(out)];                 // 重复 buffer 会抛 DataCloneError
}

const serializeError = (e) => ({ name: e?.name || 'Error', message: String(e?.message ?? e), stack: e?.stack ?? null });
const deserializeError = (o) => {
  const e = new Error(o.message);
  e.name = o.name;                          // 保住 RangeError / TimeoutError 这些语义
  if (o.stack) e.stack = o.stack;
  return e;
};

export function createRpc(target, { timeout = 30000 } = {}) {
  const pending = new Map();
  let seq = 0;

  target.addEventListener('message', (ev) => {
    const d = ev.data;
    if (!d || typeof d !== 'object') return;
    const p = pending.get(d.id);
    if (!p) return;                          // 迟到的 / 已取消的,直接丢弃
    if (d.t === TYPE.RES) { pending.delete(d.id); p.resolve(d.v); }
    else if (d.t === TYPE.ERR) { pending.delete(d.id); p.reject(deserializeError(d.e)); }
    else if (d.t === TYPE.PROGRESS) p.onProgress?.(d.v);
  });

  function call(method, ...rawArgs) {
    const id = ++seq;
    // 最后一个参数若带 signal/onProgress,是「本地控制项」:绝不参与序列化
    const last = rawArgs[rawArgs.length - 1];
    const isCtrl = last && typeof last === 'object' && !ArrayBuffer.isView(last) && ('signal' in last || 'onProgress' in last);
    const ctrl = isCtrl ? last : {};
    const args = isCtrl ? rawArgs.slice(0, -1) : rawArgs;

    return new Promise((resolve, reject) => {
      const timer = timeout > 0
        ? setTimeout(() => {
            pending.delete(id);
            target.postMessage({ t: TYPE.CANCEL, id });
            reject(Object.assign(new Error(`rpc timeout: ${method}`), { name: 'TimeoutError' }));
          }, timeout)
        : null;
      const settle = (fn) => (v) => { if (timer) clearTimeout(timer); fn(v); };

      if (ctrl.signal) {
        if (ctrl.signal.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
        ctrl.signal.addEventListener('abort', () => {
          target.postMessage({ t: TYPE.CANCEL, id });
          pending.delete(id);
          if (timer) clearTimeout(timer);
          reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
        }, { once: true });
      }

      pending.set(id, { resolve: settle(resolve), reject: settle(reject), onProgress: ctrl.onProgress });
      target.postMessage({ t: TYPE.REQ, id, m: method, a: args }, transferListOf(args));
    });
  }

  return { call, terminate: () => pending.clear(), pendingCount: () => pending.size };
}

/** worker 侧 */
export function exposeRpc(handlers, scope = self) {
  const inflight = new Map();

  scope.addEventListener('message', async (ev) => {
    const d = ev.data;
    if (!d || typeof d !== 'object') return;

    if (d.t === TYPE.CANCEL) {
      inflight.get(d.id)?.();                 // 触发协作式取消
      return;
    }
    if (d.t !== TYPE.REQ) return;

    const ctx = { cancelled: false };
    inflight.set(d.id, () => { ctx.cancelled = true; });   // 供业务代码查询/挂回调
    try {
      const fn = handlers[d.m];
      if (!fn) throw new Error(`no such method: ${d.m}`);
      const v = await fn(...d.a, {
        id: d.id,
        progress: (v) => scope.postMessage({ t: TYPE.PROGRESS, id: d.id, v }),
        get cancelled() { return ctx.cancelled; },
        onCancel: (cb) => { inflight.set(d.id, () => { ctx.cancelled = true; cb?.(); }); },
      });
      if (ctx.cancelled) return;               // 已取消:不要回包(主线程那边已 reject)
      scope.postMessage({ t: TYPE.RES, id: d.id, v }, transferListOf(v));
    } catch (e) {
      scope.postMessage({ t: TYPE.ERR, id: d.id, e: serializeError(e) });
    } finally {
      inflight.delete(d.id);
    }
  });
}

measure.mjs ------ 测量脚本(调度、迁移开销、主线程占用)

javascript 复制代码
// measure.mjs --- 一次跑完全部 Worker/OffscreenCanvas 基准
// 用真实 Chrome(CDP 驱动,零依赖),输出 bench-results.json + 表格 + 断言
// 断言失败 → exit 1(本篇的数字都是「可复现的断言」,不是截图)

import fs from 'node:fs';
import path from 'node:path';
import { launch, newPage } from './cdp.mjs';
import { serve } from './server.mjs';

const ROOT = path.dirname(new URL(import.meta.url).pathname);
const PORT = Number(process.env.DSH_PORT || 0);

const results = { meta: {}, benches: {} };
const failures = [];
const ok = (cond, msg) => { if (!cond) failures.push(msg); return cond; };

function table(rows, cols) {
  const head = '| ' + cols.map((c) => c.label).join(' | ') + ' |';
  const sep = '|' + cols.map(() => '---').join('|') + '|';
  const body = rows.map((r) => '| ' + cols.map((c) => r[c.key]).join(' | ') + ' |');
  return [head, sep, ...body].join('\n');
}

const main = async () => {
  const iso = await serve(ROOT, PORT, { isolate: true });
  const noIso = await serve(ROOT, 0, { isolate: false }); // 对照组:不发 COOP/COEP
  const browser = await launch({ port: 9410 + (PORT % 100) });
  results.meta = {
    date: new Date().toISOString(),
    browser: browser.version,
    origin_isolated: iso.origin,
    origin_plain: noIso.origin,
    node: process.version,
  };
  console.log(`# 浏览器 ${browser.version} | node ${process.version}`);

  const p = await newPage(browser.port);
  await p.send('Runtime.enable');
  const pageErrors = [];
  p.on('Runtime.exceptionThrown', (e) => pageErrors.push(String(e.exceptionDetails?.exception?.description || e.exceptionDetails?.text).slice(0, 200)));

  // ---------- 1. worker 池(放最前:冷机测,减少热降频影响) ----------
  await p.goto(iso.origin + '/pool.html');
  results.benches.pool = await p.eval('window.__run()');
  const pl0 = results.benches.pool;
  console.log('\n## 1. worker 池:加速比、争抢、启动成本\n');
  for (const [kind, rec] of Object.entries(pl0.loads)) {
    console.log(`\n### 负载 ${kind}(单任务 ${kind === 'cpu' ? pl0.cpu_task_ms_single : pl0.mem_task_ms_single} ms)`);
    console.log(table(rec.configs, [
      { key: 'workers', label: 'worker 数' },
      { key: 'wall_ms', label: '墙钟 ms' },
      { key: 'speedup', label: '加速比' },
      { key: 'efficiency', label: '效率' },
      { key: 'per_task_median_ms', label: '单任务中位 ms' },
      { key: 'slowdown_per_task', label: '单任务变慢倍数' },
      { key: 'missed_frames', label: '主线程错过帧' },
    ]));
  }
  console.log('\n启动边际成本:', JSON.stringify(pl0.spawn));
  console.log('堆隔离:', JSON.stringify(pl0.heap_isolation), '| hardwareConcurrency =', pl0.hardwareConcurrency);
  ok(pl0.loads.cpu.configs[1].speedup > 1.2, 'cpu 负载 2 worker 应有加速');
  ok(pl0.loads.cpu.configs[3].speedup > pl0.loads.cpu.configs[1].speedup, '8 worker 快于 2 worker');
  ok(pl0.hardwareConcurrency >= 4, '本机应有多核');
  ok(pl0.heap_isolation.main_delta_mb === 0, 'worker 堆不应计入主线程堆');

  // ---------- 2. 三条通道 + 启动 + 语义 ----------
  await p.goto(iso.origin + '/clone.html');
  results.benches.clone = await p.eval('window.__run()');
  const c = results.benches.clone;
  console.log('\n## 2. postMessage 三条通道(往返一次的中位耗时)\n');
  console.log(table(c.sizes, [
    { key: 'bytes', label: '字节' },
    { key: 'clone_ms', label: 'clone ms' },
    { key: 'transfer_ms', label: 'transfer ms' },
    { key: 'sab_ms', label: 'SAB ms' },
    { key: 'clone_mbps', label: 'clone MB/s' },
    { key: 'clone_over_transfer', label: 'clone/transfer' },
  ]));
  console.log('\n启动成本:', JSON.stringify(c.startup));
  console.log('对象图:', JSON.stringify(c.graph));
  console.log('语义:', JSON.stringify(c.semantics));

  ok(c.sizes.every((r) => r.transfer_ms < 1), 'transfer 应在 1ms 以内(与体积无关)');
  // 小包时三条通道都被「消息调度开销」支配(≈15µs),比不出差异;差异随体积出现
  ok(c.sizes.filter((r) => r.bytes <= 16384).every((r) => r.clone_ms < 0.05 && r.transfer_ms < 0.05), '小包时三条通道都在 50µs 以内');
  ok(c.sizes.filter((r) => r.bytes >= 262144).every((r) => r.clone_over_transfer > 5), '≥256KB 时 clone 至少慢 5 倍');
  ok(c.sizes[c.sizes.length - 1].clone_over_transfer > 100, '64MB 时 clone 应比 transfer 慢 100 倍以上');
  ok(c.semantics.clone_function.startsWith('THROW'), '函数不能过 structuredClone');
  ok(c.semantics.clone_class_proto_lost === 'false/undefined', 'structuredClone 会丢原型');
  ok(c.semantics.clone_sab_same_memory === 42, 'SAB 克隆后仍是同一块内存');
  ok(c.semantics.transfer_detaches_source === 0, 'transfer 后源 buffer 必须分离');
  ok(c.semantics.transfer_twice_throws.startsWith('THROW'), '同一个 buffer 不能转移两次');
  ok(c.semantics.clone_typed_view_geometry_kept === 'Float32Array/4/2/16', '视图的 offset/length 会被保留');

  // ---------- 2. 主线程卡顿:四种姿势 ----------
  await p.goto(iso.origin + '/jank.html');
  results.benches.jank = await p.eval('window.__run()');
  const j = results.benches.jank;
  console.log('\n## 3. 同样的计算量,五种执行姿势\n');
  console.log(table(Object.entries(j.modes).map(([k, v]) => ({ mode: k, ...v })), [
    { key: 'mode', label: '姿势' },
    { key: 'wall_ms', label: '墙钟 ms' },
    { key: 'max_gap_ms', label: '最大帧间隔 ms' },
    { key: 'p95_gap_ms', label: 'p95 帧间隔 ms' },
    { key: 'dropped_over_32ms', label: '>32ms 间隔数' },
    { key: 'missed_frames', label: '错过帧数' },
    { key: 'tbt_ms', label: 'TBT ms' },
  ]));
  const m = j.modes;
  ok(m.worker.wall_ms < m.main_block.wall_ms * 1.15, 'worker 不该比主线程慢');
  ok(m.worker.max_gap_ms < m.main_block.max_gap_ms / 10, 'worker 必须显著改善最大帧间隔');
  ok(m.main_chunked.wall_ms > m.main_block.wall_ms * 1.3, '切片会拖慢总时长');

  // ---------- 3. OffscreenCanvas ----------
  await p.goto(iso.origin + '/offscreen.html');
  results.benches.offscreen = await p.eval('window.__run()');
  const o = results.benches.offscreen;
  console.log('\n## 4. 主线程 canvas vs worker OffscreenCanvas\n');
  for (const [scene, rec] of Object.entries(o.scenes)) {
    console.log(`\n### 场景 ${scene}(每帧 ${rec.n} 个图元,${o.frames} 帧,DPR=${o.dpr})`);
    const rows = [
      { mode: 'main_canvas', draw: rec.main_canvas.draw_median_ms, lat: rec.main_canvas.latency.median_ms, lat95: rec.main_canvas.latency.p95_ms, gap: rec.main_canvas.frames.median_gap_ms, missed: rec.main_canvas.frames.missed_frames, fps: rec.main_canvas.fps, over: rec.main_canvas.over_budget_16ms },
      { mode: 'worker_offscreen', draw: rec.worker_offscreen.draw_median_ms, lat: rec.worker_offscreen.latency.median_ms, lat95: rec.worker_offscreen.latency.p95_ms, gap: rec.worker_offscreen.frames.median_gap_ms, missed: rec.worker_offscreen.frames.missed_frames, fps: '-', over: rec.worker_offscreen.over_budget_16ms },
    ];
    console.log(table(rows, [
      { key: 'mode', label: '模式' },
      { key: 'draw', label: '每帧绘制 ms' },
      { key: 'lat', label: '主线程响应中位 ms' },
      { key: 'lat95', label: 'p95 ms' },
      { key: 'gap', label: '主线程帧间隔 ms' },
      { key: 'missed', label: '错过帧数' },
      { key: 'fps', label: 'fps' },
      { key: 'over', label: '超 16.7ms 帧数' },
    ]));
    if (rec.worker_main_busy) console.log('主线程被占 300ms 时:', JSON.stringify(rec.worker_main_busy));
    // 绘制成本应几乎一致:搬走的是线程,不是算力
    const ratio = rec.worker_offscreen.draw_median_ms / rec.main_canvas.draw_median_ms;
    ok(ratio > 0.8 && ratio < 1.25, `${scene}: worker 绘制成本应与主线程相当(实测比 ${ratio.toFixed(2)})`);
    ok(rec.worker_offscreen.latency.median_ms <= rec.main_canvas.latency.median_ms * 1.1 + 0.05, `${scene}: worker 模式下主线程响应延迟不得更高`);
  }
  ok(o.font_probe && o.font_probe.hasDocument === false, 'worker 里没有 document');

  // ---------- 4. SAB ----------
  await p.goto(iso.origin + '/sab.html');
  results.benches.sab = await p.eval('window.__run()');
  const s = results.benches.sab;
  console.log('\n## 5. SharedArrayBuffer:同一块内存\n');
  console.log(table(Object.entries(s.channels).map(([k, v]) => ({ channel: k, ...v })), [
    { key: 'channel', label: '通道' },
    { key: 'median_ms', label: '中位 ms' },
    { key: 'p95_ms', label: 'p95 ms' },
    { key: 'copies_per_round', label: '往返拷贝次数' },
    { key: 'bytes_moved_mb', label: '往返搬运 MB' },
  ]));
  console.log('纯信号往返:', JSON.stringify(s.atomics.signal_roundtrip_us), '(µs)');
  console.log('隔离条件:', JSON.stringify(s.isolation));
  ok(s.channels.sab_inplace.result_check === true, 'SAB 原地改写的结果必须可见');
  ok(s.isolation.crossOriginIsolated === true, '本页应是 crossOriginIsolated');
  ok(String(s.isolation.canAtomicsWaitOnMain).startsWith('THROW'), '主线程不能 Atomics.wait');

  // 对照:没有 COOP/COEP 的页面
  const p2 = await newPage(browser.port);
  await p2.goto(noIso.origin + '/isolation.html');
  results.benches.isolation_control = await p2.eval('window.__probe()');
  console.log('无 COOP/COEP 对照组:', JSON.stringify(results.benches.isolation_control));
  ok(results.benches.isolation_control.crossOriginIsolated === false, '对照组不应是 crossOriginIsolated');
  ok(String(results.benches.isolation_control.canConstructSAB).startsWith('THROW'), '没有隔离头时 SharedArrayBuffer 应不可用');
  p2.close();


  // ---------- 输出 ----------
  if (pageErrors.length) { results.meta.page_errors = pageErrors; console.log('\n页面错误:', pageErrors.slice(0, 5)); }
  fs.writeFileSync(path.join(ROOT, 'bench-results.json'), JSON.stringify(results, null, 1));
  console.log('\n结果已写入 bench-results.json');

  console.log(`\n断言:${failures.length === 0 ? '全部通过' : '失败 ' + failures.length + ' 条'}`);
  failures.forEach((f) => console.log('  x ' + f));

  p.close();
  browser.kill();
  iso.server.close();
  noIso.server.close();
  process.exit(failures.length ? 1 : 0);
};

main().catch((e) => {
  console.error('基准失败:', e.message);
  process.exit(2);
});

cdp.mjs ------ CDP 驱动程序(真实 Chrome 里跑基准)

javascript 复制代码
// cdp.mjs --- 零依赖 Chrome DevTools Protocol 客户端
// Node 22 内置 global.WebSocket,所以不需要 puppeteer/playwright
// 用途: 驱动真实 Chrome 跑 Worker / OffscreenCanvas / SAB 基准

import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import fs from 'node:fs';

const CH_CANDIDATES = [
  `${process.env.HOME}/Library/Caches/ms-playwright/chromium-1228/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`,
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
];

export function findChrome() {
  for (const p of CH_CANDIDATES) if (fs.existsSync(p)) return p;
  throw new Error('找不到 Chrome,请设置 CHROME_PATH');
}

/** 启动 headless Chrome,返回 { proc, port, kill } */
export async function launch({ port = 9333, userDataDir, headless = true } = {}) {
  const exe = process.env.CHROME_PATH || findChrome();
  const dir = userDataDir || fs.mkdtempSync('/tmp/d32-chrome-');
  const args = [
    `--user-data-dir=${dir}`,
    `--remote-debugging-port=${port}`,
    '--no-first-run',
    '--no-default-browser-check',
    '--disable-extensions',
    '--disable-background-timer-throttling',
    '--disable-renderer-backgrounding',
    '--disable-backgrounding-occluded-windows',
    '--disable-features=CalculateNativeWinOcclusion',
    '--window-size=1280,800',
  ];
  if (headless) args.push('--headless=new');
  const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
  proc.stderr.on('data', () => {});

  for (let i = 0; i < 250; i++) {
    try {
      const r = await fetch(`http://127.0.0.1:${port}/json/version`);
      if (r.ok) {
        const v = await r.json();
        await sleep(150);
        return { proc, port, version: v['Browser'], kill: () => killProc(proc) };
      }
    } catch {}
    await sleep(100);
  }
  killProc(proc);
  throw new Error('Chrome devtools 端口未就绪');
}

function killProc(proc) {
  try { proc.kill('SIGKILL'); } catch {}
}

/** 新建标签页并连上它 */
export async function newPage(port) {
  const r = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' });
  const target = await r.json();
  return connect(target.webSocketDebuggerUrl);
}

/** 极简 CDP 会话 */
export async function connect(url) {
  const ws = new WebSocket(url);
  let id = 0;
  const pending = new Map();
  const listeners = new Map();

  await new Promise((res, rej) => {
    ws.onopen = res;
    ws.onerror = (e) => rej(new Error('ws error ' + e.message));
  });

  ws.onmessage = (ev) => {
    const msg = JSON.parse(ev.data);
    if (msg.id && pending.has(msg.id)) {
      const { res, rej } = pending.get(msg.id);
      pending.delete(msg.id);
      msg.error ? rej(new Error(`${msg.error.message} ${JSON.stringify(msg.error.data ?? '')}`)) : res(msg.result);
    } else if (msg.method) {
      const fns = listeners.get(msg.method) || [];
      for (const f of fns) f(msg.params);
    }
  };

  const send = (method, params = {}) =>
    new Promise((res, rej) => {
      const mid = ++id;
      pending.set(mid, { res, rej });
      ws.send(JSON.stringify({ id: mid, method, params }));
    });

  const once = (method, timeout = 30000) =>
    new Promise((res, rej) => {
      const t = setTimeout(() => rej(new Error('timeout ' + method)), timeout);
      const fns = listeners.get(method) || [];
      const fn = (p) => {
        clearTimeout(t);
        listeners.set(method, (listeners.get(method) || []).filter((x) => x !== fn));
        res(p);
      };
      fns.push(fn);
      listeners.set(method, fns);
    });

  return {
    send,
    once,
    on: (method, fn) => {
      const fns = listeners.get(method) || [];
      fns.push(fn);
      listeners.set(method, fns);
    },
    async goto(url) {
      await send('Page.enable');
      const loaded = once('Page.loadEventFired', 20000);
      await send('Page.navigate', { url });
      await loaded;
    },
    /** 在页面里求值(支持 async,返回可序列化值) */
    async eval(expression, { awaitPromise = true, timeout = 300000 } = {}) {
      const r = await send('Runtime.evaluate', {
        expression,
        awaitPromise,
        returnByValue: true,
        allowUnsafeEvalBlockedByCSP: true,
      });
      if (r.exceptionDetails) {
        const d = r.exceptionDetails;
        throw new Error('page error: ' + (d.exception?.description || d.text));
      }
      return r.result.value;
    },
    close() {
      try { ws.close(); } catch {}
    },
  };
}

server.mjs ------ 本地静态服务器

javascript 复制代码
// server.mjs --- 静态服务器,带 COOP/COEP(SharedArrayBuffer 的硬前提)
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.mjs': 'text/javascript; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.wasm': 'application/wasm',
  '.png': 'image/png',
};

export function serve(root, port = 0, opts = {}) {
  const isolate = opts.isolate !== false; // false = 不发 COOP/COEP(对照组)
  const server = http.createServer((req, res) => {
    const url = new URL(req.url, 'http://x');
    let p = path.join(root, decodeURIComponent(url.pathname));
    if (url.pathname === '/') p = path.join(root, 'index.html');
    fs.readFile(p, (err, buf) => {
      if (err) { res.writeHead(404).end('not found: ' + url.pathname); return; }
      const headers = {
        'Content-Type': MIME[path.extname(p)] || 'application/octet-stream',
        'Cache-Control': 'no-store',
      };
      if (isolate) {
        // 这两个头是 SharedArrayBuffer 的必要条件(crossOriginIsolated)
        headers['Cross-Origin-Opener-Policy'] = 'same-origin';
        headers['Cross-Origin-Embedder-Policy'] = 'require-corp';
        headers['Cross-Origin-Resource-Policy'] = 'same-origin';
      }
      res.writeHead(200, headers);
      res.end(buf);
    });
  });
  return new Promise((resolve) => {
    server.listen(port, '127.0.0.1', () => {
      const addr = server.address();
      resolve({ server, port: addr.port, origin: `http://127.0.0.1:${addr.port}` });
    });
  });
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const port = Number(process.argv[2] || 8080);
  const { origin } = await serve(path.dirname(new URL(import.meta.url).pathname), port);
  console.log('serving', origin, '(COOP/COEP on)');
}

bench-pool.mjs ------ 基准:Worker 池 vs 单线程

javascript 复制代码
// bench-pool.mjs --- 并行不是免费的
// 同构任务池,两种负载各跑一遍:
//   cpu: 纯算术(ALU 受限)      mem: 素数筛(内存带宽受限,每任务分配 16MB)
// 池大小 1/2/4/8,worker 用「拉取」方式取活

const TASKS = 8;
const CONFIGS = [1, 2, 4, 8];
const MEM_LIMIT = 16_000_000;
const TARGET_TASK_MS = 75;

function frameProbe() {
  const gaps = [];
  let last = performance.now();
  let running = true;
  const onFrame = () => {
    if (!running) return;
    const now = performance.now();
    gaps.push(now - last);
    last = now;
    requestAnimationFrame(onFrame);
  };
  requestAnimationFrame(onFrame);
  return {
    stop() {
      running = false;
      const s = [...gaps].sort((a, b) => a - b);
      const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
      return {
        frames: gaps.length,
        median_gap_ms: +pick(0.5).toFixed(2),
        p95_gap_ms: +pick(0.95).toFixed(2),
        max_gap_ms: +gaps.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
        dropped_over_32ms: gaps.filter((g) => g > 32).length,
        missed_frames: gaps.reduce((acc, g) => acc + Math.max(0, Math.round(g / 16.667) - 1), 0),
      };
    },
  };
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const spawn1 = () => new Worker(new URL('./workers/pool.mjs', import.meta.url), { type: 'module' });
const booted = (w) => new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'boot') { w.removeEventListener('message', h); r(); } }));

/** 在 n 个 worker 上用拉取模型跑 TASKS 个任务 */
async function runPool(n, makeTask) {
  const workers = [];
  for (let i = 0; i < n; i++) {
    const w = spawn1();
    workers.push(w);
    await booted(w);
  }
  const t0 = performance.now();
  let next = 0;
  const perTask = [];
  await new Promise((res) => {
    let done = 0;
    workers.forEach((w) => {
      const onMsg = (e) => {
        if (e.data.cmd !== 'result') return;
        perTask.push(e.data.ms);
        if (++done === TASKS) return res();
        if (next < TASKS) w.postMessage(makeTask(next++));
      };
      w.addEventListener('message', onMsg);
      if (next < TASKS) w.postMessage(makeTask(next++));
    });
  });
  const wall = performance.now() - t0;
  workers.forEach((w) => w.terminate());
  return { wall, perTask };
}

export async function run() {
  const out = { tasks: TASKS, target_task_ms: TARGET_TASK_MS, hardwareConcurrency: navigator.hardwareConcurrency, loads: {}, heap_isolation: {} };

  // 标定纯算术任务的 iters
  let iters = 2_000_000;
  {
    const w = spawn1();
    await booted(w);
    for (let r = 0; r < 8; r++) {
      const got = await new Promise((res) => {
        w.addEventListener('message', function h(e) { if (e.data.cmd === 'result') { w.removeEventListener('message', h); res(e.data); } });
        w.postMessage({ cmd: 'task', kind: 'cpu', seq: 0, iters });
      });
      if (Math.abs(got.ms - TARGET_TASK_MS) / TARGET_TASK_MS < 0.12) break;
      iters = Math.max(10000, Math.round(iters * (TARGET_TASK_MS / Math.max(0.5, got.ms))));
    }
    const single = await new Promise((res) => {
      w.addEventListener('message', function h(e) { if (e.data.cmd === 'result') { w.removeEventListener('message', h); res(e.data); } });
      w.postMessage({ cmd: 'task', kind: 'cpu', seq: 0, iters });
    });
    out.cpu_iters = iters;
    out.cpu_task_ms_single = +single.ms.toFixed(1);
    // 内存型单任务基准
    const single2 = await new Promise((res) => {
      w.addEventListener('message', function h(e) { if (e.data.cmd === 'result') { w.removeEventListener('message', h); res(e.data); } });
      w.postMessage({ cmd: 'task', kind: 'mem', seq: 0, limit: MEM_LIMIT });
    });
    out.mem_task_ms_single = +single2.ms.toFixed(1);
    out.mem_task_result = single2.result;
    w.terminate();
  }

  for (const kind of ['cpu', 'mem']) {
    out.loads[kind] = { configs: [] };
    for (const n of CONFIGS) {
      await sleep(350);
      const fp = frameProbe();
      const makeTask = (seq) => (kind === 'cpu' ? { cmd: 'task', kind, seq, iters } : { cmd: 'task', kind, seq, limit: MEM_LIMIT });
      const { wall, perTask } = await runPool(n, makeTask);
      const frames = fp.stop();
      await sleep(120);
      out.loads[kind].configs.push({
        workers: n,
        wall_ms: +wall.toFixed(1),
        tasks_run: perTask.length,
        per_task_median_ms: +[...perTask].sort((a, b) => a - b)[Math.floor(perTask.length / 2)].toFixed(1),
        sum_cpu_ms: +perTask.reduce((a, b) => a + b, 0).toFixed(1),
        frames,
        missed_frames: frames.missed_frames,
      });
    }
    const base = out.loads[kind].configs[0].wall_ms;
    out.loads[kind].configs.forEach((c) => {
      c.speedup = +(base / c.wall_ms).toFixed(2);
      c.efficiency = +(c.speedup / Math.min(c.workers, TASKS)).toFixed(2);
      // 单任务在并发下变慢了几倍(衡量资源争抢)
      c.slowdown_per_task = +(c.per_task_median_ms / (kind === 'cpu' ? out.cpu_task_ms_single : out.mem_task_ms_single)).toFixed(2);
    });
  }

  // 启动边际成本:0→N 个 worker 的 boot 时间
  {
    const rows = [];
    for (const n of [1, 4, 8]) {
      const t0 = performance.now();
      const ws = [];
      for (let i = 0; i < n; i++) { const w = spawn1(); ws.push(w); await booted(w); }
      rows.push({ workers: n, boot_ms: +(performance.now() - t0).toFixed(1), per_worker_ms: +((performance.now() - t0) / n).toFixed(2) });
      ws.forEach((w) => w.terminate());
      await sleep(100);
    }
    out.spawn = rows;
  }

  // 堆隔离:worker 吃 200MB,主线程 heap 不动
  {
    const before = performance.memory ? performance.memory.usedJSHeapSize : null;
    const w = new Worker(new URL('./workers/hog.mjs', import.meta.url), { type: 'module' });
    const got = await new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'allocated') { w.removeEventListener('message', h); r(e.data); } }));
    const after = performance.memory ? performance.memory.usedJSHeapSize : null;
    out.heap_isolation = {
      worker_allocated_mb: got.mb,
      main_heap_before_mb: before != null ? +(before / 1048576).toFixed(1) : null,
      main_heap_after_mb: after != null ? +(after / 1048576).toFixed(1) : null,
      main_delta_mb: before != null ? +((after - before) / 1048576).toFixed(1) : null,
    };
    w.terminate();
  }
  return out;
}

bench-sab.mjs ------ 基准:SharedArrayBuffer 零拷贝

javascript 复制代码
// bench-sab.mjs --- 第三通道:SharedArrayBuffer 就地改写 + Atomics 同步
// 对比 4MB 数据三种处理姿势:clone 往返 / transfer 往返 / SAB 原地改写

const SIZE = 4 * 1024 * 1024; // 4MB = 1M 个 float32
const FLOATS = SIZE / 4;
const ITERS = 60;

function newWorker(url = './workers/sab-worker.mjs') {
  return new Worker(new URL(url, import.meta.url), { type: 'module' });
}
function boot(w) {
  return new Promise((res) => w.addEventListener('message', function h(e) {
    if (e.data.cmd === 'boot') { w.removeEventListener('message', h); res(); }
  }));
}
const pct = (a, p) => { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0; };

export async function run() {
  const out = { size_bytes: SIZE, floats: FLOATS, iters: ITERS, channels: {}, atomics: {}, isolation: {} };

  // ---- 隔离条件探查:没有 COOP/COEP 就没有 SharedArrayBuffer ----
  out.isolation = {
    crossOriginIsolated: crossOriginIsolated,
    hasSharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
    hasAtomics: typeof Atomics !== 'undefined',
    canAtomicsWaitOnMain: (() => {
      if (typeof Atomics === 'undefined') return 'no Atomics';
      const a = typeof SharedArrayBuffer !== 'undefined' ? new Int32Array(new SharedArrayBuffer(4)) : new Int32Array(1);
      try { Atomics.wait(a, 0, 0, 0); return 'allowed'; }
      catch (e) { return 'THROW ' + e.name; }
    })(),
    // 主线程只能用 waitAsync
    hasWaitAsync: typeof Atomics !== 'undefined' && typeof Atomics.waitAsync === 'function',
  };

  // 三条通道做同一份活:把 1M 个 float 逐个 sqrt 后拿回结果
  // ---- 通道一:clone 往返(两份数据,主线程收到副本) ----
  {
    const w = newWorker('./workers/echo.mjs');
    await boot(w);
    const src = new Float32Array(FLOATS).fill(1.5);
    const times = [];
    for (let i = 0; i < ITERS; i++) {
      const t0 = performance.now();
      await new Promise((res) => {
        const h = (e) => { if (e.data.cmd === 'pong') { w.removeEventListener('message', h); res(); } };
        w.addEventListener('message', h);
        w.postMessage({ cmd: 'work', seq: i, payload: src, transferBack: false });
      });
      times.push(performance.now() - t0);
    }
    out.channels.clone = {
      median_ms: +pct(times, 0.5).toFixed(3),
      p95_ms: +pct(times, 0.95).toFixed(3),
      copies_per_round: 2,
      bytes_moved_mb: +(SIZE * 2 / 1e6).toFixed(1),
    };
    w.terminate();
  }

  // ---- 通道二:transfer 往返(零拷贝搬家,但对象身份每次都是新的) ----
  {
    const w = newWorker('./workers/echo.mjs');
    await boot(w);
    let cur = new Float32Array(FLOATS).fill(1.5);
    const times = [];
    for (let i = 0; i < ITERS; i++) {
      const sent = cur;
      const t0 = performance.now();
      const got = await new Promise((res) => {
        const h = (e) => { if (e.data.cmd === 'pong') { w.removeEventListener('message', h); res(e.data.payload); } };
        w.addEventListener('message', h);
        w.postMessage({ cmd: 'work', seq: i, payload: sent, transferBack: true }, [sent.buffer]);
      });
      times.push(performance.now() - t0);
      cur = got;
    }
    out.channels.transfer = {
      median_ms: +pct(times, 0.5).toFixed(3),
      p95_ms: +pct(times, 0.95).toFixed(3),
      copies_per_round: 0,
      bytes_moved_mb: 0,
    };
    w.terminate();
  }

  // ---- 通道三:SAB 原地改写(同一块内存,主线程读到 worker 的结果) ----
  {
    const w = newWorker('./workers/sab-worker.mjs');
    await boot(w);
    const sab = new SharedArrayBuffer(SIZE + 8);
    const buf = new Float32Array(sab, 0, FLOATS);
    const ctrl = new Int32Array(sab, SIZE, 2); // [0]=seq  [1]=done
    buf.fill(1.5);
    const times = [];
    for (let i = 0; i < ITERS; i++) {
      Atomics.store(ctrl, 1, 0);
      const t0 = performance.now();
      const waited = Atomics.waitAsync(ctrl, 1, 0, 5000);
      w.postMessage({ cmd: 'transform', sab, floats: FLOATS, ctrlOffset: SIZE, seq: i });
      if (waited.async) await waited.value;
      times.push(performance.now() - t0);
    }
    // 就地改写是否真的生效:全部元素应变成 sqrt(1.5)
    out.channels.sab_inplace = {
      median_ms: +pct(times, 0.5).toFixed(3),
      p95_ms: +pct(times, 0.95).toFixed(3),
      copies_per_round: 0,
      bytes_moved_mb: 0,
    };
    // 结果校验:单独跑一次全新数据
    {
      buf.fill(1.5);
      Atomics.store(ctrl, 1, 0);
      const waited = Atomics.waitAsync(ctrl, 1, 0, 5000);
      w.postMessage({ cmd: 'transform', sab, floats: FLOATS, ctrlOffset: SIZE, seq: -1 });
      if (waited.async) await waited.value;
      out.channels.sab_inplace.result_check = +buf[0].toFixed(6) === +Math.sqrt(1.5).toFixed(6);
      out.channels.sab_inplace.transform_ms = +(performance.now() - (performance.now() - 0)).toFixed(0); // 占位,下面重测
      buf.fill(2.25);
      Atomics.store(ctrl, 1, 0);
      const t0 = performance.now();
      const w2 = Atomics.waitAsync(ctrl, 1, 0, 5000);
      w.postMessage({ cmd: 'transform', sab, floats: FLOATS, ctrlOffset: SIZE, seq: -2 });
      if (w2.async) await w2.value;
      out.channels.sab_inplace.pure_transform_ms = +(performance.now() - t0).toFixed(3);
    }
    w.terminate();
  }

  // ---- 纯信号:只做一次握手,payload 为 0 字节 ----
  {
    const w = newWorker('./workers/sab-worker.mjs');
    await boot(w);
    const sab = new SharedArrayBuffer(16);
    const ctrl = new Int32Array(sab);
    const times = [];
    for (let i = 0; i < 300; i++) {
      Atomics.store(ctrl, 1, 0);
      const t0 = performance.now();
      const waited = Atomics.waitAsync(ctrl, 1, 0, 5000);
      w.postMessage({ cmd: 'signal', sab });
      if (waited.async) await waited.value;
      times.push((performance.now() - t0) * 1000); // µs
    }
    out.atomics.signal_roundtrip_us = {
      median_us: +pct(times, 0.5).toFixed(2),
      p95_us: +pct(times, 0.95).toFixed(2),
      min_us: +Math.min(...times).toFixed(2),
    };
    w.terminate();
  }
  return out;
}

bench-offscreen.mjs ------ 基准:OffscreenCanvas 渲染

javascript 复制代码
// bench-offscreen.mjs --- 同一套绘制场景,主线程 canvas vs worker OffscreenCanvas
// 三个口径:① 每帧绘制耗时(算力) ② 主线程帧间隔(会不会掉帧) ③ 主线程响应延迟(输入会不会卡)
// 注意:headless 下 worker 的 rAF 不受 vsync 限制,所以「帧率」不可比,只比「每帧成本」与「主线程是否被占」

const SCENES = [3000, 12000];   // 每帧图元数量
const FRAMES = 120;
const W = 800, H = 600;

/** 主线程响应延迟:每 50ms 发一次 MessageChannel 消息,测它多久被处理 */
function latencyProbe(intervalMs = 50) {
  const ch = new MessageChannel();
  const samples = [];
  let running = true;
  let lastSent = 0;
  ch.port1.onmessage = () => { if (running) samples.push(performance.now() - lastSent); };
  const timer = setInterval(() => {
    lastSent = performance.now();
    try { ch.port2.postMessage(0); } catch {}
  }, intervalMs);
  return {
    stop() {
      running = false;
      clearInterval(timer);
      const s = [...samples].sort((a, b) => a - b);
      const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
      return {
        samples: s.length,
        median_ms: +pick(0.5).toFixed(2),
        p95_ms: +pick(0.95).toFixed(2),
        max_ms: +s.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
      };
    },
  };
}

function frameProbe() {
  const gaps = [];
  const longtasks = [];
  let last = performance.now();
  let running = true;
  const onFrame = () => {
    if (!running) return;
    const now = performance.now();
    gaps.push(now - last);
    last = now;
    requestAnimationFrame(onFrame);
  };
  requestAnimationFrame(onFrame);
  const po = new PerformanceObserver((l) => { for (const e of l.getEntries()) longtasks.push(e.duration); });
  try { po.observe({ type: 'longtask' }); } catch {}
  return {
    stop() {
      running = false;
      try { po.disconnect(); } catch {}
      const s = [...gaps].sort((a, b) => a - b);
      const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
      return {
        frames: gaps.length,
        median_gap_ms: +pick(0.5).toFixed(2),
        p95_gap_ms: +pick(0.95).toFixed(2),
        max_gap_ms: +gaps.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
        dropped_over_32ms: gaps.filter((g) => g > 32).length,
        missed_frames: gaps.reduce((acc, g) => acc + Math.max(0, Math.round(g / 16.667) - 1), 0),
        longtasks: longtasks.length,
        tbt_ms: +longtasks.reduce((a, d) => a + Math.max(0, d - 50), 0).toFixed(1),
      };
    },
  };
}

function buildCanvas() {
  const c = document.createElement('canvas');
  c.width = Math.round(W * devicePixelRatio);
  c.height = Math.round(H * devicePixelRatio);
  c.style.width = W + 'px';
  c.style.height = H + 'px';
  document.body.appendChild(c);
  return c;
}

function scene(ctx, t, dpr, w, h, n) {
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.fillStyle = '#0b1020';
  ctx.fillRect(0, 0, w * dpr, h * dpr);
  ctx.scale(dpr, dpr);
  for (let i = 0; i < n; i++) {
    const a = (i * 0.618 + t * 0.0008) % 6.283;
    const x = w / 2 + Math.cos(a) * (Math.min(w, h) * 0.34) * ((i / n) + 0.15);
    const y = h / 2 + Math.sin(a * 1.7) * (Math.min(w, h) * 0.34) * ((i / n) + 0.15);
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(a);
    ctx.fillStyle = `hsl(${(i * 7) % 360} 70% ${40 + (i % 20)}%)`;
    ctx.beginPath();
    ctx.roundRect(-6, -6, 12 + (i % 5), 12 + (i % 5), 3);
    ctx.fill();
    ctx.restore();
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const stat = (times) => {
  const s = [...times].sort((a, b) => a - b);
  const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
  return {
    draw_median_ms: +pick(0.5).toFixed(3),
    draw_p95_ms: +pick(0.95).toFixed(3),
    draw_total_ms: +times.reduce((a, b) => a + b, 0).toFixed(1),
    over_budget_16ms: times.filter((t) => t > 16.67).length,
  };
};

export async function run() {
  const out = { frames: FRAMES, size: { w: W, h: H }, dpr: devicePixelRatio, scenes: {}, font_probe: null };
  const w = new Worker(new URL('./workers/render-worker.mjs', import.meta.url), { type: 'module' });
  let fontProbe = null;

  for (const N of SCENES) {
    const rec = { n: N };

    // ---------- A. 主线程 canvas:绘制与输入、事件、布局抢同一条线程 ----------
    {
      document.body.innerHTML = '';
      const c = buildCanvas();
      const ctx = c.getContext('2d', { alpha: false });
      const fp = frameProbe();
      const lp = latencyProbe();
      await sleep(250);
      const times = [];
      const wall0 = performance.now();
      await new Promise((res) => {
        let n = 0;
        const loop = () => {
          const t0 = performance.now();
          scene(ctx, t0, devicePixelRatio, W, H, N);
          times.push(performance.now() - t0);
          if (++n < FRAMES) requestAnimationFrame(loop);
          else res();
        };
        requestAnimationFrame(loop);
      });
      const wall = performance.now() - wall0;
      rec.main_canvas = { ...stat(times), wall_ms: +wall.toFixed(1), fps: +(FRAMES / (wall / 1000)).toFixed(1), latency: lp.stop(), frames: fp.stop() };
      c.remove();
    }

    // ---------- B. worker + OffscreenCanvas:绘制搬到另一个线程 ----------
    {
      document.body.innerHTML = '';
      const c = buildCanvas();
      const t0 = performance.now();
      const offscreen = c.transferControlToOffscreen();
      rec.handoff_ms = +(performance.now() - t0).toFixed(3);
      const init = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'inited') { w.removeEventListener('message', h); res(e.data.fontProbe); } }));
      w.postMessage({ cmd: 'init', canvas: offscreen, dpr: devicePixelRatio, w: W, h: H, n: N, frames: FRAMES }, [offscreen]);
      fontProbe = await init;

      const fp = frameProbe();
      const lp = latencyProbe();
      await sleep(250);
      const done = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'done') { w.removeEventListener('message', h); res(e.data); } }));
      w.postMessage({ cmd: 'run' });
      const r = await done;
      rec.worker_offscreen = { ...stat(r.times), worker_wall_ms: +r.wall.toFixed(1), latency: lp.stop(), frames: fp.stop() };
      c.remove();
    }

    // ---------- C. worker 继续画,主线程被 300ms 同步任务占住 ----------
    if (N === SCENES[0]) {
      document.body.innerHTML = '';
      const c = buildCanvas();
      const offscreen = c.transferControlToOffscreen();
      // 先挂监听再发 init(顺序反了就是死锁)
      const inited = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'inited') { w.removeEventListener('message', h); res(); } }));
      w.postMessage({ cmd: 'init', canvas: offscreen, dpr: devicePixelRatio, w: W, h: H, n: N, frames: FRAMES }, [offscreen]);
      await inited;
      const lp = latencyProbe();
      await sleep(250);
      const done = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'done') { w.removeEventListener('message', h); res(e.data); } }));
      w.postMessage({ cmd: 'run' });
      const t1 = performance.now();
      let x = 1;
      while (performance.now() - t1 < 300) x = (x * 31 + 7) % 2147483647; // 主线程卡死 300ms
      const r = await done;
      rec.worker_main_busy = { main_busy_ms: 300, worker_frames: r.frames, worker_wall_ms: +r.wall.toFixed(1), worker_draw_median_ms: +stat(r.times).draw_median_ms, latency: lp.stop() };
      c.remove();
    }
    out.scenes['n' + N] = rec;
  }

  out.font_probe = fontProbe;
  w.terminate();
  return out;
}

bench-jank.mjs ------ 基准:主线程卡顿(jank)

javascript 复制代码
// bench-jank.mjs --- 「搬走计算」到底买到了什么:三种姿势的帧稳定性对比
// A. 主线程一次算完(block)  B. 主线程切片(chunk 5ms)  C. Worker
// 关键指标不是总耗时,而是主线程的帧间隔分布与长任务

const BATCHES = 24;        // 24 批 × ~25ms ≈ 600ms 的总计算量
const TARGET_MS = 25;      // 每批目标耗时
const CHUNK_MS = 5;        // 切片粒度

/** 不可被优化掉的整数运算内核 */
function kernel(iters, seed) {
  let x = seed;
  for (let i = 0; i < iters; i++) x = (x * 31 + i) % 2147483647;
  return x;
}

/** 标定 iterations,使内核耗时 ≈ targetMs */
function calibrate(targetMs) {
  let iters = 200000;
  for (let round = 0; round < 12; round++) {
    const t0 = performance.now();
    kernel(iters, 7);
    const ms = performance.now() - t0;
    if (ms <= 0.001) { iters *= 8; continue; }
    const scale = targetMs / ms;
    if (Math.abs(scale - 1) < 0.08) break;
    iters = Math.max(1000, Math.round(iters * scale));
  }
  return iters;
}

/** 记录 rAF 帧间隔 + 长任务 */
function startProbe() {
  const gaps = [];
  const longtasks = [];
  let last = performance.now();
  let running = true;
  const onFrame = () => {
    if (!running) return;
    const now = performance.now();
    gaps.push(now - last);
    last = now;
    requestAnimationFrame(onFrame);
  };
  requestAnimationFrame(onFrame);
  const po = new PerformanceObserver((list) => {
    for (const e of list.getEntries()) longtasks.push(e.duration);
  });
  try { po.observe({ type: 'longtask', buffered: false }); } catch {}
  return {
    stop() {
      running = false;
      try { po.disconnect(); } catch {}
      const sorted = [...gaps].sort((a, b) => a - b);
      const pick = (p) => sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p))] || 0;
      const dropped = gaps.filter((g) => g > 32).length;
      // 错过帧数:每个 >16.67ms 的间隔折算成少画了几帧
      const missed = gaps.reduce((acc, g) => acc + Math.max(0, Math.round(g / 16.667) - 1), 0);
      return {
        frames: gaps.length,
        median_gap_ms: +pick(0.5).toFixed(2),
        p95_gap_ms: +pick(0.95).toFixed(2),
        max_gap_ms: +gaps.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
        dropped_over_32ms: dropped,
        dropped_ratio: gaps.length ? +(dropped / gaps.length * 100).toFixed(1) : 0,
        missed_frames: missed,
        longtasks: longtasks.length,
        longest_task_ms: +longtasks.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
        // TBT 口径:长任务超过 50ms 的部分累加
        tbt_ms: +longtasks.reduce((s, d) => s + Math.max(0, d - 50), 0).toFixed(1),
      };
    },
  };
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const yieldToHost = () =>
  typeof scheduler !== 'undefined' && scheduler.yield ? scheduler.yield() : sleep(0);

/** 主线程三种执行姿势 */
async function runMain(iters, mode) {
  const t0 = performance.now();
  if (mode === 'main_block') {
    // 一口气跑完:一个巨大的同步任务
    for (let b = 0; b < BATCHES; b++) kernel(iters, b + 1);
  } else if (mode === 'main_batch_yield') {
    // 每批之间让出一次(最常见的「异步化」写法)
    for (let b = 0; b < BATCHES; b++) {
      kernel(iters, b + 1);
      await sleep(0);
    }
  } else if (mode === 'main_chunked_yield') {
    // 切片 + scheduler.yield(优先让给渲染与输入)
    const parts = Math.max(1, Math.ceil(TARGET_MS / CHUNK_MS));
    const per = Math.max(1, Math.round(iters / parts));
    for (let b = 0; b < BATCHES; b++) {
      for (let p = 0; p < parts; p++) {
        kernel(per, b + 1);
        await yieldToHost();
      }
    }
  } else {
    const parts = Math.max(1, Math.ceil(TARGET_MS / CHUNK_MS));
    const per = Math.max(1, Math.round(iters / parts));
    for (let b = 0; b < BATCHES; b++) {
      for (let p = 0; p < parts; p++) {
        kernel(per, b + 1);
        await sleep(0); // 让出主线程,交给渲染与输入
      }
    }
  }
  return performance.now() - t0;
}

/** C: 同样的工作量,全部丢给 worker */
async function runWorker(iters) {
  const w = new Worker(new URL('./workers/echo.mjs', import.meta.url), { type: 'module' });
  await new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'boot') { w.removeEventListener('message', h); r(); } }));
  const t0 = performance.now();
  await new Promise((res) => {
    let done = 0;
    const h = (e) => {
      if (e.data.cmd !== 'burned') return;
      if (++done === BATCHES) { w.removeEventListener('message', h); res(); }
    };
    w.addEventListener('message', h);
    for (let b = 0; b < BATCHES; b++) w.postMessage({ cmd: 'burn', seq: b, iter: iters, seed: b + 1 });
  });
  const ms = performance.now() - t0;
  w.terminate();
  return ms;
}

export async function run() {
  const iters = calibrate(TARGET_MS);
  const out = { iters, target_ms: TARGET_MS, batches: BATCHES, modes: {} };

  // 预热一次,避免 JIT 首次编译混入
  kernel(iters, 1);

  for (const mode of ['main_block', 'main_batch_yield', 'main_chunked', 'main_chunked_yield', 'worker']) {
    await sleep(300); // 让上一轮的帧探针收拾干净
    const probe = startProbe();
    let wall;
    if (mode === 'worker') wall = await runWorker(iters);
    else wall = await runMain(iters, mode);
    await sleep(200);
    const frames = probe.stop();
    out.modes[mode] = { wall_ms: +wall.toFixed(1), ...frames };
  }
  return out;
}

bench-clone.mjs ------ 基准:structuredClone 迁移成本

javascript 复制代码
// bench-clone.mjs --- postMessage 三条所有权通道 + worker 启动成本 + 语义矩阵
// clone = 两份数据 / transfer = 移动所有权 / SAB = 同一块内存
// 计的是「主线程 postMessage → worker 收发 → 回到主线程」的往返墙钟时间

const SIZES = [1024, 16 * 1024, 256 * 1024, 1024 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024, 64 * 1024 * 1024];
const BUDGET = 128 * 1024 * 1024; // 每档至少搬 128MB,保证信噪比

function newWorker() {
  return new Worker(new URL('./workers/echo.mjs', import.meta.url), { type: 'module' });
}

function waitBoot(w) {
  return new Promise((res) => {
    const t0 = performance.now();
    w.addEventListener('message', function h(e) {
      if (e.data.cmd === 'boot') {
        w.removeEventListener('message', h);
        res(performance.now() - t0);
      }
    });
  });
}

/** 往返一次:clone / transfer 通道(等消息回显),返回耗时与回包 */
function rtt(w, msg, transfer) {
  return new Promise((res, rej) => {
    const t0 = performance.now();
    const h = (e) => {
      if (e.data.seq !== msg.seq) return;
      w.removeEventListener('message', h);
      res({ ms: performance.now() - t0, data: e.data });
    };
    w.addEventListener('message', h);
    setTimeout(() => rej(new Error('rtt timeout')), 30000);
    w.postMessage(msg, transfer || []);
  });
}

/** SAB 通道:主线程 Atomics.waitAsync 等旗标,worker 只做 notify(零拷贝) */
function sabRtt(w, msg, flag, timeout = 30000) {
  return new Promise((res, rej) => {
    Atomics.store(flag, 0, 0);
    const t0 = performance.now();
    const w0 = Atomics.waitAsync(flag, 0, 0, timeout);
    w.postMessage(msg, []);
    if (w0.async) {
      w0.value.then((how) => (how === 'timed-out' ? rej(new Error('sab timeout')) : res(performance.now() - t0)));
    } else res(performance.now() - t0);
  });
}

function pct(arr, p) {
  const s = [...arr].sort((a, b) => a - b);
  return s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))];
}
const med = (a) => +pct(a, 0.5).toFixed(4);

export async function run() {
  const out = { sizes: [], startup: {}, semantics: {}, graph: {} };

  // ---- 启动成本:module worker vs classic worker,各 15 次取中位数 ----
  for (const kind of ['module', 'classic']) {
    const times = [];
    for (let i = 0; i < 15; i++) {
      const w = kind === 'module'
        ? new Worker(new URL('./workers/echo.mjs', import.meta.url), { type: 'module' })
        : new Worker(new URL('./workers/echo-classic.js', import.meta.url));
      times.push(await waitBoot(w));
      w.terminate();
    }
    out.startup[kind + '_median_ms'] = +pct(times, 0.5).toFixed(3);
    out.startup[kind + '_min_ms'] = +Math.min(...times).toFixed(3);
    out.startup[kind + '_p90_ms'] = +pct(times, 0.9).toFixed(3);
  }

  // ---- 三条通道 ----
  const w = newWorker();
  await waitBoot(w);
  let seq = 0;

  for (const size of SIZES) {
    const iters = Math.max(8, Math.min(400, Math.floor(BUDGET / size)));

    // A. 结构化克隆:每次都是两份数据
    const bufA = new Uint8Array(size).fill(1);
    const cloneTimes = [];
    for (let i = 0; i < iters; i++) {
      cloneTimes.push((await rtt(w, { cmd: 'ping', seq: ++seq, payload: bufA, transferBack: false })).ms);
    }

    // B. 所有权转移:ping-pong。发出去的视图会永久分离,回来的是一批新对象
    let cur = new Uint8Array(size).fill(2);
    const transferTimes = [];
    let detachedCount = 0, newObjCount = 0;
    for (let i = 0; i < iters; i++) {
      const sent = cur;
      const r = await rtt(w, { cmd: 'ping', seq: ++seq, payload: sent, transferBack: true }, [sent.buffer]);
      transferTimes.push(r.ms);
      if (sent.byteLength === 0) detachedCount++;            // 发出去的视图: 分离
      if (r.data.payload !== sent) newObjCount++;             // 回来的: 新对象(不是同一个)
      cur = r.data.payload;                                  // 复用它做下一轮,稳态零分配
    }
    out[`transfer_detached_${size}`] = detachedCount === iters;
    out[`transfer_newobj_${size}`] = newObjCount === iters;

    // C. SharedArrayBuffer:零拷贝,只发信号
    const sab = new SharedArrayBuffer(size);
    new Uint8Array(sab).fill(3);
    const flag = new Int32Array(new SharedArrayBuffer(8));
    const sabTimes = [];
    for (let i = 0; i < iters; i++) sabTimes.push(await sabRtt(w, { cmd: 'sab', seq: ++seq, buf: sab, flag }, flag));

    out.sizes.push({
      bytes: size,
      iters,
      clone_ms: med(cloneTimes),
      transfer_ms: med(transferTimes),
      sab_ms: med(sabTimes),
      clone_mbps: +((size / 1e6) / (pct(cloneTimes, 0.5) / 1000)).toFixed(1),
      clone_over_transfer: +(pct(cloneTimes, 0.5) / pct(transferTimes, 0.5)).toFixed(1),
      sab_ratio: +(pct(cloneTimes, 0.5) / pct(sabTimes, 0.5)).toFixed(1),
    });
  }
  w.terminate();

  // ---- 对象图:传「对象数组」还是传「SoA 的 buffer」 ----
  const N = 50000;
  const recs = Array.from({ length: N }, (_, i) => ({ id: i, x: Math.random() * 100, y: Math.random() * 100, name: 'n' + (i % 97) }));
  const w2 = newWorker();
  await waitBoot(w2);
  const g = { records: N };

  let t0 = performance.now();
  await rtt(w2, { cmd: 'ping', seq: ++seq, payload: recs, transferBack: false });
  g.clone_records_ms = +(performance.now() - t0).toFixed(3);

  t0 = performance.now();
  JSON.parse(JSON.stringify(recs));
  g.json_roundtrip_ms = +(performance.now() - t0).toFixed(3);

  // SoA:同样的数据换成 2 个 Float32Array + 1 个 Int32Array + 1 个 name 索引,直接 transfer
  const soa = {
    id: new Int32Array(N),
    x: new Float32Array(N),
    y: new Float32Array(N),
    nameIdx: new Uint8Array(N),
  };
  for (let i = 0; i < N; i++) {
    soa.id[i] = recs[i].id; soa.x[i] = recs[i].x; soa.y[i] = recs[i].y; soa.nameIdx[i] = i % 97;
  }
  const soaBytes = soa.id.byteLength + soa.x.byteLength + soa.y.byteLength + soa.nameIdx.byteLength;
  const t1 = performance.now();
  await rtt(w2, { cmd: 'ping', seq: ++seq, payload: soa, transferBack: true },
    [soa.id.buffer, soa.x.buffer, soa.y.buffer, soa.nameIdx.buffer]);
  g.soa_transfer_ms = +(performance.now() - t1).toFixed(3);
  g.soa_bytes = soaBytes;
  g.aos_clone_bytes_est = JSON.stringify(recs).length;
  w2.terminate();
  out.graph = g;

  // ---- 语义矩阵 ----
  const sem = {};
  const probe = (name, fn) => {
    try {
      const r = fn();
      sem[name] = r === undefined ? 'ok' : r;
    } catch (e) {
      sem[name] = 'THROW ' + String(e.name || e.message).slice(0, 24);
    }
  };
  probe('clone_function', () => { structuredClone({ f: () => 1 }); });
  probe('clone_dom_node', () => { structuredClone({ n: document.body }); });
  probe('clone_class_proto_lost', () => {
    class P { constructor() { this.a = 1; } m() { return 2; } }
    const c = structuredClone(new P());
    return (c instanceof P) + '/' + (typeof c.m);
  });
  probe('clone_map_set_date_regex', () => {
    const c = structuredClone({ m: new Map([[1, { x: 1 }]]), s: new Set([2]), d: new Date(5), re: /a/g });
    return c.m.get(1).x + '/' + c.s.has(2) + '/' + (c.d instanceof Date) + '/' + (c.re instanceof RegExp);
  });
  probe('clone_error_type_kept', () => {
    const c = structuredClone(new TypeError('boom'));
    return (c instanceof Error) + '/' + c.name + '/' + c.message;
  });
  probe('clone_symbol_key_dropped', () => {
    const o = { [Symbol('k')]: 1, keep: 2 };
    return JSON.stringify(Object.keys(structuredClone(o)));
  });
  probe('clone_sab_same_memory', () => {
    const sab = new SharedArrayBuffer(8);
    const c = structuredClone(sab);
    new Uint8Array(c)[0] = 42;
    return new Uint8Array(sab)[0];
  });
  probe('clone_cycle_kept', () => {
    const o = { a: 1 }; o.self = o;
    const c = structuredClone(o);
    return c.self === c; // 环必须在副本内部闭合,而不是指回原对象
  });
  probe('transfer_detaches_source', () => {
    const b = new ArrayBuffer(1024);
    new MessageChannel().port1.postMessage(b, [b]);
    return b.byteLength;
  });
  probe('transfer_twice_throws', () => {
    const b = new ArrayBuffer(1024);
    const ch = new MessageChannel();
    ch.port1.postMessage(b, [b]);
    ch.port2.postMessage(b, [b]);
  });
  probe('transfer_sab_throws', () => {
    const sab = new SharedArrayBuffer(8);
    new MessageChannel().port1.postMessage(sab, [sab]);
  });
  probe('transfer_raw_view_in_list', () => {
    const u8 = new Uint8Array(1024);
    new MessageChannel().port1.postMessage(u8, [u8]);
    return 'accepted/' + u8.buffer.byteLength;
  });
  probe('transfer_object_with_views_ok', () => {
    const soa = { x: new Float32Array(8) };
    new MessageChannel().port1.postMessage(soa, [soa.x.buffer]);
    return 'soa_byteLength=' + soa.x.byteLength;
  });
  probe('clone_typed_view_geometry_kept', () => {
    const u8 = new Uint8Array(16);
    const f32 = new Float32Array(u8.buffer, 4, 2);
    const c = structuredClone({ f32, u8 });
    return c.f32.constructor.name + '/' + c.f32.byteOffset + '/' + c.f32.length + '/' + c.f32.buffer.byteLength;
  });
  out.semantics = sem;
  return out;
}

test-article-code.mjs ------ 文章代码自检(断言文中所有结论)

javascript 复制代码
// test-article-code.mjs --- 验证「文章里贴的代码」真能跑
// 覆盖: rpc.mjs(文章版)、workers/rpc-worker.mjs(文章版)、Pool 类(文章版)
// 用法: node test-article-code.mjs   (失败 exit 1)

import { launch, newPage } from './cdp.mjs';
import { serve } from './server.mjs';

const fails = [];
const ok = (cond, msg) => { if (!cond) fails.push(msg); console.log((cond ? '  ok  ' : '  FAIL ') + msg); };

const iso = await serve('.', 0);
const noIso = await serve('.', 0, { isolate: false });
const browser = await launch({ port: 9420 });

// ---------- A. RPC 层 ----------
{
  const p = await newPage(browser.port);
  p.on('Runtime.exceptionThrown', (e) => console.log('[page-err]', (e.exceptionDetails?.exception?.description || '').slice(0, 200)));
  await p.goto(iso.origin + '/rpc.html');
  const r = await p.eval('window.__test()');
  console.log('\nRPC(文章版 rpc.mjs + rpc-worker.mjs)');
  ok(r.add === 5, '普通调用 add(2,3) === 5');
  ok(r.scale === '10,20,30,40', '返回值自动转移(scale)');
  ok(r.src_detached === true, '参数(Float32Array)调用后已分离');
  ok(r.boom === 'RangeError/算不了', '错误 name + message 保真');
  ok(r.timeout === 'TimeoutError', '超时 reject TimeoutError');
  ok(r.abort.startsWith('AbortError') && r.abort.endsWith('progress=true'), '协作式取消 + 取消前有 progress');
  ok(r.pending_after_2000 === 0, '2000 次并发后 pending 清空(无泄漏)');
  p.close();
}

// ---------- B. Pool 类 ----------
{
  const p = await newPage(browser.port);
  p.on('Runtime.exceptionThrown', (e) => console.log('[page-err]', (e.exceptionDetails?.exception?.description || '').slice(0, 200)));
  await p.goto(iso.origin + '/pool-test.html');
  const r = await p.eval('window.__test()');
  console.log('\nPool(文章版类 + count-worker)');
  ok(r.jobs_done === 20, '20 个任务全部完成');
  ok(r.seqs_ok === true, '回包序号无错配');
  ok(r.distinct_workers <= 4, `worker 实例数不超过池上限(实测 ${r.distinct_workers})`);
  ok(String(r.crash).startsWith('rejected:worker crashed'), 'worker 崩溃时 job 被 reject');
  ok(r.recovered === true, '崩溃后池自动补人并继续服务');
  p.close();
}

console.log(`\n失败 ${fails.length} 条`);
browser.kill(); iso.server.close(); noIso.server.close();
process.exit(fails.length ? 1 : 0);

pool.html ------ 演示页:Worker 池

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>pool bench</title>
<script type="module">
  import { run } from './bench-pool.mjs';
  window.__run = run;
</script>

pool-test.html ------ 测试页:Worker 池并发行为

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>pool test</title>
<script type="module">
  import { Pool } from './pool-lib.mjs';
  window.__test = async () => {
    const out = {};
    const pool = new Pool(4, './workers/count-worker.mjs', { warm: 4 });
    // 等预热完成(本类在构造时就 spawn,boot 是异步回来的)
    await new Promise((r) => setTimeout(r, 300));

    const t0 = performance.now();
    const jobs = await Promise.all(Array.from({ length: 20 }, (_, i) => pool.run({ cmd: 'task', seq: i })));
    out.jobs_done = jobs.length;
    out.wall_ms = +(performance.now() - t0).toFixed(1);
    out.distinct_workers = new Set(jobs.map((j) => j.id)).size;   // 应 ≤ 4
    out.seqs_ok = jobs.map((j) => j.seq).sort((a, b) => a - b).join(',') === Array.from({length:20},(_,i)=>i).join(',');

    // 崩溃路径:worker 抛未捕获错误 → onerror → job 必须被 reject,且池要补人
    const crashed = await pool.run({ cmd: 'boom' }).then(() => 'resolved', (e) => 'rejected:' + e.message.slice(0, 20));
    out.crash = crashed;
    await new Promise((r) => setTimeout(r, 300));
    // 崩溃后池必须还能干活
    const after = await pool.run({ cmd: 'task', seq: 999 });
    out.recovered = after.seq === 999;
    pool.destroy();
    return out;
  };
</script>

rpc.html ------ 演示页:RPC 调用

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>rpc test</title>
<script type="module">
  import { createRpc } from './rpc.mjs';
  window.__test = async () => {
    const results = {};
    const w = new Worker(new URL('./workers/rpc-worker.mjs', import.meta.url), { type: 'module' });
    await new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'boot') { w.removeEventListener('message', h); r(); } }));
    const rpc = createRpc(w, { timeout: 5000 });

    results.add = await rpc.call('add', 2, 3);

    const src = new Float32Array([1, 2, 3, 4]);
    const scaled = await rpc.call('scale', src, 10);
    results.scale = Array.from(scaled).join(',');
    results.src_detached = src.byteLength === 0;     // 参数被转移走了
    results.reply_byteLength = scaled.byteLength;

    try { await rpc.call('boom'); } catch (e) { results.boom = e.name + '/' + e.message; }

    // 超时
    const fast = createRpc(w, { timeout: 20 });
    try { await fast.call('longTask', 200); } catch (e) { results.timeout = e.name; }
    fast.terminate();

    // 取消
    const ac = new AbortController();
    setTimeout(() => ac.abort(), 30);
    let progressSeen = 0;
    try {
      await rpc.call('longTask', 400, { onProgress: () => progressSeen++, signal: ac.signal });
      results.abort = 'no-throw';
    } catch (e) { results.abort = e.name + '|progress=' + (progressSeen > 0); }

    // 并发压力:2000 次调用后 pending 必须清空
    await Promise.all(Array.from({ length: 2000 }, (_, i) => rpc.call('add', i, 1)));
    results.pending_after_2000 = rpc.pendingCount();

    rpc.terminate();
    w.terminate();
    return results;
  };
</script>

offscreen.html ------ 演示页:OffscreenCanvas

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>offscreen bench</title>
<style>body{margin:0;background:#111}</style>
<script type="module">
  import { run } from './bench-offscreen.mjs';
  window.__run = run;
</script>

sab.html ------ 演示页:SharedArrayBuffer

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>sab bench</title>
<script type="module">
  import { run } from './bench-sab.mjs';
  window.__run = run;
</script>

jank.html ------ 演示页:主线程卡顿

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>jank bench</title>
<script type="module">
  import { run } from './bench-jank.mjs';
  window.__run = run;
</script>

clone.html ------ 演示页:structuredClone

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>clone bench</title>
<script type="module">
  import { run } from './bench-clone.mjs';
  window.__run = run;
  window.__ready = true;
</script>

isolation.html ------ 演示页:Worker 隔离

html 复制代码
<!doctype html>
<meta charset="utf-8">
<title>isolation probe</title>
<script>
  window.__probe = () => ({
    crossOriginIsolated: crossOriginIsolated,
    hasSharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
    canConstructSAB: (() => { try { new SharedArrayBuffer(8); return true; } catch (e) { return 'THROW ' + e.name; } })(),
  });
</script>
相关推荐
Csvn1 小时前
图片字体
前端
m0_738120721 小时前
JWT 安全从入门到实战:六种典型攻击场景解析
前端·网络·python·安全·网络安全
拖孩1 小时前
AI 帮我写的小程序上线 44 天:36 个人进了收银台,8 个人付钱
前端·后端·微信小程序
java1234_小锋1 小时前
【技术专题】CSS3 网页技术 - CSS3 视觉增强
前端·css·css3
Dovis(誓平步青云)2 小时前
拖动关键帧角色就抽动,动画编辑器怎样补齐中间状态
开发语言·前端·javascript·机器学习·编辑器
lpfasd1232 小时前
pywebview与Electron
前端·javascript·electron
行走的陀螺仪2 小时前
Claude Code 从入门到实战(一):安装、登录与第一个Vue项目
前端·javascript·vue.js
梦曦i2 小时前
0.8.2 新增 backFallback 配置,无 tabbar 页返回无忧
前端·uni-app x
Amos_Web2 小时前
Rspack 源码解析(三十四):rspack_hook 与宏系统如何用过程宏生成异步 Hook 与插件
前端