Worker 与 OffscreenCanvas

0 阅读46分钟

一、技术难点

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 mstransfer msSAB msclone 吞吐 MB/sclone/transfer
1KB0.0150.020.01568.30.8
16KB0.030.0150.01546.12.0
256KB0.1250.0150.0152097.28.3
1MB0.6250.020.0151677.731.3
4MB2.2050.020.0151902.2110.3
16MB11.7350.030.021429.7391.2
64MB56.140.180.0151195.4311.9

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

  1. 1KB 以下的三条通道全是 15µs——比不出任何差异,因为此时开销不在数据上,在消息调度上。实测的这条「地板」直接否掉了「把任务切碎成小消息更灵活」的做法:15µs ≈ 每秒 6.6 万条消息的硬上限,切得越碎,越多时间花在调度而不是计算上。
  2. 256KB~16MB 是 transfer 的绝对主场:clone 线性涨(12002100MB/s,注意这不是常数,见下),transfer 稳在 0.0150.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 稳定在 17002100MB/s,64MB 掉到 1195MB/s。任何「postMessage 大约 1GB/s」的经验值,只在特定体积区间成立。

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

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

// 实测: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):
// 稳态零分配的关键:把回包里的 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,不能放视图。

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

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

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 只能被转移一次。

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

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

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

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

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

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

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

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

姿势墙钟 ms最大帧间隔 msp95 帧间隔 ms错过帧数TBT ms
main_block 一口气跑完610.6611.4516.7436561
main_batch_yield 每批之间 setTimeout(0)713.640.1829.57230
main_chunked 每批再切 5ms 片1174.421.8319.1700
main_chunked_yield 切片 + scheduler.yield()614.1105.05105.05290
worker 全丢给 worker611.423.4616.7700

「错过帧数」= 每个 >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 图元 · 主线程 canvas3.6754.414.8416.66159.9
3000 图元 · worker canvas3.3550.040.0516.661—
12000 图元 · 主线程 canvas13.6217.0721.3717.11354.6
12000 图元 · worker canvas13.860.030.0416.681—

判据就在这里:绘制成本从 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 的变化通知、没有布局。实测的字体探针还顺带确认了更基础的一条:

// 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=1worker=2worker=4worker=88 worker 加速比效率
纯算术(ALU 受限)577.7ms294.6ms148.5ms74.1ms7.80×0.97
素数筛(每任务写 16MB)671.0ms457.3ms307.5ms254.3ms2.64×0.33

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

负载1 worker2 workers4 workers8 workers劣化倍数
纯算术71.1ms74.5ms76.4ms73.3ms1.04×
素数筛72.2ms109.5ms153.3ms250.1ms3.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,主线程不再要transfer0 拷贝,代价与体积无关
大 buffer 反复来回(视频帧、像素缓冲)SAB 或 ping-pong transferSAB 连「换身份」都省了;ping-pong 用回包复用 buffer 做稳态零分配
结构化小对象、配置、事件clone简单、安全,15µs 地板够用
对象数组(几万条记录)改成 SoA + transfer实测 40.87ms → 0.885ms
双工、多路复用MessagePort一条通道隔离一组会话,便于 close()

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

// 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.87ms1×≈3.5MB(JSON 口径)
JSON.parse(JSON.stringify())16.39ms2.5× 快≈3.5MB
SoA + transfer0.885ms46× 快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),它把前面所有坑都收进了封装里:

// 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):

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 里实测过的调用姿势):

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(多了场景几何与逐帧计时),两者是同一条管线。

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

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 侧(渲染循环 + 尺寸/字体):

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 堆对主线程完全不可见

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

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,而是两个响应头:

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

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

条件crossOriginIsolatedtypeof SharedArrayBuffernew SharedArrayBuffer(8)
有 COOP+COEPtrue'function'成功
无 COOP+COEPfalse'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 后拿回结果)的端到端实测:

通道中位 msp95 ms往返拷贝往返搬运
clone 往返3.64512.3052 份8.4MB
transfer 往返0.8352.1300
SAB 原地改写0.8250.85500

读法要小心:这里的 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 共享。

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

// 主线程:写第 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 条)——不涉及性能,只固化行为:

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 条)——钉住「方向」,不钉绝对值(换机器绝对值会变,方向不会):

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

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 条):

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 次检查)——跨场景必须成立的性质:

// 搬线程不改变算力: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。可搬的与不可搬的分得很干净:

能力线程原因
词法分析、语法树、格式化、diffworker纯文本进、纯结构出,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 一定比主线程快」是同一类错误的另一个版本。


运行环境与文件清单

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)
帧间隔 / 长任务 / TBTrequestAnimationFrame 序列 + 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 双缓冲撕裂检测、降级路径回归。

复现:

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 池核心(并发槽、任务队列、取消)

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 };

rpc.mjs —— 主线程 <-> Worker 的 RPC(类 Comlink 协议)

// 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 —— 测量脚本(调度、迁移开销、主线程占用)

// 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 里跑基准)

// 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 —— 本地静态服务器

// 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 单线程

// 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 零拷贝

// 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 渲染

// 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)

// 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 迁移成本

// 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 —— 文章代码自检(断言文中所有结论)

// 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 池

<!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 池并发行为

<!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 调用

<!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

<!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

<!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 —— 演示页:主线程卡顿

<!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

<!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 隔离

<!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>