一、技术难点
Day31 的结尾留了一句:「Day30 管传输,Day31 管该传多少,Day32 管传到了之后在哪个线程算」。
这一篇就是那句话的展开。而它有三个流传极广的「常识」,我都是在把它们写成断言、再用脚本打脸之后,才敢往文章里写:
- 「postMessage 传的是引用」 ------错,而且错得离谱。默认路径是结构化克隆 :一块 64MB 的 buffer 往来一次要付 56.14ms (去程一次克隆、回程再克隆一次,单程约 28ms)。同一块 buffer 换成所有权转移 (transfer),同样的往返只要 0.18ms ------差 312 倍。而这两条路的行为差异(源 buffer 会不会被抽干、回来的还是不是原来那个对象)没有任何编译期检查,也没有任何运行时警告。
- 「搬到 worker 里就快了」 ------计算量一样时,总墙钟一点没变 :主线程一口气跑 610.6ms ,搬到 worker 里跑 611.4ms 。同理,同一套 12000 个图元的场景,主线程 canvas 每帧 13.62ms ,worker 里的 OffscreenCanvas 每帧 13.86ms ------绘制成本一分钱没省。搬走的不是算力,是**「这次占用发生在哪个线程」**。
- 「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 万条记录的数组,
structuredClone40.87ms ,JSON.parse(JSON.stringify())16.39ms ------JSON 快 2.5 倍 。而把同样的数据换成 SoA 的四个 TypedArray 直接转移,0.885ms ,比 JSON 快 18.5 倍 、比 structuredClone 快 46 倍。 - 「用切片代替 worker 就够了」 :
setTimeout(0)切片确实做到 0 掉帧,但墙钟从 610.6ms 涨到 1174.4ms(+92%) ;换成scheduler.yield()保住吞吐(614.1ms),却换来 105.05ms 的最大帧间隔和 29 个错过帧。两条路都不等价于 worker。 - 「SharedArrayBuffer 要开 flag」 :现在不需要 flag,它要的是两个响应头 。同一份代码,有 COOP/COEP 时
new SharedArrayBuffer(8)正常,抽掉这两个头之后直接ReferenceError(实测对照组)。
本篇所有数字都来自 5 组真实浏览器基准 + 1 组隔离对照 ,共 25 条断言 (其中 2 条在两个场景上各跑一次,实际执行 27 次检查 ),跑在真实 Chrome 上(CDP 驱动,零依赖),失败即 exit 1。全部是实测,没有模型推演------这一篇比 Day31 幸运,因为线程与画布的行为都能在浏览器里直接量到。边界在文末「运行环境」逐条写清。
难点一:postMessage 有三条所有权语义,而它们长得一模一样
同样一行 worker.postMessage(data),可能是三种完全不同的搬运方式,代价差三个数量级:
| 语义 | 写法 | 往返拷贝 | 数据在哪 | 64MB 实测(往返中位) |
|---|---|---|---|---|
| 结构化克隆 | postMessage(data) |
2 份 | 两边各一份,互不影响 | 56.14ms |
| 所有权转移 | postMessage(data, [buf]) |
0 份 | 搬到对面,原处分离 | 0.18ms |
| 共享内存 | postMessage(sab) |
0 份 | 同一块物理内存 | 0.015ms |
完整刻度(同一块 Uint8Array,往返一次,med of N):
| 字节 | clone ms | transfer ms | SAB ms | clone 吞吐 MB/s | clone/transfer |
|---|---|---|---|---|---|
| 1KB | 0.015 | 0.02 | 0.015 | 68.3 | 0.8 |
| 16KB | 0.03 | 0.015 | 0.01 | 546.1 | 2.0 |
| 256KB | 0.125 | 0.015 | 0.015 | 2097.2 | 8.3 |
| 1MB | 0.625 | 0.02 | 0.015 | 1677.7 | 31.3 |
| 4MB | 2.205 | 0.02 | 0.015 | 1902.2 | 110.3 |
| 16MB | 11.735 | 0.03 | 0.02 | 1429.7 | 391.2 |
| 64MB | 56.14 | 0.18 | 0.015 | 1195.4 | 311.9 |
这张表要分三段读,每一段对应一种错误的直觉:
- 1KB 以下的三条通道全是 15µs ------比不出任何差异,因为此时开销不在数据上,在消息调度上。实测的这条「地板」直接否掉了「把任务切碎成小消息更灵活」的做法:15µs ≈ 每秒 6.6 万条消息的硬上限,切得越碎,越多时间花在调度而不是计算上。
- 256KB~16MB 是 transfer 的绝对主场 :clone 线性涨(1200
2100MB/s,注意这不是常数,见下),transfer 稳在 0.0150.03ms 不动。搬运一份数据的成本,和「数据有多大」在 transfer 通道上是无关的------这才是「零拷贝」的真实含义。 - 64MB 时 transfer 也涨到 0.18ms (此前 0.02~0.03),clone 的吞吐同时从 1429.7 掉到 1195.4 MB/s。原因不在协议,在内存子系统:页表、内存映射与 GC 记账在大对象上开始显形。「零拷贝」不等于「零成本」,它只是把成本从「与字节数成正比」压成了「与页数弱相关」。
clone 的吞吐也不是常数------1KB 时 68.3MB/s(被 15µs 地板支配),256KB4MB 稳定在 1700 2100MB/s,64MB 掉到 1195MB/s。任何「postMessage 大约 1GB/s」的经验值,只在特定体积区间成立。
三个坑,全部实测确认(7 个尺寸的流转检查 100% 命中):
坑一:transfer 出去的视图永久分离,回来的不是原来那个对象。
js
// 实测:7 个尺寸(1KB → 64MB)全部满足
const sent = new Uint8Array(1024);
const r = await rtt(worker, { payload: sent, transferBack: true }, [sent.buffer]);
sent.byteLength; // 0 ------ 发出去的视图已分离,不能再用
r.data.payload === sent; // false ------ 回来的是新对象,哪怕只是转了一圈
这条「往返后换了个身份」的性质,直接决定了两件事:
- 缓冲池不能只按尺寸复用 。想做到稳态零分配,必须把回包里的新 buffer 当作下一轮的输入(ping-pong):
js
// 稳态零分配的关键:把回包里的 buffer 接过来继续用
let cur = new Uint8Array(size).fill(2);
for (let i = 0; i < iters; i++) {
const sent = cur;
const r = await rtt(worker, { cmd: 'ping', payload: sent, transferBack: true }, [sent.buffer]);
cur = r.data.payload; // 不是 sent!sent 已经是 0 字节的空壳
}
- 任何持有该 buffer 的旧引用都会变成「0 字节尸体」。项目中「transfer 之后 length 变 0」的诡异 bug,全部源于此。
坑二:转移列表里只能放 buffer,不能放视图。
js
const u8 = new Uint8Array(1024);
port.postMessage(u8, [u8]); // THROW DataCloneError(实测)
port.postMessage(u8, [u8.buffer]); // 正确:放 buffer,视图也会被一起带走
这条错误的杀伤力在于「一半对一半错」:postMessage(u8) 不传转移列表是完全合法的(走克隆),只有把视图 塞进转移列表才炸。所以代码里必须有一个统一的摊平函数,把嵌套结构里的所有 ArrayBuffer / ArrayBufferView 找出来:
js
export function transferListOf(...values) {
const out = [];
const walk = (v, depth = 0) => {
if (!v || depth > 6) return;
if (v instanceof ArrayBuffer) out.push(v);
else if (ArrayBuffer.isView(v)) out.push(v.buffer); // 视图 → 它的 buffer
else if (Array.isArray(v)) for (const x of v) walk(x, depth + 1);
else if (typeof v === 'object') for (const k of Object.keys(v)) walk(v[k], depth + 1);
};
for (const v of values) walk(v);
return [...new Set(out)]; // 同一个 buffer 出现两次 → DataCloneError
}
「去重」这行不是洁癖:实测同一个 buffer 在转移列表里出现两次,同样抛 DataCloneError。
坑三:一个 buffer 只能被转移一次。
js
const b = new ArrayBuffer(1024);
port1.postMessage(b, [b]);
port2.postMessage(b, [b]); // THROW DataCloneError(实测)
而 SharedArrayBuffer 恰好相反:它不能被转移 (转移列表里塞 SAB 直接抛 DataCloneError),因为它不需要转移------克隆之后仍是同一块内存:
js
const sab = new SharedArrayBuffer(8);
const copy = structuredClone(sab);
new Uint8Array(copy)[0] = 42;
new Uint8Array(sab)[0]; // 42 ------ 「拷贝」是假象,它是引用同一块内存
克隆的语义边界也一并固化成断言(左列是行为,右列是实测结果):
| 探针 | 实测 | 含义 |
|---|---|---|
| 克隆函数 | THROW DataCloneError |
函数不能过线程边界 |
| 克隆 DOM 节点 | THROW DataCloneError |
DOM 只属于主线程 |
| 克隆类实例 | false/undefined |
原型丢了 :instanceof 为 false,方法变 undefined |
| Map / Set / Date / RegExp | 1/true/true/true |
这些内建类型被正确重建 |
| 克隆 Error | true/TypeError/boom |
类型、name、message 都保留 |
| Symbol 作键 | ["keep"] |
Symbol 键被静默丢弃,只有字符串键留下 |
| 循环引用 | true(c.self === c) |
环在副本内部闭合,不指回原对象 |
| TypedArray 视图 | Float32Array/4/2/16 |
offset=4、length=2、底层 buffer=16,视图几何完整保留 |
| 转移两次 | THROW DataCloneError |
一次性所有权 |
| 转移 SAB | THROW DataCloneError |
SAB 不需要转移 |
「类实例丢原型」这条最阴险:DTO 传过去之后 instanceof 悄悄变 false,方法调用才炸,而错误现场离病因可能隔了几百行。
难点二:搬走计算不减少总耗时,改变的是「单次占用时长」与「每帧间隙」
我把同一份计算量(24 批 × 约 25ms ≈ 600ms)用五种姿势各跑一遍,同时记录主线程的帧间隔与长任务:
| 姿势 | 墙钟 ms | 最大帧间隔 ms | p95 帧间隔 ms | 错过帧数 | TBT ms |
|---|---|---|---|---|---|
main_block 一口气跑完 |
610.6 | 611.45 | 16.74 | 36 | 561 |
main_batch_yield 每批之间 setTimeout(0) |
713.6 | 40.18 | 29.57 | 23 | 0 |
main_chunked 每批再切 5ms 片 |
1174.4 | 21.83 | 19.17 | 0 | 0 |
main_chunked_yield 切片 + scheduler.yield() |
614.1 | 105.05 | 105.05 | 29 | 0 |
worker 全丢给 worker |
611.4 | 23.46 | 16.77 | 0 | 0 |
「错过帧数」= 每个 >16.67ms 的间隔折算成少画了几帧(Σ max(0, round(gap/16.67) − 1)),这个口径比「掉帧次数」诚实得多:main_block 只掉「1 次」,但实际少画了 36 帧。
四个结论,每一个都反直觉:
① worker 没有让总时间变快,一毫秒都没有。 610.6ms → 611.4ms。它的全部价值在最后两列:最大帧间隔 611.45 → 23.46ms,错过帧 36 → 0,TBT 561 → 0。你要的从来不是「更快的 CPU」,而是「没有任何一次 JS 占用超过 16.7ms」。
② setTimeout(0) 切片是拿吞吐换帧率,汇率极差:+92% 墙钟。 600ms 的计算量,切碎之后跑了 1174.4ms。原因很朴素:24 批 × 5 片 = 120 次让出,每次最少被 setTimeout 的 4ms 下限拖住------让出本身比计算还贵 。所以「切片代替 worker」在一个 600ms 的总量上就已经不成立;如果一个页面每天都在做这种量级的计算,切片方案的隐性成本是 把用户等待时间翻倍。
③ scheduler.yield() 保住了吞吐,却把帧率打穿了。 614.1ms 几乎等于理想值(610.6ms),但最大帧间隔 105.05ms 、错过 29 帧 ------比不切片的 main_block 略好一点,比 setTimeout 切片差得多。原因是 yield() 让给的是同优先级的任务队列 而不是渲染:当队列里永远有下一片工作要跑时,渲染被无限期后推,于是出现「CPU 一直很忙、画面一直不动」这种最难解释的卡顿形态。这条是我在做这个实验之前完全预期错的 ------我以为 scheduler.yield() 是切片的正确姿势,实测它是「吞吐版的假切片」。
④ 只有 worker 同时吃下吞吐与帧率。 611.4ms / 0 错过帧 / TBT 0。代价是:启动 4.9~5.6ms/worker、每个 worker 一份独立堆(见难点四)、以及「数据要过边界」的全部复杂度。
再补一刀,把「worker 是万灵药」也否掉:如果单次占用本来就只有 3~4ms,把它搬走几乎什么也买不到。 同一个 OffscreenCanvas 场景,缩到每帧 3000 个图元时,主线程 canvas 每帧 3.675ms 、主线程响应延迟中位 4.41ms ------此时 worker 版本的主线程延迟是 0.04ms ,改善看起来很夸张(110 倍),但用户感知不到差别:两者都是 0 错过帧、都是 60fps。收益只有在「单次绘制成本逼近或超过帧预算」之后才出现:
| 场景 | 每帧绘制 | 主线程响应中位 | p95 | 主线程帧间隔 | 错过帧 | fps |
|---|---|---|---|---|---|---|
| 3000 图元 · 主线程 canvas | 3.675 | 4.41 | 4.84 | 16.66 | 1 | 59.9 |
| 3000 图元 · worker canvas | 3.355 | 0.04 | 0.05 | 16.66 | 1 | --- |
| 12000 图元 · 主线程 canvas | 13.62 | 17.07 | 21.37 | 17.11 | 3 | 54.6 |
| 12000 图元 · worker canvas | 13.86 | 0.03 | 0.04 | 16.68 | 1 | --- |
判据就在这里 :绘制成本从 3.675 涨到 13.62ms(3.7 倍),主线程响应延迟从 4.41 涨到 17.07ms(3.9 倍),fps 从 59.9 掉到 54.6------过了这条线,用户开始能感觉到「拖拽发黏」。同样的代码,worker 版本把这条线彻底移走:无论每帧多贵,主线程响应延迟都是 0.03~0.04ms(因为主线程根本不在这个循环里)。
难点三:OffscreenCanvas 的一次性所有权交接
transferControlToOffscreen() 不是「共享一个 canvas」,而是把这个 canvas 的所有权整个交给另一个线程 。实测交接本身很便宜(0.08~0.26ms),但这笔交易是不可逆、且附带一堆沉默的规则:
① 交接之后,主线程对这块画布一无所知。 不能 getContext、不能 toDataURL、不能 toBlob、不能设 width/height------它们会抛错或静默失效。这不是缺陷,是设计:如果主线程还能读,就必须同步两边的绘制状态。
② 尺寸与 DPR 必须显式同步。 worker 里没有 resize 事件、没有 devicePixelRatio 的变化通知、没有布局。实测的字体探针还顺带确认了更基础的一条:
js
// worker 内实测
typeof document // 'undefined'
typeof self.fonts // 'object'(FontFaceSet 在 worker 里是有的!)
也就是说:worker 里没有 DOM,但有字体集合 。所以自定义字体必须在 worker 内自己 new FontFace(...).load() 再 self.fonts.add()------@font-face 那条 CSS 路径对 OffscreenCanvas 完全无效。不加载的后果是静默 fallback :实测同样一段 Hello 前端,bold 24px monospace 量出 134.7px ,写一个不存在的字体名 bold 24px NoSuchFontXYZ 量出 113.86px ------相差 15.5%,画出来是错的排版,而且没有任何报错 。这正是 Day31「度量覆盖」那条结论在线程边界上的镜像:字体是每个渲染环境各自一份的资源。
③ worker 里可以用 requestAnimationFrame。 这是 OffscreenCanvas 能成立的前提:渲染循环可以完全搬过去。但要注意,headless 环境下 worker 的 rAF 不受 vsync 限制 (实测同一场景 worker 侧 1013ms 画完 120 帧 ≈ 118fps,主线程侧 1992ms ≈ 60fps)------所以本篇从不比较两者的 fps,只比较「每帧成本」和「主线程是否被占」。这是一个必须预先声明的口径。
④ 交互要自己搬回去。 worker 里没有事件系统,pointerdown 的坐标、命中测试、光标样式,全部得由主线程转发;命中测试所需的几何索引也只能在 worker 内自建。这是 OffscreenCanvas 方案真正的工作量所在------渲染搬走的复杂度是 O(1),交互搬走的复杂度是 O(你的图形结构)。
难点四:并行度的天花板是内存带宽,不是核心数
同一台机器(navigator.hardwareConcurrency = 11)、同样 8 个任务、同样 1/2/4/8 个 worker,只换任务的内存特征:
| 负载 | worker=1 | worker=2 | worker=4 | worker=8 | 8 worker 加速比 | 效率 |
|---|---|---|---|---|---|---|
| 纯算术(ALU 受限) | 577.7ms | 294.6ms | 148.5ms | 74.1ms | 7.80× | 0.97 |
| 素数筛(每任务写 16MB) | 671.0ms | 457.3ms | 307.5ms | 254.3ms | 2.64× | 0.33 |
单看加速比还不够刺眼,看单任务耗时:
| 负载 | 1 worker | 2 workers | 4 workers | 8 workers | 劣化倍数 |
|---|---|---|---|---|---|
| 纯算术 | 71.1ms | 74.5ms | 76.4ms | 73.3ms | 1.04× |
| 素数筛 | 72.2ms | 109.5ms | 153.3ms | 250.1ms | 3.33× |
纯算术负载下,单任务耗时几乎不动(71.1 → 73.3ms),8 个 worker 就换来 7.80 倍------这才是「并行」本该有的样子 。素数筛负载下,同样的任务在 8 路并发时自己慢了 3.33 倍 ,于是 8 个核心只买到 2.64 倍:任务本身变慢了,加速比自然被吃掉。
原因不是 CPU 不够,是每个任务都要把 16MB 写穿一遍(筛法在大跨度上做 stride 写)。8 个任务并发时,L3 与内存控制器的带宽被 8 路同时挤占,每个任务都在等内存。这也解释了为什么「加 worker」在这类负载上是负收益:第 9 个 worker 抢不到带宽,只抢到调度开销。
配套的三条代价,也都是实测的:
- 启动成本 4.87~5.63ms/worker (1 个 4.9ms、4 个 22.5ms、8 个 40.2ms)。所以池必须预热并复用 ,不能「任务来了再
new Worker」------一次 20ms 的交互式操作,配一个 5ms 的启动,25% 的开销。 - 每个 worker 有独立隔离堆 :在 worker 里吃 200MB ,主线程
performance.memory.usedJSHeapSize从 0.7MB 到 0.7MB,delta = 0 。这意味着两件事:worker 的内存问题在主线程指标里完全隐形(监控要单独接),以及「池大小 × 单 worker 工作集」才是真实内存预算------共享一份 50MB 查表数据的池,8 个 worker 就是 400MB,除非用 SAB 或每 worker 只加载切片。 - 消息调度的地板是 15µs (难点一)。任务粒度低于这个量级时,池化带来的收益会被消息开销吃掉------每个任务至少要跑够 1ms 才值得过一次线程边界。
二、完整解法
2.1 先做减法:什么值得搬过线程边界
搬线程不是免费的(启动 5ms、消息 15µs、每 worker 一份堆、数据要过克隆/转移),所以先要一张判据表。下表每一行都能在本文的实测数字里找到依据:
| 特征 | 搬? | 依据 |
|---|---|---|
| 单次占用 > 8ms 且会重复发生 | 搬 | 8ms 已占掉半个帧预算,超过它就必然和渲染抢 |
| 单次占用 < 3ms | 不搬 | 实测 3.675ms/帧的主线程场景,用户无感(0 错过帧) |
| 触碰 DOM / 布局 / 事件坐标 | 只能留在主线程 | document 在 worker 里不存在(实测) |
| 纯像素绘制(无布局依赖) | 搬(OffscreenCanvas) | 绘制成本不变,但主线程响应 17.07 → 0.03ms |
| 数据体量 > 256KB 且可转移 | 搬 + transfer | ≥256KB 时 clone 至少慢 5 倍,64MB 慢 312 倍 |
| 数据体量 < 16KB | 搬了也没坏处 | 三条通道都是 15µs 地板 |
| 高频小消息(> 1 万次/秒) | 先批处理 | 15µs/条 ≈ 6.6 万条/秒上限 |
| 多任务且内存密集 | 限量(池 ≤ 2~4) | 内存型负载 8 worker 效率 0.33、单任务劣化 3.33 倍 |
| 多任务且 ALU 密集 | 池 ≈ 核数 | 8 worker 效率 0.97 |
一句话版本:搬「占用长的、重复的、不碰 DOM 的」;不搬「短的、一次性的、要读布局的」。
2.2 传输层:先定语义,再定体积
三条通道各有自己的「正确场景」,不能混用:
| 场景 | 通道 | 理由 |
|---|---|---|
| 一次性大 buffer 递给 worker,主线程不再要 | transfer | 0 拷贝,代价与体积无关 |
| 大 buffer 反复来回(视频帧、像素缓冲) | SAB 或 ping-pong transfer | SAB 连「换身份」都省了;ping-pong 用回包复用 buffer 做稳态零分配 |
| 结构化小对象、配置、事件 | clone | 简单、安全,15µs 地板够用 |
| 对象数组(几万条记录) | 改成 SoA + transfer | 实测 40.87ms → 0.885ms |
| 双工、多路复用 | MessagePort | 一条通道隔离一组会话,便于 close() |
SoA 那条值得单独展开,因为它是本篇收益最大的一次重构,而且和线程无关------它只是「换个内存布局」:
js
// Before:5 万条记录的对象数组
const records = Array.from({ length: 50000 }, (_, i) => ({
id: i, x: rand(), y: rand(), name: 'n' + (i % 97),
}));
worker.postMessage({ records }); // 实测 40.87ms(一次克隆)
// After:同样的数据,4 个 TypedArray(Struct-of-Arrays)
const soa = {
id: new Int32Array(N), // 200KB
x: new Float32Array(N), // 200KB
y: new Float32Array(N), // 200KB
nameIdx: new Uint8Array(N), // 50KB
};
worker.postMessage(soa, transferListOf(soa)); // 实测 0.885ms
| 方案 | 实测耗时 | 相对 | 搬运字节 |
|---|---|---|---|
structuredClone 5 万条记录 |
40.87ms | 1× | ≈3.5MB(JSON 口径) |
JSON.parse(JSON.stringify()) |
16.39ms | 2.5× 快 | ≈3.5MB |
| SoA + transfer | 0.885ms | 46× 快 | 650KB |
三个附带好处,都是刚需级的:
- 字节数直接砍到 1/5.4:对象数组要为每条记录付 4 个属性名 + 指针 + 装箱开销;SoA 只有 650KB 的裸数组。
- worker 侧拿到的是「已经就位」的连续内存,可以直接喂给 WebGL / WebGPU / WASM,不需要再整理。
- GC 压力转移:SoA 是「一次分配、长期复用」,对象数组是「每次克隆都造 5 万个对象」。
代价要说清:SoA 让代码可读性变差(x[i] 而不是 r.x),并且字符串必须换成索引 + 字典 (本例 nameIdx 只存 0~96 的字典下标)。这是典型的「性能换可读性」交易,只在边界处做一次,内部照旧用对象。
2.3 RPC 层:把 postMessage 变成函数调用
裸 postMessage 在真实项目里活不过两周------因为每条消息都要手写一次 seq 配对、错误分支、超时、转移列表 。所以第二层是一个约 130 行的 RPC。下面这份是本文实测通过的完整实现(rpc.mjs),它把前面所有坑都收进了封装里:
js
// rpc.mjs --- 双侧共用:主线程 createRpc(worker) / worker 侧 exposeRpc(handlers)
const TYPE = { REQ: 1, RES: 2, ERR: 3, PROGRESS: 4, CANCEL: 5 };
/** 摊平可转移对象(转移列表里只能放 buffer,见难点一坑二) */
export function transferListOf(...values) {
const out = [];
const walk = (v, depth = 0) => {
if (!v || depth > 6) return;
if (v instanceof ArrayBuffer) out.push(v);
else if (ArrayBuffer.isView(v)) out.push(v.buffer);
else if (Array.isArray(v)) for (const x of v) walk(x, depth + 1);
else if (typeof v === 'object') for (const k of Object.keys(v)) walk(v[k], depth + 1);
};
for (const v of values) walk(v);
return [...new Set(out)]; // 重复 buffer 会抛 DataCloneError
}
const serializeError = (e) => ({ name: e?.name || 'Error', message: String(e?.message ?? e), stack: e?.stack ?? null });
const deserializeError = (o) => {
const e = new Error(o.message);
e.name = o.name; // 保住 RangeError / TimeoutError 这些语义
if (o.stack) e.stack = o.stack;
return e;
};
export function createRpc(target, { timeout = 30000 } = {}) {
const pending = new Map();
let seq = 0;
target.addEventListener('message', (ev) => {
const d = ev.data;
if (!d || typeof d !== 'object') return;
const p = pending.get(d.id);
if (!p) return; // 迟到的 / 已取消的,直接丢弃
if (d.t === TYPE.RES) { pending.delete(d.id); p.resolve(d.v); }
else if (d.t === TYPE.ERR) { pending.delete(d.id); p.reject(deserializeError(d.e)); }
else if (d.t === TYPE.PROGRESS) p.onProgress?.(d.v);
});
function call(method, ...rawArgs) {
const id = ++seq;
// 最后一个参数若带 signal/onProgress,是「本地控制项」:绝不参与序列化
const last = rawArgs[rawArgs.length - 1];
const isCtrl = last && typeof last === 'object' && !ArrayBuffer.isView(last) && ('signal' in last || 'onProgress' in last);
const ctrl = isCtrl ? last : {};
const args = isCtrl ? rawArgs.slice(0, -1) : rawArgs;
return new Promise((resolve, reject) => {
const timer = timeout > 0
? setTimeout(() => {
pending.delete(id);
target.postMessage({ t: TYPE.CANCEL, id });
reject(Object.assign(new Error(`rpc timeout: ${method}`), { name: 'TimeoutError' }));
}, timeout)
: null;
const settle = (fn) => (v) => { if (timer) clearTimeout(timer); fn(v); };
if (ctrl.signal) {
if (ctrl.signal.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
ctrl.signal.addEventListener('abort', () => {
target.postMessage({ t: TYPE.CANCEL, id });
pending.delete(id);
if (timer) clearTimeout(timer);
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
}
pending.set(id, { resolve: settle(resolve), reject: settle(reject), onProgress: ctrl.onProgress });
target.postMessage({ t: TYPE.REQ, id, m: method, a: args }, transferListOf(args));
});
}
return { call, pendingCount: () => pending.size };
}
/** worker 侧 */
export function exposeRpc(handlers, scope = self) {
const inflight = new Map();
scope.addEventListener('message', async (ev) => {
const d = ev.data;
if (!d || typeof d !== 'object') return;
if (d.t === TYPE.CANCEL) {
inflight.get(d.id)?.(); // 触发协作式取消
return;
}
if (d.t !== TYPE.REQ) return;
const ctx = { cancelled: false };
inflight.set(d.id, () => { ctx.cancelled = true; }); // 供业务代码查询/挂回调
try {
const fn = handlers[d.m];
if (!fn) throw new Error(`no such method: ${d.m}`);
const v = await fn(...d.a, {
id: d.id,
progress: (v) => scope.postMessage({ t: TYPE.PROGRESS, id: d.id, v }),
get cancelled() { return ctx.cancelled; },
onCancel: (cb) => { inflight.set(d.id, () => { ctx.cancelled = true; cb?.(); }); },
});
if (ctx.cancelled) return; // 已取消:不要回包(主线程那边已 reject)
scope.postMessage({ t: TYPE.RES, id: d.id, v }, transferListOf(v));
} catch (e) {
scope.postMessage({ t: TYPE.ERR, id: d.id, e: serializeError(e) });
} finally {
inflight.delete(d.id);
}
});
}
worker 侧的业务代码(workers/rpc-worker.mjs):
js
import { exposeRpc } from '../rpc.mjs';
function kernel(iters, seed) {
let x = seed;
for (let i = 0; i < iters; i++) x = (x * 31 + i) % 2147483647;
return x;
}
exposeRpc({
add: (a, b) => a + b,
// 就地算,结果用 transfer 交回主线程(零拷贝回去)
scale: (arr, k) => {
const out = new Float32Array(arr.length);
for (let i = 0; i < arr.length; i++) out[i] = arr[i] * k;
return out; // 返回的值会被 transferListOf(v) 自动转移
},
boom: () => { throw new RangeError('算不了'); },
// 注意签名:ctx 永远排在业务参数最后一位
async longTask(n, ctx) {
for (let i = 0; i < n; i++) {
kernel(20000, i);
if (i % 10 === 0) ctx.progress({ done: i, total: n });
if (ctx.cancelled) throw Object.assign(new Error('cancelled'), { name: 'AbortError' });
if (i % 5 === 0) await new Promise((r) => setTimeout(r, 0)); // 让 CANCEL 消息有机会被处理
}
return n;
},
});
// 就绪信号:主线程据此决定何时开始派活(宁可多一条消息,也不要「发出去不知道谁接了」)
self.postMessage({ cmd: 'boot' });
主线程侧(rpc.html 里实测过的调用姿势):
js
import { createRpc } from './rpc.mjs';
const worker = new Worker(new URL('./workers/rpc-worker.mjs', import.meta.url), { type: 'module' });
const rpc = createRpc(worker, { timeout: 5000 });
await rpc.call('add', 2, 3); // 5
const src = new Float32Array([1, 2, 3, 4]);
const scaled = await rpc.call('scale', src, 10); // "10,20,30,40"
src.byteLength; // 0 ------ 参数已被转移(想保留就别传可转移对象)
try { await rpc.call('boom'); } catch (e) { e.name; } // 'RangeError',message 原样带回
const ac = new AbortController();
setTimeout(() => ac.abort(), 30);
await rpc.call('longTask', 400, { onProgress: (p) => console.log(p), signal: ac.signal }); // AbortError
实测断言(6 条,全部通过):
| 行为 | 实测结果 |
|---|---|
| 普通调用 | add(2,3) → 5 |
| 返回值自动转移 | scale 返回 16 字节 Float32Array,回包即转移 |
| 参数被转移 | 调用后 src.byteLength === 0 |
| 错误保真 | RangeError/算不了(name + message 都保留) |
| 超时 | TimeoutError(20ms 超时打在长任务上) |
| 协作式取消 | AbortError,且取消前已收到 progress |
| 并发压力 | 2000 次并发调用后 pendingCount() === 0(无泄漏) |
最后一条是我最在意的:RPC 封装最容易出的不是功能 bug,是 pending map 泄漏 ------超时、取消、worker 崩溃三条路径上只要有一条忘记 delete,长时间运行的页面就会缓慢吃内存。所以 pendingCount() 必须是被断言的公开 API。
2.4 OffscreenCanvas 渲染管线:交接、尺寸、字体、交互
一个能上生产的 worker + OffscreenCanvas 需要四件套:交接、尺寸同步、字体、交互转发 。下面把四件套写成最小完整形态;跑基准用的完整版在 workers/render-worker.mjs(多了场景几何与逐帧计时),两者是同一条管线。
主线程侧(交接 + 尺寸同步 + 交互转发):
js
const canvas = document.createElement('canvas');
canvas.style.cssText = 'width:800px;height:600px';
const offscreen = canvas.transferControlToOffscreen(); // 实测 0.17~0.25ms,一次性且不可逆
const worker = new Worker(new URL('./workers/render-worker.mjs', import.meta.url), { type: 'module' });
worker.postMessage(
{ cmd: 'init', canvas: offscreen, dpr: devicePixelRatio, w: 800, h: 600, n: 12000 },
[offscreen], // 必须转移,否则拿不到所有权
);
// worker 里没有 resize 事件:尺寸变化必须由主线程算好再发过去
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
worker.postMessage({ cmd: 'resize', w: width, h: height, dpr: devicePixelRatio });
});
ro.observe(canvas);
// 命中测试在 worker 里:主线程只负责把坐标搬过去
canvas.addEventListener('pointerdown', (e) => {
worker.postMessage({ cmd: 'hit', x: e.offsetX, y: e.offsetY, pointerId: e.pointerId });
});
worker 侧(渲染循环 + 尺寸/字体):
js
let ctx, W, H, DPR, N, frames = 0;
self.onmessage = async (e) => {
const d = e.data;
if (d.cmd === 'init') {
DPR = d.dpr; W = d.w; H = d.h; N = d.n;
d.canvas.width = Math.round(W * DPR);
d.canvas.height = Math.round(H * DPR);
// worker 里有 self.fonts,没有 document.fonts / @font-face
// (本篇资产里没有自定义字体文件,这段是写法示范;难点三那个 15.5% 的 fallback 差异是用
// 「monospace vs 不存在的字体族」实测出来的)
const face = new FontFace('SceneFont', 'url(/fonts/scene.woff2)');
await face.load();
self.fonts.add(face); // 不加这一步 → 静默 fallback(实测宽度差 15.5%)
ctx = d.canvas.getContext('2d', { alpha: false });
requestAnimationFrame(loop); // worker 里的 rAF 是可用的
}
if (d.cmd === 'resize') {
DPR = d.dpr; W = d.w; H = d.h;
d.canvas.width = Math.round(W * DPR); // 注意:worker 侧持有的是 OffscreenCanvas
d.canvas.height = Math.round(H * DPR);
}
if (d.cmd === 'hit') {
// 命中测试只能基于 worker 自己维护的几何索引
const picked = index.at(d.x * DPR, d.y * DPR);
self.postMessage({ cmd: 'picked', pointerId: d.pointerId, id: picked?.id ?? null });
}
};
function loop(t) {
const t0 = performance.now();
drawScene(ctx, t, W, H, DPR, N);
frameCost.push(performance.now() - t0); // 这个数组就是本文 n3000/n12000 那张表的来源
requestAnimationFrame(loop);
}
需要单独点出来的两个细节:
canvas.width必须乘 DPR ,而 CSS 尺寸由主线程负责。worker 里改width会清空画布并重置变换矩阵 ------所以resize之后要重新setTransform,否则整个场景会被缩放两次。- 渲染循环里不要在每帧
postMessage汇报状态 。实测消息地板 15µs,60fps 下每帧一条汇报就占掉 0.9% 的主线程时间;正确做法是攒一批再报(或者只把「要展示的东西」写到 SAB 的固定槽位里)。
2.5 Worker 池:拉取模型 + 预热 + 任务粒度
由难点四的实测,池的设计规则是硬的:
| 决策 | 规则 | 依据 |
|---|---|---|
| 池大小(ALU 型) | ≈ hardwareConcurrency,封顶到任务数 |
8 worker / 8 任务效率 0.97 |
| 池大小(内存型) | 2~4,不要跟着核数走 | 8 worker 效率 0.33 ,单任务劣化 3.33× |
| 创建时机 | 页面空闲时预热,不要任务来了再建 | 每 worker 4.87~5.63ms |
| 派发方式 | 拉取(worker 空闲就领下一个) | 推模型在大任务上会饿死尾部 |
| 任务粒度 | 每个任务 ≥ 1ms | 消息地板 15µs |
| 单 worker 工作集 | 计入「池大小 × 工作集」 | worker 堆对主线程完全不可见 |
拉取模型的最小实现(本文实测用它跑出上面所有池数据):
js
class Pool {
#workers = [];
#idle = [];
#queue = [];
#size;
#url;
constructor(size, url, { warm = 0 } = {}) {
this.#size = Math.min(size, navigator.hardwareConcurrency || 4);
this.#url = url;
// 预热:空闲时先建好,别把 5ms 启动成本算到用户交互上
for (let i = 0; i < (warm || this.#size); i++) this.#spawn();
}
#spawn() {
const w = new Worker(new URL(this.#url, import.meta.url), { type: 'module' });
w.__job = null;
w.__ready = false;
w.onmessage = (e) => {
// 第一步必须是握手:worker 发 {cmd:'boot'} 才算可用。
// (我第一版没做握手,预热 worker 的 boot 回包被当成了任务结果 ------ 池直接死锁)
if (!w.__ready) {
if (e.data && e.data.cmd === 'boot') {
w.__ready = true;
this.#idle.push(w);
this.#drain();
}
return;
}
const job = w.__job;
if (!job) return; // 迟到 / 非任务回包,丢弃而不是错配
w.__job = null;
this.#idle.push(w);
job.resolve(e.data);
this.#drain();
};
// worker 崩了要能让调用方知道,否则 Promise 永远悬着、池慢慢失血
w.onerror = (err) => {
const job = w.__job;
w.__job = null;
job?.reject(new Error('worker crashed: ' + (err.message || 'unknown')));
this.#workers = this.#workers.filter((x) => x !== w);
w.terminate();
if (this.#workers.length < this.#size) this.#spawn(); // 补人,否则并发度悄悄降级
this.#drain();
};
this.#workers.push(w);
}
#drain() {
while (this.#queue.length && this.#idle.length) {
const w = this.#idle.pop();
const job = this.#queue.shift();
w.__job = job;
w.postMessage(job.msg, job.transfer || []);
}
}
run(msg, transfer) {
return new Promise((resolve, reject) => {
this.#queue.push({ msg, transfer, resolve, reject });
this.#drain();
});
}
destroy() {
this.#workers.forEach((w) => w.terminate());
this.#workers = [];
this.#idle = [];
this.#queue.forEach((j) => j.reject(new Error('pool destroyed')));
this.#queue = [];
}
}
四个「不写就出事故」的地方:
- 握手 。worker 的第一条
{cmd:'boot'}必须先被认出来,否则它会被当成某个任务的结果------本文的测试就是这样把池跑死的(预热 4 个 worker,boot 回包被当成任务回包,同一个 worker 被重复塞进空闲列表,20 个任务里有一半的 Promise 永远不 settle)。 onerror必须 reject 当前 job。否则 Promise 悬空,调用方永远等,池慢慢失血。- 崩溃后要补一个新 worker,否则并发度悄悄降级------这是最隐蔽的一种退化:功能全对,只是越来越慢。
- 每个 worker 只挂一个
__job。postMessage无序竞争时会错配,表现为「结果串了」,极难排查。
实测验收(pool-test.html,20 个任务 / 4 worker):20 个任务全部完成、去重后 worker 实例数 ≤ 4、序号无错配、故意让 worker 抛未捕获错误后 job 正确 reject 且池自动补人并继续服务。
2.6 SharedArrayBuffer:真正需要它的两种场景
SAB 的门槛不是 flag,而是两个响应头:
js
// server.mjs:不开这两个头,SharedArrayBuffer 直接不存在
res.writeHead(200, {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
实测对照(同一份页面代码,只差这两个头):
| 条件 | crossOriginIsolated |
typeof SharedArrayBuffer |
new SharedArrayBuffer(8) |
|---|---|---|---|
| 有 COOP+COEP | true |
'function' |
成功 |
| 无 COOP+COEP | false |
'undefined' |
ReferenceError |
这个失败模式很讨厌:它不是「性能退化」,是功能直接不存在 ,而且只在线上(CDN/网关去掉了头)才暴露。所以 SAB 代码必须写成能力探测 + 降级,不能假设它有。
两条额外的硬规则(实测):
- 主线程不能用
Atomics.wait():TypeError。主线程只能用Atomics.waitAsync()(返回{async: true, value: Promise}),worker 里则可以同步阻塞。 - 纯信号往返 10µs (中位;min 5µs、p95 20µs)。这就是「跨线程一次同步」的地板,比 15µs 的消息地板略快,但在同一量级------所以不要指望用 SAB 做「高频小同步」。
四条通道做同一份活(把 4MB / 100 万个 float 逐个 sqrt 后拿回结果)的端到端实测:
| 通道 | 中位 ms | p95 ms | 往返拷贝 | 往返搬运 |
|---|---|---|---|---|
| clone 往返 | 3.645 | 12.305 | 2 份 | 8.4MB |
| transfer 往返 | 0.835 | 2.13 | 0 | 0 |
| SAB 原地改写 | 0.825 | 0.855 | 0 | 0 |
读法要小心:这里的 0.825ms 不是「传输成本」,而是「计算 + 同步 」成本(1M 次 sqrt 本身就要这个量级)。它和 clone 3.645ms 的差(≈2.8ms)才是两次克隆的开销,与难点一表里「4MB 克隆往返 2.205ms」互相印证。
transfer 和 SAB 在单次 往返上打平(0.835 vs 0.825,在噪声内)。SAB 真正不可替代的是另外两件事:
- 双工、无所有权。transfer 是「一方交出、另一方接手」,同一块 buffer 同一时刻只有一边能用;SAB 是「两个人同时读写同一块内存」,天生适合「主线程持续写、worker 持续读」的像素/音频管道。
- 不需要「换身份」。transfer 后对象身份变了(难点一坑一),SAB 的引用永远稳定------可以安全地长期持有、放进 Map、被多个 worker 共享。
生产形态通常是这样(双缓冲 + 序号,避免撕裂):
js
// 主线程:写第 buf 号缓冲,写完后用序号告诉 worker「这一帧好了」
const slots = new Int32Array(sab, 0, 4); // [seq, ready, w, h]
const pixels = new Uint8ClampedArray(sab, 16); // RGBA
function pushFrame(imageData) {
pixels.set(imageData.data);
Atomics.store(slots, 1, 1); // ready = 1
const seq = Atomics.add(slots, 0, 1) + 1; // 帧序号,单调递增
Atomics.notify(slots, 0); // 唤醒 worker(≈10µs)
return seq;
}
// worker:等新帧 → 处理 → 回序号(绝不要用「清零」做同步,会丢帧)
Atomics.wait(slots, 0, lastSeq); // worker 里可以同步阻塞
lastSeq = Atomics.load(slots, 0);
processInPlace(pixels);
双缓冲是必须的:单缓冲时主线程写下一帧会覆盖 worker 正在读的那一帧,症状是「画面周期性撕裂」,而这类 bug 在本地几乎复现不出来(因为本地机器太快,竞态窗口小)。
2.7 验收:把上面全部变成断言
本文的 25 条断言分四层,全部在 measure.mjs 里,失败即 exit 1:
语义层(6 条)------不涉及性能,只固化行为:
js
ok(c.semantics.clone_function.startsWith('THROW'), '函数不能过 structuredClone');
ok(c.semantics.clone_class_proto_lost === 'false/undefined', 'structuredClone 会丢原型');
ok(c.semantics.clone_sab_same_memory === 42, 'SAB 克隆后仍是同一块内存');
ok(c.semantics.transfer_detaches_source === 0, 'transfer 后源 buffer 必须分离');
ok(c.semantics.transfer_twice_throws.startsWith('THROW'), '同一个 buffer 不能转移两次');
ok(c.semantics.clone_typed_view_geometry_kept === 'Float32Array/4/2/16', '视图的 offset/length 会被保留');
// ... 以及 clone_cycle_kept / clone_map_set_date_regex / clone_error_type_kept / transfer_raw_view_in_list ...
通道层(4 条)------钉住「方向」,不钉绝对值(换机器绝对值会变,方向不会):
js
ok(c.sizes.every((r) => r.transfer_ms < 1), 'transfer 应在 1ms 以内(与体积无关)');
ok(c.sizes.filter((r) => r.bytes <= 16384).every((r) => r.clone_ms < 0.05 && r.transfer_ms < 0.05),
'小包时三条通道都在 50µs 以内'); // 消息地板
ok(c.sizes.filter((r) => r.bytes >= 262144).every((r) => r.clone_over_transfer > 5),
'≥256KB 时 clone 至少慢 5 倍');
ok(c.sizes.at(-1).clone_over_transfer > 100, '64MB 时 clone 应比 transfer 慢 100 倍以上');
帧与主线程层(7 条)------主线程到底有没有被占用(这是本篇真正的验收对象,因为它是唯一能区分「换线程」和「换实现」的指标):
js
ok(m.worker.wall_ms < m.main_block.wall_ms * 1.15, 'worker 不该比主线程慢(它省的不是时间)');
ok(m.worker.max_gap_ms < m.main_block.max_gap_ms / 10, 'worker 必须显著改善最大帧间隔');
ok(m.main_chunked.wall_ms > m.main_block.wall_ms * 1.3, '切片会拖慢总时长');
ok(o.font_probe.hasDocument === false, 'worker 里没有 document');
ok(s.channels.sab_inplace.result_check === true, 'SAB 原地改写的结果必须可见');
ok(s.isolation.crossOriginIsolated === true, '本页应是 crossOriginIsolated');
ok(String(s.isolation.canAtomicsWaitOnMain).startsWith('THROW'), '主线程不能 Atomics.wait');
池与隔离层(6 条):
js
ok(pl.loads.cpu.configs[1].speedup > 1.2, 'cpu 负载 2 worker 应有加速');
ok(pl.loads.cpu.configs[3].speedup > pl.loads.cpu.configs[1].speedup, '8 worker 快于 2 worker');
ok(pl.hardwareConcurrency >= 4, '本机应有多核');
ok(pl.heap_isolation.main_delta_mb === 0, 'worker 堆不应计入主线程堆');
ok(control.crossOriginIsolated === false, '对照组不应是 crossOriginIsolated');
ok(String(control.canConstructSAB).startsWith('THROW'), '没有隔离头时 SharedArrayBuffer 应不可用');
不变量层(2 条,在两个场景上各跑一次 = 4 次检查)------跨场景必须成立的性质:
js
// 搬线程不改变算力:worker 的绘制成本应与主线程相当(实测比 0.91~1.02)
ok(ratio > 0.8 && ratio < 1.25, `${scene}: 绘制成本应与主线程相当`);
// 主线程响应延迟不得更高
ok(rec.worker_offscreen.latency.median_ms <= rec.main_canvas.latency.median_ms * 1.1 + 0.05,
`${scene}: worker 模式下主线程响应延迟不得更高`);
三、应用场景
场景一:数据可视化大屏 / Canvas 图表
这是本篇数字直接对应的场景。判据来自那张 3000 vs 12000 图元的表:3000 图元时 worker 版本毫无必要 (两者都 0 错过帧,主线程延迟 4.41 vs 0.04ms 但用户无感);12000 图元时主线程版本开始掉 fps(54.6)且输入延迟 17.07ms,这时 worker 是唯一出路。
落地要点:
- 图表交互(hover 提示、缩放拖拽)必须留在主线程,它要读 DOM 做 tooltip 定位;渲染搬走之后,两者通过「主线程算好视口矩阵 → 发给 worker」通信,而不是让 worker 猜。
- 动画状态别用 postMessage 逐帧同步 。60fps 下每帧一条消息就是 15µs × 60 = 0.9ms/s 的主线程税,而且会引入一帧延迟(消息在下一次任务里才被处理)。正确做法是把动画参数写进 SAB 的固定槽位,worker 每帧自己读。
- 如果图表要能导出图片,注意交接之后主线程不能再
toDataURL()(难点三)。导出要走「worker 内convertToBlob()→ 转移 ImageBitmap/Blob 回主线程」这条路。
场景二:图像 / 视频处理(滤镜、缩略图、证件照)
这里 SAB 与 transfer 的分工最清晰:
- 一次性处理 (用户选一张图 → 出结果):
createImageBitmap→ 转移进 worker → 处理 → 转移回来。走 transfer,主线程在等待期间完全自由。 - 连续帧处理 (摄像头预览、视频滤镜):必须走 SAB 双缓冲。transfer 的单所有权语义在这里是致命的------你没法在 worker 处理第 N 帧的同时让主线程准备第 N+1 帧,除非复制(4MB 一帧、30fps = 吞吐 120MB/s 的纯浪费)。
- 像素布局用
Uint8ClampedArray+ 索引 ,一行一个连续的 stride,别用{r,g,b,a}对象(一条 1080p 帧是 200 万个对象,GC 直接崩盘)。
场景三:富文本 / 代码编辑器
Monaco / CodeMirror 已经把语法高亮放进了 worker,但只放了一部分,原因就在难点三:worker 里没有 DOM、没有布局、没有选区 API。可搬的与不可搬的分得很干净:
| 能力 | 线程 | 原因 |
|---|---|---|
| 词法分析、语法树、格式化、diff | worker | 纯文本进、纯结构出,SoA 式的数据边界 |
| 全文搜索、模糊匹配、符号索引 | worker + 池 | ALU 型负载,实测接近线性(0.97 效率) |
| 光标、选区、滚动、IME | 主线程 | 依赖布局与事件 |
| 高亮结果回填 | 主线程应用 | worker 只回 [{start,end,token}],别回 DOM/HTML |
这里最容易被忽视的是输入延迟 :语法分析单次 8~15ms,如果在主线程做,每敲一个字符就吃掉一个帧。这和本篇 main_batch_yield 那行是同一个病(最大帧间隔 40.18ms)------打字黏手不是因为打包慢,是因为键盘事件后面排着一个 15ms 的分析任务。
场景四:表格 / 虚拟滚动 / 大数据筛选
表格的典型形态是「10 万行 × 20 列,用户每次改动筛选条件都要重算聚合」。这类负载有三个特征,正好踩中本篇的三条实测结论:
- 单次占用 30~200ms → 必须搬(多搬一秒都是白屏);
- ALU 型、任务可切分(按行块切)→ 池可以跟着核数走,实测 8 worker 效率 0.97;
- 数据是「一份大表 + 多个查询」 → 大表用 SoA + transfer 只传一次,之后每个任务只传「行区间 + 条件」这种几十字节的小对象(此时 15µs 消息地板完全可以接受)。
反例:别把「每行一个任务」派下去 。1000 个任务 × 15µs = 15ms 纯调度开销,而每个任务本身可能只要 0.1ms------任务粒度低于 1ms 是池化方案的第一个死因。
场景五:地图 / 游戏 / 大画布渲染
这是 OffscreenCanvas 收益最大的场景,因为它同时满足「每帧成本高」和「持续渲染」两个条件:
- 每帧成本高:本篇 12000 图元的场景(13.62ms/帧)已经让主线程 fps 掉到 54.6;真实地图应用的每帧成本常在 10~30ms 区间,主线程版本必然是「拖动地图时按钮点不动」。
- 持续渲染 :worker 的 rAF 循环可以一直跑,主线程只在「视口变了」「数据变了」时发一条消息。主线程被 300ms 同步任务占住时,实测 worker 仍然画完了全部 120 帧(1013.3ms,每帧 3.335ms)------这就是「主线程卡死但画面还在动」的能力,也是它和一切主线程方案的代差分界。
配套要做的是命中测试索引 :worker 里没有 DOM 命中测试,pointerdown 的坐标要靠主线程转发,worker 自己的空间索引(四叉树 / 网格哈希)来做拾取。这部分工作量常常比渲染搬运本身更大------这是 OffscreenCanvas 最容易被低估的成本。
四、总结
Day31 的结论是「图片和字体的优化对象不是字节,是缺口」。Day32 沿同一条思路往下走,得到的结论更硬,因为它可以逐条量化:
「搬到 worker」买的从来不是速度,是「不再占用主线程」。
- 计算量一样时,总墙钟 610.6ms → 611.4ms,一毫秒没省;
- 绘制成本一样时,每帧 13.62ms → 13.86ms,一分钱没省;
- 换来的是:最大帧间隔 611.45ms → 23.46ms 、错过帧 36 → 0 、TBT 561ms → 0 、主线程响应延迟 17.07ms → 0.03ms。
所以判断「该不该搬」的标准,不是「这段代码慢不慢」,而是「这段代码单次占用多久、多久发生一次」。3.675ms/帧不值得搬(实测用户无感),13.62ms/帧必须搬。
具体到可复用的判断:
- 三条通道选错,代价是三个数量级 。64MB 往返:clone 56.14ms 、transfer 0.18ms 、SAB 0.015ms 。而三者的 API 只差一个可选参数------这一行代码没有任何检查能救你 ,只能靠封装(2.3 的
transferListOf)和断言。 - transfer 是「一次性所有权」,不是「复制快一点」。发出去的视图变 0 字节、回来的不是同一个对象(7 个尺寸 100% 命中)。缓冲池要 ping-pong 复用,不能按尺寸缓存视图。
- 结构比通道更值钱 。5 万条记录:structuredClone 40.87ms → JSON 16.39ms → SoA + transfer 0.885ms (46 倍 ),字节数同时从 3.5MB 降到 650KB。先把数据布局改对,再谈传得有多快。
- structuredClone 不比 JSON 快(40.87 vs 16.39ms)。它的价值是语义完整(Date/Map/Set/Error/循环引用/视图几何),不是速度。纯数据过边界时,JSON 或 SoA 都可能更快。
- 消息有 15µs 的地板,纯信号同步 10µs。这两个数决定了「任务粒度 ≥ 1ms」「别逐帧 postMessage」这两条工程纪律------它们不是风格建议,是量级结论。
- 切片不是 worker 的替代品 。
setTimeout(0)切片换来 0 掉帧但 +92% 墙钟 ;scheduler.yield()保住吞吐却把最大帧间隔打到 105.05ms (比不切片的 611ms 好,但远差于 worker 的 23.46ms)。只有 worker 同时吃下吞吐与帧率。 - 并行的天花板是带宽,不是核数 。ALU 型 8 worker 7.80×(效率 0.97) ;内存型同样 8 worker 只有 2.64×(效率 0.33) ,单任务自己就慢了 3.33 倍。所以池大小的正确问法不是「我该开几个 worker」,而是「我的任务是不是在抢内存」。
- 每个 worker 是一份独立堆 :worker 里吃 200MB,主线程堆 delta 0。这既是优势(隔离、不拖累主线程 GC),也是陷阱(监控看不见、池 × 工作集才是真实内存预算)。
- 每个门槛都要有能力探测和降级 。SAB 不是 flag 问题,是 COOP/COEP 问题:缺头时
SharedArrayBuffer直接undefined(ReferenceError),这是功能缺失 而不是性能退化。同理OffscreenCanvas不可用时要有「主线程 canvas」的整条回退路径。
本篇没有覆盖的 (免得被当成全集):Transferable Streams(把 ReadableStream 转移过线程,替代「先攒完再传」)、WebCodecs 的 VideoFrame 转移、SharedWorker 与跨标签页共享、AudioWorklet 的实时音频线程、WASM threads 与 SharedArrayBuffer 的组合、Service Worker 里的计算、OffscreenCanvas 在 WebKit 上的差异、COEP 对第三方资源的影响(credentialless)、以及 worker 的调试(DevTools 的 worker target 与 --enable-logging)。这些都在同一片战场,每一个都够单独写一篇。
下一篇 Day33 换到另一条更硬的维度:WASM------如果说 Worker 是「换个线程算」,WASM 就是「换套指令算」。那里第一条要打掉的直觉是「WASM 一定比 JS 快」,和本篇「worker 一定比主线程快」是同一类错误的另一个版本。
运行环境与文件清单
php
Day32-Worker与OffscreenCanvas.md 本篇
day32-assets/
├── cdp.mjs 零依赖 CDP 客户端(Node 22 内置 WebSocket,不用 puppeteer)
├── server.mjs 静态服务器,可开关 COOP/COEP(对照组用)
├── measure.mjs 一键跑完 5 组基准 + 25 条断言,失败 exit 1
├── bench-clone.mjs 三条通道 × 7 个体积 + 启动成本 + 14 项语义探针
├── bench-jank.mjs 同一计算量的 5 种执行姿势(含 scheduler.yield)
├── bench-offscreen.mjs 主线程 canvas vs worker OffscreenCanvas(3000 / 12000 图元)
├── bench-sab.mjs 四条通道做同一份活的端到端对比 + 隔离条件对照
├── bench-pool.mjs worker 池:ALU 型 vs 内存型,1/2/4/8 worker
├── rpc.mjs RPC 层(文章版)------ 转移/错误/超时/取消/2000 并发无泄漏
├── pool-lib.mjs 文章版 Pool 类(含握手、崩溃补人)
├── test-article-code.mjs 验证「文章里贴的代码」真能跑:12 条断言
├── rpc.html · pool-test.html 上面两个的测试页
├── workers/echo.mjs · echo-classic.js · render-worker.mjs · sab-worker.mjs
│ · pool.mjs · hog.mjs · rpc-worker.mjs
├── *.html 6 个基准页
└── bench-results.json 本次全部原始数据
全部是实测。这一篇没有模型推演,因为线程与画布的行为都能在浏览器里直接量。
另外,文章中贴出的 RPC 层与 Pool 类本身也是被验证过的 :node test-article-code.mjs 会跑 12 条断言(普通调用、参数/返回值自动转移、错误保真、超时、协作式取消、2000 并发无泄漏、20 任务/4 worker 无错配、worker 崩溃后 reject 并自动补人)。写这篇文章的过程中,这套测试抓出了两个真 bug------Pool 没做握手导致预热 worker 的 boot 回包被当成任务结果(池死锁) ,以及 RPC 把 signal/onProgress 这类控制字段一起塞进了 postMessage(DataCloneError)。这两个坑都已经写进正文。
| 部分 | 口径 |
|---|---|
| 通道耗时、启动成本、语义矩阵 | 真实 Chrome,往返中位值,iters 随体积自适应(每档至少搬 128MB) |
| 帧间隔 / 长任务 / TBT | requestAnimationFrame 序列 + PerformanceObserver('longtask') |
| 主线程响应延迟 | MessageChannel 每 50ms 发一次,测「排队多久被处理」 |
| OffscreenCanvas 每帧成本 | 场景内部 performance.now() 包住绘制调用 |
| 池的加速比 | 拉取模型派发,8 个等长任务,1/2/4/8 worker 各跑一遍 |
| 隔离对照 | 同一份页面代码,一份带 COOP/COEP、一份不带 |
几点必须说清的边界:
- 绝不比较 worker 与主线程的 fps。headless 环境下 worker 的 rAF 不受 vsync 限制(同一场景 worker 侧 1013ms 画完 120 帧,主线程侧 1992ms),所以本篇只比「每帧成本」与「主线程是否被占用」。真实浏览器里两者都会被 vsync 钉住。
- 绝对毫秒会随机器状态漂移,比值不会 。同一台机器上,素数筛单任务的绝对值在不同轮次间从 75ms 漂到 448ms(热降频与内存压力),但「8 worker 效率 0.33」这个结论每一轮都成立。所以断言只钉方向与量级,不钉绝对值。
- 本地静态服务器,没有网络延迟 。worker 脚本的获取在真实项目里要过一次网络(
type: 'module'的模块图可能有多层),启动成本会高于实测的 4.87~5.63ms 。冷缓存下模块 worker 比经典 worker 多约 1.5ms(独立跑过一次:6.97 vs 5.44ms),预热后几乎相等(2.095 vs 1.925ms)。 transferControlToOffscreen每个 canvas 只能调一次,本文的基准里用的是「每次新建 canvas」,真实项目要做的是「一个 canvas 一块 worker」的长期绑定。- 25 条断言不是全集 ,只是本篇讨论过的那些性质。真实项目的验收清单还应包含:worker 崩溃恢复、
unhandledrejection传播、SAB 双缓冲撕裂检测、降级路径回归。
复现:
bash
cd day32-assets
node measure.mjs # 5 组基准 + 25 条断言(约 3 分钟,失败 exit 1)
node test-article-code.mjs # 文章里贴的 RPC / Pool 代码:12 条断言
(Node v22.23.3 / Chrome for Testing 149.0.7827.55 / macOS / headless=new,2026-10-23)
附录:完整代码
以下文件均来自
day32-assets/(完整源码,非片段)。bench-*.mjs通过cdp.mjs驱动真实 Chrome,test-article-code.mjs断言正文中的每一条结论。
pool-lib.mjs ------ Worker 池核心(并发槽、任务队列、取消)
javascript
class Pool {
#workers = [];
#idle = [];
#queue = [];
#size;
#url;
constructor(size, url, { warm = 0 } = {}) {
this.#size = Math.min(size, navigator.hardwareConcurrency || 4);
this.#url = url;
// 预热:空闲时先建好,别把 5ms 启动成本算到用户交互上
for (let i = 0; i < (warm || this.#size); i++) this.#spawn();
}
#spawn() {
const w = new Worker(new URL(this.#url, import.meta.url), { type: 'module' });
w.__job = null;
w.__ready = false;
w.onmessage = (e) => {
// 第一步必须是握手:worker 发 {cmd:'boot'} 才算可用。
// (我第一版没做握手,预热 worker 的 boot 回包被当成了任务结果 ------ 池直接死锁)
if (!w.__ready) {
if (e.data && e.data.cmd === 'boot') {
w.__ready = true;
this.#idle.push(w);
this.#drain();
}
return;
}
const job = w.__job;
if (!job) return; // 迟到 / 非任务回包,丢弃而不是错配
w.__job = null;
this.#idle.push(w);
job.resolve(e.data);
this.#drain();
};
// worker 崩了要能让调用方知道,否则 Promise 永远悬着、池慢慢失血
w.onerror = (err) => {
const job = w.__job;
w.__job = null;
job?.reject(new Error('worker crashed: ' + (err.message || 'unknown')));
this.#workers = this.#workers.filter((x) => x !== w);
w.terminate();
if (this.#workers.length < this.#size) this.#spawn(); // 补人,否则并发度悄悄降级
this.#drain();
};
this.#workers.push(w);
}
#drain() {
while (this.#queue.length && this.#idle.length) {
const w = this.#idle.pop();
const job = this.#queue.shift();
w.__job = job;
w.postMessage(job.msg, job.transfer || []);
}
}
run(msg, transfer) {
return new Promise((resolve, reject) => {
this.#queue.push({ msg, transfer, resolve, reject });
this.#drain();
});
}
destroy() {
this.#workers.forEach((w) => w.terminate());
this.#workers = [];
this.#idle = [];
this.#queue.forEach((j) => j.reject(new Error('pool destroyed')));
this.#queue = [];
}
}
export { Pool };
rpc.mjs ------ 主线程 <-> Worker 的 RPC(类 Comlink 协议)
javascript
// rpc.mjs --- 双侧共用:主线程 createRpc(worker) / worker 侧 exposeRpc(handlers)
const TYPE = { REQ: 1, RES: 2, ERR: 3, PROGRESS: 4, CANCEL: 5 };
/** 摊平可转移对象(转移列表里只能放 buffer,见难点一坑二) */
export function transferListOf(...values) {
const out = [];
const walk = (v, depth = 0) => {
if (!v || depth > 6) return;
if (v instanceof ArrayBuffer) out.push(v);
else if (ArrayBuffer.isView(v)) out.push(v.buffer);
else if (Array.isArray(v)) for (const x of v) walk(x, depth + 1);
else if (typeof v === 'object') for (const k of Object.keys(v)) walk(v[k], depth + 1);
};
for (const v of values) walk(v);
return [...new Set(out)]; // 重复 buffer 会抛 DataCloneError
}
const serializeError = (e) => ({ name: e?.name || 'Error', message: String(e?.message ?? e), stack: e?.stack ?? null });
const deserializeError = (o) => {
const e = new Error(o.message);
e.name = o.name; // 保住 RangeError / TimeoutError 这些语义
if (o.stack) e.stack = o.stack;
return e;
};
export function createRpc(target, { timeout = 30000 } = {}) {
const pending = new Map();
let seq = 0;
target.addEventListener('message', (ev) => {
const d = ev.data;
if (!d || typeof d !== 'object') return;
const p = pending.get(d.id);
if (!p) return; // 迟到的 / 已取消的,直接丢弃
if (d.t === TYPE.RES) { pending.delete(d.id); p.resolve(d.v); }
else if (d.t === TYPE.ERR) { pending.delete(d.id); p.reject(deserializeError(d.e)); }
else if (d.t === TYPE.PROGRESS) p.onProgress?.(d.v);
});
function call(method, ...rawArgs) {
const id = ++seq;
// 最后一个参数若带 signal/onProgress,是「本地控制项」:绝不参与序列化
const last = rawArgs[rawArgs.length - 1];
const isCtrl = last && typeof last === 'object' && !ArrayBuffer.isView(last) && ('signal' in last || 'onProgress' in last);
const ctrl = isCtrl ? last : {};
const args = isCtrl ? rawArgs.slice(0, -1) : rawArgs;
return new Promise((resolve, reject) => {
const timer = timeout > 0
? setTimeout(() => {
pending.delete(id);
target.postMessage({ t: TYPE.CANCEL, id });
reject(Object.assign(new Error(`rpc timeout: ${method}`), { name: 'TimeoutError' }));
}, timeout)
: null;
const settle = (fn) => (v) => { if (timer) clearTimeout(timer); fn(v); };
if (ctrl.signal) {
if (ctrl.signal.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
ctrl.signal.addEventListener('abort', () => {
target.postMessage({ t: TYPE.CANCEL, id });
pending.delete(id);
if (timer) clearTimeout(timer);
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
}
pending.set(id, { resolve: settle(resolve), reject: settle(reject), onProgress: ctrl.onProgress });
target.postMessage({ t: TYPE.REQ, id, m: method, a: args }, transferListOf(args));
});
}
return { call, terminate: () => pending.clear(), pendingCount: () => pending.size };
}
/** worker 侧 */
export function exposeRpc(handlers, scope = self) {
const inflight = new Map();
scope.addEventListener('message', async (ev) => {
const d = ev.data;
if (!d || typeof d !== 'object') return;
if (d.t === TYPE.CANCEL) {
inflight.get(d.id)?.(); // 触发协作式取消
return;
}
if (d.t !== TYPE.REQ) return;
const ctx = { cancelled: false };
inflight.set(d.id, () => { ctx.cancelled = true; }); // 供业务代码查询/挂回调
try {
const fn = handlers[d.m];
if (!fn) throw new Error(`no such method: ${d.m}`);
const v = await fn(...d.a, {
id: d.id,
progress: (v) => scope.postMessage({ t: TYPE.PROGRESS, id: d.id, v }),
get cancelled() { return ctx.cancelled; },
onCancel: (cb) => { inflight.set(d.id, () => { ctx.cancelled = true; cb?.(); }); },
});
if (ctx.cancelled) return; // 已取消:不要回包(主线程那边已 reject)
scope.postMessage({ t: TYPE.RES, id: d.id, v }, transferListOf(v));
} catch (e) {
scope.postMessage({ t: TYPE.ERR, id: d.id, e: serializeError(e) });
} finally {
inflight.delete(d.id);
}
});
}
measure.mjs ------ 测量脚本(调度、迁移开销、主线程占用)
javascript
// measure.mjs --- 一次跑完全部 Worker/OffscreenCanvas 基准
// 用真实 Chrome(CDP 驱动,零依赖),输出 bench-results.json + 表格 + 断言
// 断言失败 → exit 1(本篇的数字都是「可复现的断言」,不是截图)
import fs from 'node:fs';
import path from 'node:path';
import { launch, newPage } from './cdp.mjs';
import { serve } from './server.mjs';
const ROOT = path.dirname(new URL(import.meta.url).pathname);
const PORT = Number(process.env.DSH_PORT || 0);
const results = { meta: {}, benches: {} };
const failures = [];
const ok = (cond, msg) => { if (!cond) failures.push(msg); return cond; };
function table(rows, cols) {
const head = '| ' + cols.map((c) => c.label).join(' | ') + ' |';
const sep = '|' + cols.map(() => '---').join('|') + '|';
const body = rows.map((r) => '| ' + cols.map((c) => r[c.key]).join(' | ') + ' |');
return [head, sep, ...body].join('\n');
}
const main = async () => {
const iso = await serve(ROOT, PORT, { isolate: true });
const noIso = await serve(ROOT, 0, { isolate: false }); // 对照组:不发 COOP/COEP
const browser = await launch({ port: 9410 + (PORT % 100) });
results.meta = {
date: new Date().toISOString(),
browser: browser.version,
origin_isolated: iso.origin,
origin_plain: noIso.origin,
node: process.version,
};
console.log(`# 浏览器 ${browser.version} | node ${process.version}`);
const p = await newPage(browser.port);
await p.send('Runtime.enable');
const pageErrors = [];
p.on('Runtime.exceptionThrown', (e) => pageErrors.push(String(e.exceptionDetails?.exception?.description || e.exceptionDetails?.text).slice(0, 200)));
// ---------- 1. worker 池(放最前:冷机测,减少热降频影响) ----------
await p.goto(iso.origin + '/pool.html');
results.benches.pool = await p.eval('window.__run()');
const pl0 = results.benches.pool;
console.log('\n## 1. worker 池:加速比、争抢、启动成本\n');
for (const [kind, rec] of Object.entries(pl0.loads)) {
console.log(`\n### 负载 ${kind}(单任务 ${kind === 'cpu' ? pl0.cpu_task_ms_single : pl0.mem_task_ms_single} ms)`);
console.log(table(rec.configs, [
{ key: 'workers', label: 'worker 数' },
{ key: 'wall_ms', label: '墙钟 ms' },
{ key: 'speedup', label: '加速比' },
{ key: 'efficiency', label: '效率' },
{ key: 'per_task_median_ms', label: '单任务中位 ms' },
{ key: 'slowdown_per_task', label: '单任务变慢倍数' },
{ key: 'missed_frames', label: '主线程错过帧' },
]));
}
console.log('\n启动边际成本:', JSON.stringify(pl0.spawn));
console.log('堆隔离:', JSON.stringify(pl0.heap_isolation), '| hardwareConcurrency =', pl0.hardwareConcurrency);
ok(pl0.loads.cpu.configs[1].speedup > 1.2, 'cpu 负载 2 worker 应有加速');
ok(pl0.loads.cpu.configs[3].speedup > pl0.loads.cpu.configs[1].speedup, '8 worker 快于 2 worker');
ok(pl0.hardwareConcurrency >= 4, '本机应有多核');
ok(pl0.heap_isolation.main_delta_mb === 0, 'worker 堆不应计入主线程堆');
// ---------- 2. 三条通道 + 启动 + 语义 ----------
await p.goto(iso.origin + '/clone.html');
results.benches.clone = await p.eval('window.__run()');
const c = results.benches.clone;
console.log('\n## 2. postMessage 三条通道(往返一次的中位耗时)\n');
console.log(table(c.sizes, [
{ key: 'bytes', label: '字节' },
{ key: 'clone_ms', label: 'clone ms' },
{ key: 'transfer_ms', label: 'transfer ms' },
{ key: 'sab_ms', label: 'SAB ms' },
{ key: 'clone_mbps', label: 'clone MB/s' },
{ key: 'clone_over_transfer', label: 'clone/transfer' },
]));
console.log('\n启动成本:', JSON.stringify(c.startup));
console.log('对象图:', JSON.stringify(c.graph));
console.log('语义:', JSON.stringify(c.semantics));
ok(c.sizes.every((r) => r.transfer_ms < 1), 'transfer 应在 1ms 以内(与体积无关)');
// 小包时三条通道都被「消息调度开销」支配(≈15µs),比不出差异;差异随体积出现
ok(c.sizes.filter((r) => r.bytes <= 16384).every((r) => r.clone_ms < 0.05 && r.transfer_ms < 0.05), '小包时三条通道都在 50µs 以内');
ok(c.sizes.filter((r) => r.bytes >= 262144).every((r) => r.clone_over_transfer > 5), '≥256KB 时 clone 至少慢 5 倍');
ok(c.sizes[c.sizes.length - 1].clone_over_transfer > 100, '64MB 时 clone 应比 transfer 慢 100 倍以上');
ok(c.semantics.clone_function.startsWith('THROW'), '函数不能过 structuredClone');
ok(c.semantics.clone_class_proto_lost === 'false/undefined', 'structuredClone 会丢原型');
ok(c.semantics.clone_sab_same_memory === 42, 'SAB 克隆后仍是同一块内存');
ok(c.semantics.transfer_detaches_source === 0, 'transfer 后源 buffer 必须分离');
ok(c.semantics.transfer_twice_throws.startsWith('THROW'), '同一个 buffer 不能转移两次');
ok(c.semantics.clone_typed_view_geometry_kept === 'Float32Array/4/2/16', '视图的 offset/length 会被保留');
// ---------- 2. 主线程卡顿:四种姿势 ----------
await p.goto(iso.origin + '/jank.html');
results.benches.jank = await p.eval('window.__run()');
const j = results.benches.jank;
console.log('\n## 3. 同样的计算量,五种执行姿势\n');
console.log(table(Object.entries(j.modes).map(([k, v]) => ({ mode: k, ...v })), [
{ key: 'mode', label: '姿势' },
{ key: 'wall_ms', label: '墙钟 ms' },
{ key: 'max_gap_ms', label: '最大帧间隔 ms' },
{ key: 'p95_gap_ms', label: 'p95 帧间隔 ms' },
{ key: 'dropped_over_32ms', label: '>32ms 间隔数' },
{ key: 'missed_frames', label: '错过帧数' },
{ key: 'tbt_ms', label: 'TBT ms' },
]));
const m = j.modes;
ok(m.worker.wall_ms < m.main_block.wall_ms * 1.15, 'worker 不该比主线程慢');
ok(m.worker.max_gap_ms < m.main_block.max_gap_ms / 10, 'worker 必须显著改善最大帧间隔');
ok(m.main_chunked.wall_ms > m.main_block.wall_ms * 1.3, '切片会拖慢总时长');
// ---------- 3. OffscreenCanvas ----------
await p.goto(iso.origin + '/offscreen.html');
results.benches.offscreen = await p.eval('window.__run()');
const o = results.benches.offscreen;
console.log('\n## 4. 主线程 canvas vs worker OffscreenCanvas\n');
for (const [scene, rec] of Object.entries(o.scenes)) {
console.log(`\n### 场景 ${scene}(每帧 ${rec.n} 个图元,${o.frames} 帧,DPR=${o.dpr})`);
const rows = [
{ mode: 'main_canvas', draw: rec.main_canvas.draw_median_ms, lat: rec.main_canvas.latency.median_ms, lat95: rec.main_canvas.latency.p95_ms, gap: rec.main_canvas.frames.median_gap_ms, missed: rec.main_canvas.frames.missed_frames, fps: rec.main_canvas.fps, over: rec.main_canvas.over_budget_16ms },
{ mode: 'worker_offscreen', draw: rec.worker_offscreen.draw_median_ms, lat: rec.worker_offscreen.latency.median_ms, lat95: rec.worker_offscreen.latency.p95_ms, gap: rec.worker_offscreen.frames.median_gap_ms, missed: rec.worker_offscreen.frames.missed_frames, fps: '-', over: rec.worker_offscreen.over_budget_16ms },
];
console.log(table(rows, [
{ key: 'mode', label: '模式' },
{ key: 'draw', label: '每帧绘制 ms' },
{ key: 'lat', label: '主线程响应中位 ms' },
{ key: 'lat95', label: 'p95 ms' },
{ key: 'gap', label: '主线程帧间隔 ms' },
{ key: 'missed', label: '错过帧数' },
{ key: 'fps', label: 'fps' },
{ key: 'over', label: '超 16.7ms 帧数' },
]));
if (rec.worker_main_busy) console.log('主线程被占 300ms 时:', JSON.stringify(rec.worker_main_busy));
// 绘制成本应几乎一致:搬走的是线程,不是算力
const ratio = rec.worker_offscreen.draw_median_ms / rec.main_canvas.draw_median_ms;
ok(ratio > 0.8 && ratio < 1.25, `${scene}: worker 绘制成本应与主线程相当(实测比 ${ratio.toFixed(2)})`);
ok(rec.worker_offscreen.latency.median_ms <= rec.main_canvas.latency.median_ms * 1.1 + 0.05, `${scene}: worker 模式下主线程响应延迟不得更高`);
}
ok(o.font_probe && o.font_probe.hasDocument === false, 'worker 里没有 document');
// ---------- 4. SAB ----------
await p.goto(iso.origin + '/sab.html');
results.benches.sab = await p.eval('window.__run()');
const s = results.benches.sab;
console.log('\n## 5. SharedArrayBuffer:同一块内存\n');
console.log(table(Object.entries(s.channels).map(([k, v]) => ({ channel: k, ...v })), [
{ key: 'channel', label: '通道' },
{ key: 'median_ms', label: '中位 ms' },
{ key: 'p95_ms', label: 'p95 ms' },
{ key: 'copies_per_round', label: '往返拷贝次数' },
{ key: 'bytes_moved_mb', label: '往返搬运 MB' },
]));
console.log('纯信号往返:', JSON.stringify(s.atomics.signal_roundtrip_us), '(µs)');
console.log('隔离条件:', JSON.stringify(s.isolation));
ok(s.channels.sab_inplace.result_check === true, 'SAB 原地改写的结果必须可见');
ok(s.isolation.crossOriginIsolated === true, '本页应是 crossOriginIsolated');
ok(String(s.isolation.canAtomicsWaitOnMain).startsWith('THROW'), '主线程不能 Atomics.wait');
// 对照:没有 COOP/COEP 的页面
const p2 = await newPage(browser.port);
await p2.goto(noIso.origin + '/isolation.html');
results.benches.isolation_control = await p2.eval('window.__probe()');
console.log('无 COOP/COEP 对照组:', JSON.stringify(results.benches.isolation_control));
ok(results.benches.isolation_control.crossOriginIsolated === false, '对照组不应是 crossOriginIsolated');
ok(String(results.benches.isolation_control.canConstructSAB).startsWith('THROW'), '没有隔离头时 SharedArrayBuffer 应不可用');
p2.close();
// ---------- 输出 ----------
if (pageErrors.length) { results.meta.page_errors = pageErrors; console.log('\n页面错误:', pageErrors.slice(0, 5)); }
fs.writeFileSync(path.join(ROOT, 'bench-results.json'), JSON.stringify(results, null, 1));
console.log('\n结果已写入 bench-results.json');
console.log(`\n断言:${failures.length === 0 ? '全部通过' : '失败 ' + failures.length + ' 条'}`);
failures.forEach((f) => console.log(' x ' + f));
p.close();
browser.kill();
iso.server.close();
noIso.server.close();
process.exit(failures.length ? 1 : 0);
};
main().catch((e) => {
console.error('基准失败:', e.message);
process.exit(2);
});
cdp.mjs ------ CDP 驱动程序(真实 Chrome 里跑基准)
javascript
// cdp.mjs --- 零依赖 Chrome DevTools Protocol 客户端
// Node 22 内置 global.WebSocket,所以不需要 puppeteer/playwright
// 用途: 驱动真实 Chrome 跑 Worker / OffscreenCanvas / SAB 基准
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import fs from 'node:fs';
const CH_CANDIDATES = [
`${process.env.HOME}/Library/Caches/ms-playwright/chromium-1228/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
];
export function findChrome() {
for (const p of CH_CANDIDATES) if (fs.existsSync(p)) return p;
throw new Error('找不到 Chrome,请设置 CHROME_PATH');
}
/** 启动 headless Chrome,返回 { proc, port, kill } */
export async function launch({ port = 9333, userDataDir, headless = true } = {}) {
const exe = process.env.CHROME_PATH || findChrome();
const dir = userDataDir || fs.mkdtempSync('/tmp/d32-chrome-');
const args = [
`--user-data-dir=${dir}`,
`--remote-debugging-port=${port}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-extensions',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows',
'--disable-features=CalculateNativeWinOcclusion',
'--window-size=1280,800',
];
if (headless) args.push('--headless=new');
const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
proc.stderr.on('data', () => {});
for (let i = 0; i < 250; i++) {
try {
const r = await fetch(`http://127.0.0.1:${port}/json/version`);
if (r.ok) {
const v = await r.json();
await sleep(150);
return { proc, port, version: v['Browser'], kill: () => killProc(proc) };
}
} catch {}
await sleep(100);
}
killProc(proc);
throw new Error('Chrome devtools 端口未就绪');
}
function killProc(proc) {
try { proc.kill('SIGKILL'); } catch {}
}
/** 新建标签页并连上它 */
export async function newPage(port) {
const r = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' });
const target = await r.json();
return connect(target.webSocketDebuggerUrl);
}
/** 极简 CDP 会话 */
export async function connect(url) {
const ws = new WebSocket(url);
let id = 0;
const pending = new Map();
const listeners = new Map();
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = (e) => rej(new Error('ws error ' + e.message));
});
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(`${msg.error.message} ${JSON.stringify(msg.error.data ?? '')}`)) : res(msg.result);
} else if (msg.method) {
const fns = listeners.get(msg.method) || [];
for (const f of fns) f(msg.params);
}
};
const send = (method, params = {}) =>
new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
const once = (method, timeout = 30000) =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error('timeout ' + method)), timeout);
const fns = listeners.get(method) || [];
const fn = (p) => {
clearTimeout(t);
listeners.set(method, (listeners.get(method) || []).filter((x) => x !== fn));
res(p);
};
fns.push(fn);
listeners.set(method, fns);
});
return {
send,
once,
on: (method, fn) => {
const fns = listeners.get(method) || [];
fns.push(fn);
listeners.set(method, fns);
},
async goto(url) {
await send('Page.enable');
const loaded = once('Page.loadEventFired', 20000);
await send('Page.navigate', { url });
await loaded;
},
/** 在页面里求值(支持 async,返回可序列化值) */
async eval(expression, { awaitPromise = true, timeout = 300000 } = {}) {
const r = await send('Runtime.evaluate', {
expression,
awaitPromise,
returnByValue: true,
allowUnsafeEvalBlockedByCSP: true,
});
if (r.exceptionDetails) {
const d = r.exceptionDetails;
throw new Error('page error: ' + (d.exception?.description || d.text));
}
return r.result.value;
},
close() {
try { ws.close(); } catch {}
},
};
}
server.mjs ------ 本地静态服务器
javascript
// server.mjs --- 静态服务器,带 COOP/COEP(SharedArrayBuffer 的硬前提)
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const MIME = {
'.html': 'text/html; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.wasm': 'application/wasm',
'.png': 'image/png',
};
export function serve(root, port = 0, opts = {}) {
const isolate = opts.isolate !== false; // false = 不发 COOP/COEP(对照组)
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
let p = path.join(root, decodeURIComponent(url.pathname));
if (url.pathname === '/') p = path.join(root, 'index.html');
fs.readFile(p, (err, buf) => {
if (err) { res.writeHead(404).end('not found: ' + url.pathname); return; }
const headers = {
'Content-Type': MIME[path.extname(p)] || 'application/octet-stream',
'Cache-Control': 'no-store',
};
if (isolate) {
// 这两个头是 SharedArrayBuffer 的必要条件(crossOriginIsolated)
headers['Cross-Origin-Opener-Policy'] = 'same-origin';
headers['Cross-Origin-Embedder-Policy'] = 'require-corp';
headers['Cross-Origin-Resource-Policy'] = 'same-origin';
}
res.writeHead(200, headers);
res.end(buf);
});
});
return new Promise((resolve) => {
server.listen(port, '127.0.0.1', () => {
const addr = server.address();
resolve({ server, port: addr.port, origin: `http://127.0.0.1:${addr.port}` });
});
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
const port = Number(process.argv[2] || 8080);
const { origin } = await serve(path.dirname(new URL(import.meta.url).pathname), port);
console.log('serving', origin, '(COOP/COEP on)');
}
bench-pool.mjs ------ 基准:Worker 池 vs 单线程
javascript
// bench-pool.mjs --- 并行不是免费的
// 同构任务池,两种负载各跑一遍:
// cpu: 纯算术(ALU 受限) mem: 素数筛(内存带宽受限,每任务分配 16MB)
// 池大小 1/2/4/8,worker 用「拉取」方式取活
const TASKS = 8;
const CONFIGS = [1, 2, 4, 8];
const MEM_LIMIT = 16_000_000;
const TARGET_TASK_MS = 75;
function frameProbe() {
const gaps = [];
let last = performance.now();
let running = true;
const onFrame = () => {
if (!running) return;
const now = performance.now();
gaps.push(now - last);
last = now;
requestAnimationFrame(onFrame);
};
requestAnimationFrame(onFrame);
return {
stop() {
running = false;
const s = [...gaps].sort((a, b) => a - b);
const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
return {
frames: gaps.length,
median_gap_ms: +pick(0.5).toFixed(2),
p95_gap_ms: +pick(0.95).toFixed(2),
max_gap_ms: +gaps.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
dropped_over_32ms: gaps.filter((g) => g > 32).length,
missed_frames: gaps.reduce((acc, g) => acc + Math.max(0, Math.round(g / 16.667) - 1), 0),
};
},
};
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const spawn1 = () => new Worker(new URL('./workers/pool.mjs', import.meta.url), { type: 'module' });
const booted = (w) => new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'boot') { w.removeEventListener('message', h); r(); } }));
/** 在 n 个 worker 上用拉取模型跑 TASKS 个任务 */
async function runPool(n, makeTask) {
const workers = [];
for (let i = 0; i < n; i++) {
const w = spawn1();
workers.push(w);
await booted(w);
}
const t0 = performance.now();
let next = 0;
const perTask = [];
await new Promise((res) => {
let done = 0;
workers.forEach((w) => {
const onMsg = (e) => {
if (e.data.cmd !== 'result') return;
perTask.push(e.data.ms);
if (++done === TASKS) return res();
if (next < TASKS) w.postMessage(makeTask(next++));
};
w.addEventListener('message', onMsg);
if (next < TASKS) w.postMessage(makeTask(next++));
});
});
const wall = performance.now() - t0;
workers.forEach((w) => w.terminate());
return { wall, perTask };
}
export async function run() {
const out = { tasks: TASKS, target_task_ms: TARGET_TASK_MS, hardwareConcurrency: navigator.hardwareConcurrency, loads: {}, heap_isolation: {} };
// 标定纯算术任务的 iters
let iters = 2_000_000;
{
const w = spawn1();
await booted(w);
for (let r = 0; r < 8; r++) {
const got = await new Promise((res) => {
w.addEventListener('message', function h(e) { if (e.data.cmd === 'result') { w.removeEventListener('message', h); res(e.data); } });
w.postMessage({ cmd: 'task', kind: 'cpu', seq: 0, iters });
});
if (Math.abs(got.ms - TARGET_TASK_MS) / TARGET_TASK_MS < 0.12) break;
iters = Math.max(10000, Math.round(iters * (TARGET_TASK_MS / Math.max(0.5, got.ms))));
}
const single = await new Promise((res) => {
w.addEventListener('message', function h(e) { if (e.data.cmd === 'result') { w.removeEventListener('message', h); res(e.data); } });
w.postMessage({ cmd: 'task', kind: 'cpu', seq: 0, iters });
});
out.cpu_iters = iters;
out.cpu_task_ms_single = +single.ms.toFixed(1);
// 内存型单任务基准
const single2 = await new Promise((res) => {
w.addEventListener('message', function h(e) { if (e.data.cmd === 'result') { w.removeEventListener('message', h); res(e.data); } });
w.postMessage({ cmd: 'task', kind: 'mem', seq: 0, limit: MEM_LIMIT });
});
out.mem_task_ms_single = +single2.ms.toFixed(1);
out.mem_task_result = single2.result;
w.terminate();
}
for (const kind of ['cpu', 'mem']) {
out.loads[kind] = { configs: [] };
for (const n of CONFIGS) {
await sleep(350);
const fp = frameProbe();
const makeTask = (seq) => (kind === 'cpu' ? { cmd: 'task', kind, seq, iters } : { cmd: 'task', kind, seq, limit: MEM_LIMIT });
const { wall, perTask } = await runPool(n, makeTask);
const frames = fp.stop();
await sleep(120);
out.loads[kind].configs.push({
workers: n,
wall_ms: +wall.toFixed(1),
tasks_run: perTask.length,
per_task_median_ms: +[...perTask].sort((a, b) => a - b)[Math.floor(perTask.length / 2)].toFixed(1),
sum_cpu_ms: +perTask.reduce((a, b) => a + b, 0).toFixed(1),
frames,
missed_frames: frames.missed_frames,
});
}
const base = out.loads[kind].configs[0].wall_ms;
out.loads[kind].configs.forEach((c) => {
c.speedup = +(base / c.wall_ms).toFixed(2);
c.efficiency = +(c.speedup / Math.min(c.workers, TASKS)).toFixed(2);
// 单任务在并发下变慢了几倍(衡量资源争抢)
c.slowdown_per_task = +(c.per_task_median_ms / (kind === 'cpu' ? out.cpu_task_ms_single : out.mem_task_ms_single)).toFixed(2);
});
}
// 启动边际成本:0→N 个 worker 的 boot 时间
{
const rows = [];
for (const n of [1, 4, 8]) {
const t0 = performance.now();
const ws = [];
for (let i = 0; i < n; i++) { const w = spawn1(); ws.push(w); await booted(w); }
rows.push({ workers: n, boot_ms: +(performance.now() - t0).toFixed(1), per_worker_ms: +((performance.now() - t0) / n).toFixed(2) });
ws.forEach((w) => w.terminate());
await sleep(100);
}
out.spawn = rows;
}
// 堆隔离:worker 吃 200MB,主线程 heap 不动
{
const before = performance.memory ? performance.memory.usedJSHeapSize : null;
const w = new Worker(new URL('./workers/hog.mjs', import.meta.url), { type: 'module' });
const got = await new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'allocated') { w.removeEventListener('message', h); r(e.data); } }));
const after = performance.memory ? performance.memory.usedJSHeapSize : null;
out.heap_isolation = {
worker_allocated_mb: got.mb,
main_heap_before_mb: before != null ? +(before / 1048576).toFixed(1) : null,
main_heap_after_mb: after != null ? +(after / 1048576).toFixed(1) : null,
main_delta_mb: before != null ? +((after - before) / 1048576).toFixed(1) : null,
};
w.terminate();
}
return out;
}
bench-sab.mjs ------ 基准:SharedArrayBuffer 零拷贝
javascript
// bench-sab.mjs --- 第三通道:SharedArrayBuffer 就地改写 + Atomics 同步
// 对比 4MB 数据三种处理姿势:clone 往返 / transfer 往返 / SAB 原地改写
const SIZE = 4 * 1024 * 1024; // 4MB = 1M 个 float32
const FLOATS = SIZE / 4;
const ITERS = 60;
function newWorker(url = './workers/sab-worker.mjs') {
return new Worker(new URL(url, import.meta.url), { type: 'module' });
}
function boot(w) {
return new Promise((res) => w.addEventListener('message', function h(e) {
if (e.data.cmd === 'boot') { w.removeEventListener('message', h); res(); }
}));
}
const pct = (a, p) => { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0; };
export async function run() {
const out = { size_bytes: SIZE, floats: FLOATS, iters: ITERS, channels: {}, atomics: {}, isolation: {} };
// ---- 隔离条件探查:没有 COOP/COEP 就没有 SharedArrayBuffer ----
out.isolation = {
crossOriginIsolated: crossOriginIsolated,
hasSharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
hasAtomics: typeof Atomics !== 'undefined',
canAtomicsWaitOnMain: (() => {
if (typeof Atomics === 'undefined') return 'no Atomics';
const a = typeof SharedArrayBuffer !== 'undefined' ? new Int32Array(new SharedArrayBuffer(4)) : new Int32Array(1);
try { Atomics.wait(a, 0, 0, 0); return 'allowed'; }
catch (e) { return 'THROW ' + e.name; }
})(),
// 主线程只能用 waitAsync
hasWaitAsync: typeof Atomics !== 'undefined' && typeof Atomics.waitAsync === 'function',
};
// 三条通道做同一份活:把 1M 个 float 逐个 sqrt 后拿回结果
// ---- 通道一:clone 往返(两份数据,主线程收到副本) ----
{
const w = newWorker('./workers/echo.mjs');
await boot(w);
const src = new Float32Array(FLOATS).fill(1.5);
const times = [];
for (let i = 0; i < ITERS; i++) {
const t0 = performance.now();
await new Promise((res) => {
const h = (e) => { if (e.data.cmd === 'pong') { w.removeEventListener('message', h); res(); } };
w.addEventListener('message', h);
w.postMessage({ cmd: 'work', seq: i, payload: src, transferBack: false });
});
times.push(performance.now() - t0);
}
out.channels.clone = {
median_ms: +pct(times, 0.5).toFixed(3),
p95_ms: +pct(times, 0.95).toFixed(3),
copies_per_round: 2,
bytes_moved_mb: +(SIZE * 2 / 1e6).toFixed(1),
};
w.terminate();
}
// ---- 通道二:transfer 往返(零拷贝搬家,但对象身份每次都是新的) ----
{
const w = newWorker('./workers/echo.mjs');
await boot(w);
let cur = new Float32Array(FLOATS).fill(1.5);
const times = [];
for (let i = 0; i < ITERS; i++) {
const sent = cur;
const t0 = performance.now();
const got = await new Promise((res) => {
const h = (e) => { if (e.data.cmd === 'pong') { w.removeEventListener('message', h); res(e.data.payload); } };
w.addEventListener('message', h);
w.postMessage({ cmd: 'work', seq: i, payload: sent, transferBack: true }, [sent.buffer]);
});
times.push(performance.now() - t0);
cur = got;
}
out.channels.transfer = {
median_ms: +pct(times, 0.5).toFixed(3),
p95_ms: +pct(times, 0.95).toFixed(3),
copies_per_round: 0,
bytes_moved_mb: 0,
};
w.terminate();
}
// ---- 通道三:SAB 原地改写(同一块内存,主线程读到 worker 的结果) ----
{
const w = newWorker('./workers/sab-worker.mjs');
await boot(w);
const sab = new SharedArrayBuffer(SIZE + 8);
const buf = new Float32Array(sab, 0, FLOATS);
const ctrl = new Int32Array(sab, SIZE, 2); // [0]=seq [1]=done
buf.fill(1.5);
const times = [];
for (let i = 0; i < ITERS; i++) {
Atomics.store(ctrl, 1, 0);
const t0 = performance.now();
const waited = Atomics.waitAsync(ctrl, 1, 0, 5000);
w.postMessage({ cmd: 'transform', sab, floats: FLOATS, ctrlOffset: SIZE, seq: i });
if (waited.async) await waited.value;
times.push(performance.now() - t0);
}
// 就地改写是否真的生效:全部元素应变成 sqrt(1.5)
out.channels.sab_inplace = {
median_ms: +pct(times, 0.5).toFixed(3),
p95_ms: +pct(times, 0.95).toFixed(3),
copies_per_round: 0,
bytes_moved_mb: 0,
};
// 结果校验:单独跑一次全新数据
{
buf.fill(1.5);
Atomics.store(ctrl, 1, 0);
const waited = Atomics.waitAsync(ctrl, 1, 0, 5000);
w.postMessage({ cmd: 'transform', sab, floats: FLOATS, ctrlOffset: SIZE, seq: -1 });
if (waited.async) await waited.value;
out.channels.sab_inplace.result_check = +buf[0].toFixed(6) === +Math.sqrt(1.5).toFixed(6);
out.channels.sab_inplace.transform_ms = +(performance.now() - (performance.now() - 0)).toFixed(0); // 占位,下面重测
buf.fill(2.25);
Atomics.store(ctrl, 1, 0);
const t0 = performance.now();
const w2 = Atomics.waitAsync(ctrl, 1, 0, 5000);
w.postMessage({ cmd: 'transform', sab, floats: FLOATS, ctrlOffset: SIZE, seq: -2 });
if (w2.async) await w2.value;
out.channels.sab_inplace.pure_transform_ms = +(performance.now() - t0).toFixed(3);
}
w.terminate();
}
// ---- 纯信号:只做一次握手,payload 为 0 字节 ----
{
const w = newWorker('./workers/sab-worker.mjs');
await boot(w);
const sab = new SharedArrayBuffer(16);
const ctrl = new Int32Array(sab);
const times = [];
for (let i = 0; i < 300; i++) {
Atomics.store(ctrl, 1, 0);
const t0 = performance.now();
const waited = Atomics.waitAsync(ctrl, 1, 0, 5000);
w.postMessage({ cmd: 'signal', sab });
if (waited.async) await waited.value;
times.push((performance.now() - t0) * 1000); // µs
}
out.atomics.signal_roundtrip_us = {
median_us: +pct(times, 0.5).toFixed(2),
p95_us: +pct(times, 0.95).toFixed(2),
min_us: +Math.min(...times).toFixed(2),
};
w.terminate();
}
return out;
}
bench-offscreen.mjs ------ 基准:OffscreenCanvas 渲染
javascript
// bench-offscreen.mjs --- 同一套绘制场景,主线程 canvas vs worker OffscreenCanvas
// 三个口径:① 每帧绘制耗时(算力) ② 主线程帧间隔(会不会掉帧) ③ 主线程响应延迟(输入会不会卡)
// 注意:headless 下 worker 的 rAF 不受 vsync 限制,所以「帧率」不可比,只比「每帧成本」与「主线程是否被占」
const SCENES = [3000, 12000]; // 每帧图元数量
const FRAMES = 120;
const W = 800, H = 600;
/** 主线程响应延迟:每 50ms 发一次 MessageChannel 消息,测它多久被处理 */
function latencyProbe(intervalMs = 50) {
const ch = new MessageChannel();
const samples = [];
let running = true;
let lastSent = 0;
ch.port1.onmessage = () => { if (running) samples.push(performance.now() - lastSent); };
const timer = setInterval(() => {
lastSent = performance.now();
try { ch.port2.postMessage(0); } catch {}
}, intervalMs);
return {
stop() {
running = false;
clearInterval(timer);
const s = [...samples].sort((a, b) => a - b);
const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
return {
samples: s.length,
median_ms: +pick(0.5).toFixed(2),
p95_ms: +pick(0.95).toFixed(2),
max_ms: +s.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
};
},
};
}
function frameProbe() {
const gaps = [];
const longtasks = [];
let last = performance.now();
let running = true;
const onFrame = () => {
if (!running) return;
const now = performance.now();
gaps.push(now - last);
last = now;
requestAnimationFrame(onFrame);
};
requestAnimationFrame(onFrame);
const po = new PerformanceObserver((l) => { for (const e of l.getEntries()) longtasks.push(e.duration); });
try { po.observe({ type: 'longtask' }); } catch {}
return {
stop() {
running = false;
try { po.disconnect(); } catch {}
const s = [...gaps].sort((a, b) => a - b);
const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
return {
frames: gaps.length,
median_gap_ms: +pick(0.5).toFixed(2),
p95_gap_ms: +pick(0.95).toFixed(2),
max_gap_ms: +gaps.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
dropped_over_32ms: gaps.filter((g) => g > 32).length,
missed_frames: gaps.reduce((acc, g) => acc + Math.max(0, Math.round(g / 16.667) - 1), 0),
longtasks: longtasks.length,
tbt_ms: +longtasks.reduce((a, d) => a + Math.max(0, d - 50), 0).toFixed(1),
};
},
};
}
function buildCanvas() {
const c = document.createElement('canvas');
c.width = Math.round(W * devicePixelRatio);
c.height = Math.round(H * devicePixelRatio);
c.style.width = W + 'px';
c.style.height = H + 'px';
document.body.appendChild(c);
return c;
}
function scene(ctx, t, dpr, w, h, n) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#0b1020';
ctx.fillRect(0, 0, w * dpr, h * dpr);
ctx.scale(dpr, dpr);
for (let i = 0; i < n; i++) {
const a = (i * 0.618 + t * 0.0008) % 6.283;
const x = w / 2 + Math.cos(a) * (Math.min(w, h) * 0.34) * ((i / n) + 0.15);
const y = h / 2 + Math.sin(a * 1.7) * (Math.min(w, h) * 0.34) * ((i / n) + 0.15);
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.fillStyle = `hsl(${(i * 7) % 360} 70% ${40 + (i % 20)}%)`;
ctx.beginPath();
ctx.roundRect(-6, -6, 12 + (i % 5), 12 + (i % 5), 3);
ctx.fill();
ctx.restore();
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const stat = (times) => {
const s = [...times].sort((a, b) => a - b);
const pick = (p) => s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
return {
draw_median_ms: +pick(0.5).toFixed(3),
draw_p95_ms: +pick(0.95).toFixed(3),
draw_total_ms: +times.reduce((a, b) => a + b, 0).toFixed(1),
over_budget_16ms: times.filter((t) => t > 16.67).length,
};
};
export async function run() {
const out = { frames: FRAMES, size: { w: W, h: H }, dpr: devicePixelRatio, scenes: {}, font_probe: null };
const w = new Worker(new URL('./workers/render-worker.mjs', import.meta.url), { type: 'module' });
let fontProbe = null;
for (const N of SCENES) {
const rec = { n: N };
// ---------- A. 主线程 canvas:绘制与输入、事件、布局抢同一条线程 ----------
{
document.body.innerHTML = '';
const c = buildCanvas();
const ctx = c.getContext('2d', { alpha: false });
const fp = frameProbe();
const lp = latencyProbe();
await sleep(250);
const times = [];
const wall0 = performance.now();
await new Promise((res) => {
let n = 0;
const loop = () => {
const t0 = performance.now();
scene(ctx, t0, devicePixelRatio, W, H, N);
times.push(performance.now() - t0);
if (++n < FRAMES) requestAnimationFrame(loop);
else res();
};
requestAnimationFrame(loop);
});
const wall = performance.now() - wall0;
rec.main_canvas = { ...stat(times), wall_ms: +wall.toFixed(1), fps: +(FRAMES / (wall / 1000)).toFixed(1), latency: lp.stop(), frames: fp.stop() };
c.remove();
}
// ---------- B. worker + OffscreenCanvas:绘制搬到另一个线程 ----------
{
document.body.innerHTML = '';
const c = buildCanvas();
const t0 = performance.now();
const offscreen = c.transferControlToOffscreen();
rec.handoff_ms = +(performance.now() - t0).toFixed(3);
const init = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'inited') { w.removeEventListener('message', h); res(e.data.fontProbe); } }));
w.postMessage({ cmd: 'init', canvas: offscreen, dpr: devicePixelRatio, w: W, h: H, n: N, frames: FRAMES }, [offscreen]);
fontProbe = await init;
const fp = frameProbe();
const lp = latencyProbe();
await sleep(250);
const done = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'done') { w.removeEventListener('message', h); res(e.data); } }));
w.postMessage({ cmd: 'run' });
const r = await done;
rec.worker_offscreen = { ...stat(r.times), worker_wall_ms: +r.wall.toFixed(1), latency: lp.stop(), frames: fp.stop() };
c.remove();
}
// ---------- C. worker 继续画,主线程被 300ms 同步任务占住 ----------
if (N === SCENES[0]) {
document.body.innerHTML = '';
const c = buildCanvas();
const offscreen = c.transferControlToOffscreen();
// 先挂监听再发 init(顺序反了就是死锁)
const inited = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'inited') { w.removeEventListener('message', h); res(); } }));
w.postMessage({ cmd: 'init', canvas: offscreen, dpr: devicePixelRatio, w: W, h: H, n: N, frames: FRAMES }, [offscreen]);
await inited;
const lp = latencyProbe();
await sleep(250);
const done = new Promise((res) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'done') { w.removeEventListener('message', h); res(e.data); } }));
w.postMessage({ cmd: 'run' });
const t1 = performance.now();
let x = 1;
while (performance.now() - t1 < 300) x = (x * 31 + 7) % 2147483647; // 主线程卡死 300ms
const r = await done;
rec.worker_main_busy = { main_busy_ms: 300, worker_frames: r.frames, worker_wall_ms: +r.wall.toFixed(1), worker_draw_median_ms: +stat(r.times).draw_median_ms, latency: lp.stop() };
c.remove();
}
out.scenes['n' + N] = rec;
}
out.font_probe = fontProbe;
w.terminate();
return out;
}
bench-jank.mjs ------ 基准:主线程卡顿(jank)
javascript
// bench-jank.mjs --- 「搬走计算」到底买到了什么:三种姿势的帧稳定性对比
// A. 主线程一次算完(block) B. 主线程切片(chunk 5ms) C. Worker
// 关键指标不是总耗时,而是主线程的帧间隔分布与长任务
const BATCHES = 24; // 24 批 × ~25ms ≈ 600ms 的总计算量
const TARGET_MS = 25; // 每批目标耗时
const CHUNK_MS = 5; // 切片粒度
/** 不可被优化掉的整数运算内核 */
function kernel(iters, seed) {
let x = seed;
for (let i = 0; i < iters; i++) x = (x * 31 + i) % 2147483647;
return x;
}
/** 标定 iterations,使内核耗时 ≈ targetMs */
function calibrate(targetMs) {
let iters = 200000;
for (let round = 0; round < 12; round++) {
const t0 = performance.now();
kernel(iters, 7);
const ms = performance.now() - t0;
if (ms <= 0.001) { iters *= 8; continue; }
const scale = targetMs / ms;
if (Math.abs(scale - 1) < 0.08) break;
iters = Math.max(1000, Math.round(iters * scale));
}
return iters;
}
/** 记录 rAF 帧间隔 + 长任务 */
function startProbe() {
const gaps = [];
const longtasks = [];
let last = performance.now();
let running = true;
const onFrame = () => {
if (!running) return;
const now = performance.now();
gaps.push(now - last);
last = now;
requestAnimationFrame(onFrame);
};
requestAnimationFrame(onFrame);
const po = new PerformanceObserver((list) => {
for (const e of list.getEntries()) longtasks.push(e.duration);
});
try { po.observe({ type: 'longtask', buffered: false }); } catch {}
return {
stop() {
running = false;
try { po.disconnect(); } catch {}
const sorted = [...gaps].sort((a, b) => a - b);
const pick = (p) => sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p))] || 0;
const dropped = gaps.filter((g) => g > 32).length;
// 错过帧数:每个 >16.67ms 的间隔折算成少画了几帧
const missed = gaps.reduce((acc, g) => acc + Math.max(0, Math.round(g / 16.667) - 1), 0);
return {
frames: gaps.length,
median_gap_ms: +pick(0.5).toFixed(2),
p95_gap_ms: +pick(0.95).toFixed(2),
max_gap_ms: +gaps.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
dropped_over_32ms: dropped,
dropped_ratio: gaps.length ? +(dropped / gaps.length * 100).toFixed(1) : 0,
missed_frames: missed,
longtasks: longtasks.length,
longest_task_ms: +longtasks.reduce((a, b) => Math.max(a, b), 0).toFixed(2),
// TBT 口径:长任务超过 50ms 的部分累加
tbt_ms: +longtasks.reduce((s, d) => s + Math.max(0, d - 50), 0).toFixed(1),
};
},
};
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const yieldToHost = () =>
typeof scheduler !== 'undefined' && scheduler.yield ? scheduler.yield() : sleep(0);
/** 主线程三种执行姿势 */
async function runMain(iters, mode) {
const t0 = performance.now();
if (mode === 'main_block') {
// 一口气跑完:一个巨大的同步任务
for (let b = 0; b < BATCHES; b++) kernel(iters, b + 1);
} else if (mode === 'main_batch_yield') {
// 每批之间让出一次(最常见的「异步化」写法)
for (let b = 0; b < BATCHES; b++) {
kernel(iters, b + 1);
await sleep(0);
}
} else if (mode === 'main_chunked_yield') {
// 切片 + scheduler.yield(优先让给渲染与输入)
const parts = Math.max(1, Math.ceil(TARGET_MS / CHUNK_MS));
const per = Math.max(1, Math.round(iters / parts));
for (let b = 0; b < BATCHES; b++) {
for (let p = 0; p < parts; p++) {
kernel(per, b + 1);
await yieldToHost();
}
}
} else {
const parts = Math.max(1, Math.ceil(TARGET_MS / CHUNK_MS));
const per = Math.max(1, Math.round(iters / parts));
for (let b = 0; b < BATCHES; b++) {
for (let p = 0; p < parts; p++) {
kernel(per, b + 1);
await sleep(0); // 让出主线程,交给渲染与输入
}
}
}
return performance.now() - t0;
}
/** C: 同样的工作量,全部丢给 worker */
async function runWorker(iters) {
const w = new Worker(new URL('./workers/echo.mjs', import.meta.url), { type: 'module' });
await new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'boot') { w.removeEventListener('message', h); r(); } }));
const t0 = performance.now();
await new Promise((res) => {
let done = 0;
const h = (e) => {
if (e.data.cmd !== 'burned') return;
if (++done === BATCHES) { w.removeEventListener('message', h); res(); }
};
w.addEventListener('message', h);
for (let b = 0; b < BATCHES; b++) w.postMessage({ cmd: 'burn', seq: b, iter: iters, seed: b + 1 });
});
const ms = performance.now() - t0;
w.terminate();
return ms;
}
export async function run() {
const iters = calibrate(TARGET_MS);
const out = { iters, target_ms: TARGET_MS, batches: BATCHES, modes: {} };
// 预热一次,避免 JIT 首次编译混入
kernel(iters, 1);
for (const mode of ['main_block', 'main_batch_yield', 'main_chunked', 'main_chunked_yield', 'worker']) {
await sleep(300); // 让上一轮的帧探针收拾干净
const probe = startProbe();
let wall;
if (mode === 'worker') wall = await runWorker(iters);
else wall = await runMain(iters, mode);
await sleep(200);
const frames = probe.stop();
out.modes[mode] = { wall_ms: +wall.toFixed(1), ...frames };
}
return out;
}
bench-clone.mjs ------ 基准:structuredClone 迁移成本
javascript
// bench-clone.mjs --- postMessage 三条所有权通道 + worker 启动成本 + 语义矩阵
// clone = 两份数据 / transfer = 移动所有权 / SAB = 同一块内存
// 计的是「主线程 postMessage → worker 收发 → 回到主线程」的往返墙钟时间
const SIZES = [1024, 16 * 1024, 256 * 1024, 1024 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024, 64 * 1024 * 1024];
const BUDGET = 128 * 1024 * 1024; // 每档至少搬 128MB,保证信噪比
function newWorker() {
return new Worker(new URL('./workers/echo.mjs', import.meta.url), { type: 'module' });
}
function waitBoot(w) {
return new Promise((res) => {
const t0 = performance.now();
w.addEventListener('message', function h(e) {
if (e.data.cmd === 'boot') {
w.removeEventListener('message', h);
res(performance.now() - t0);
}
});
});
}
/** 往返一次:clone / transfer 通道(等消息回显),返回耗时与回包 */
function rtt(w, msg, transfer) {
return new Promise((res, rej) => {
const t0 = performance.now();
const h = (e) => {
if (e.data.seq !== msg.seq) return;
w.removeEventListener('message', h);
res({ ms: performance.now() - t0, data: e.data });
};
w.addEventListener('message', h);
setTimeout(() => rej(new Error('rtt timeout')), 30000);
w.postMessage(msg, transfer || []);
});
}
/** SAB 通道:主线程 Atomics.waitAsync 等旗标,worker 只做 notify(零拷贝) */
function sabRtt(w, msg, flag, timeout = 30000) {
return new Promise((res, rej) => {
Atomics.store(flag, 0, 0);
const t0 = performance.now();
const w0 = Atomics.waitAsync(flag, 0, 0, timeout);
w.postMessage(msg, []);
if (w0.async) {
w0.value.then((how) => (how === 'timed-out' ? rej(new Error('sab timeout')) : res(performance.now() - t0)));
} else res(performance.now() - t0);
});
}
function pct(arr, p) {
const s = [...arr].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))];
}
const med = (a) => +pct(a, 0.5).toFixed(4);
export async function run() {
const out = { sizes: [], startup: {}, semantics: {}, graph: {} };
// ---- 启动成本:module worker vs classic worker,各 15 次取中位数 ----
for (const kind of ['module', 'classic']) {
const times = [];
for (let i = 0; i < 15; i++) {
const w = kind === 'module'
? new Worker(new URL('./workers/echo.mjs', import.meta.url), { type: 'module' })
: new Worker(new URL('./workers/echo-classic.js', import.meta.url));
times.push(await waitBoot(w));
w.terminate();
}
out.startup[kind + '_median_ms'] = +pct(times, 0.5).toFixed(3);
out.startup[kind + '_min_ms'] = +Math.min(...times).toFixed(3);
out.startup[kind + '_p90_ms'] = +pct(times, 0.9).toFixed(3);
}
// ---- 三条通道 ----
const w = newWorker();
await waitBoot(w);
let seq = 0;
for (const size of SIZES) {
const iters = Math.max(8, Math.min(400, Math.floor(BUDGET / size)));
// A. 结构化克隆:每次都是两份数据
const bufA = new Uint8Array(size).fill(1);
const cloneTimes = [];
for (let i = 0; i < iters; i++) {
cloneTimes.push((await rtt(w, { cmd: 'ping', seq: ++seq, payload: bufA, transferBack: false })).ms);
}
// B. 所有权转移:ping-pong。发出去的视图会永久分离,回来的是一批新对象
let cur = new Uint8Array(size).fill(2);
const transferTimes = [];
let detachedCount = 0, newObjCount = 0;
for (let i = 0; i < iters; i++) {
const sent = cur;
const r = await rtt(w, { cmd: 'ping', seq: ++seq, payload: sent, transferBack: true }, [sent.buffer]);
transferTimes.push(r.ms);
if (sent.byteLength === 0) detachedCount++; // 发出去的视图: 分离
if (r.data.payload !== sent) newObjCount++; // 回来的: 新对象(不是同一个)
cur = r.data.payload; // 复用它做下一轮,稳态零分配
}
out[`transfer_detached_${size}`] = detachedCount === iters;
out[`transfer_newobj_${size}`] = newObjCount === iters;
// C. SharedArrayBuffer:零拷贝,只发信号
const sab = new SharedArrayBuffer(size);
new Uint8Array(sab).fill(3);
const flag = new Int32Array(new SharedArrayBuffer(8));
const sabTimes = [];
for (let i = 0; i < iters; i++) sabTimes.push(await sabRtt(w, { cmd: 'sab', seq: ++seq, buf: sab, flag }, flag));
out.sizes.push({
bytes: size,
iters,
clone_ms: med(cloneTimes),
transfer_ms: med(transferTimes),
sab_ms: med(sabTimes),
clone_mbps: +((size / 1e6) / (pct(cloneTimes, 0.5) / 1000)).toFixed(1),
clone_over_transfer: +(pct(cloneTimes, 0.5) / pct(transferTimes, 0.5)).toFixed(1),
sab_ratio: +(pct(cloneTimes, 0.5) / pct(sabTimes, 0.5)).toFixed(1),
});
}
w.terminate();
// ---- 对象图:传「对象数组」还是传「SoA 的 buffer」 ----
const N = 50000;
const recs = Array.from({ length: N }, (_, i) => ({ id: i, x: Math.random() * 100, y: Math.random() * 100, name: 'n' + (i % 97) }));
const w2 = newWorker();
await waitBoot(w2);
const g = { records: N };
let t0 = performance.now();
await rtt(w2, { cmd: 'ping', seq: ++seq, payload: recs, transferBack: false });
g.clone_records_ms = +(performance.now() - t0).toFixed(3);
t0 = performance.now();
JSON.parse(JSON.stringify(recs));
g.json_roundtrip_ms = +(performance.now() - t0).toFixed(3);
// SoA:同样的数据换成 2 个 Float32Array + 1 个 Int32Array + 1 个 name 索引,直接 transfer
const soa = {
id: new Int32Array(N),
x: new Float32Array(N),
y: new Float32Array(N),
nameIdx: new Uint8Array(N),
};
for (let i = 0; i < N; i++) {
soa.id[i] = recs[i].id; soa.x[i] = recs[i].x; soa.y[i] = recs[i].y; soa.nameIdx[i] = i % 97;
}
const soaBytes = soa.id.byteLength + soa.x.byteLength + soa.y.byteLength + soa.nameIdx.byteLength;
const t1 = performance.now();
await rtt(w2, { cmd: 'ping', seq: ++seq, payload: soa, transferBack: true },
[soa.id.buffer, soa.x.buffer, soa.y.buffer, soa.nameIdx.buffer]);
g.soa_transfer_ms = +(performance.now() - t1).toFixed(3);
g.soa_bytes = soaBytes;
g.aos_clone_bytes_est = JSON.stringify(recs).length;
w2.terminate();
out.graph = g;
// ---- 语义矩阵 ----
const sem = {};
const probe = (name, fn) => {
try {
const r = fn();
sem[name] = r === undefined ? 'ok' : r;
} catch (e) {
sem[name] = 'THROW ' + String(e.name || e.message).slice(0, 24);
}
};
probe('clone_function', () => { structuredClone({ f: () => 1 }); });
probe('clone_dom_node', () => { structuredClone({ n: document.body }); });
probe('clone_class_proto_lost', () => {
class P { constructor() { this.a = 1; } m() { return 2; } }
const c = structuredClone(new P());
return (c instanceof P) + '/' + (typeof c.m);
});
probe('clone_map_set_date_regex', () => {
const c = structuredClone({ m: new Map([[1, { x: 1 }]]), s: new Set([2]), d: new Date(5), re: /a/g });
return c.m.get(1).x + '/' + c.s.has(2) + '/' + (c.d instanceof Date) + '/' + (c.re instanceof RegExp);
});
probe('clone_error_type_kept', () => {
const c = structuredClone(new TypeError('boom'));
return (c instanceof Error) + '/' + c.name + '/' + c.message;
});
probe('clone_symbol_key_dropped', () => {
const o = { [Symbol('k')]: 1, keep: 2 };
return JSON.stringify(Object.keys(structuredClone(o)));
});
probe('clone_sab_same_memory', () => {
const sab = new SharedArrayBuffer(8);
const c = structuredClone(sab);
new Uint8Array(c)[0] = 42;
return new Uint8Array(sab)[0];
});
probe('clone_cycle_kept', () => {
const o = { a: 1 }; o.self = o;
const c = structuredClone(o);
return c.self === c; // 环必须在副本内部闭合,而不是指回原对象
});
probe('transfer_detaches_source', () => {
const b = new ArrayBuffer(1024);
new MessageChannel().port1.postMessage(b, [b]);
return b.byteLength;
});
probe('transfer_twice_throws', () => {
const b = new ArrayBuffer(1024);
const ch = new MessageChannel();
ch.port1.postMessage(b, [b]);
ch.port2.postMessage(b, [b]);
});
probe('transfer_sab_throws', () => {
const sab = new SharedArrayBuffer(8);
new MessageChannel().port1.postMessage(sab, [sab]);
});
probe('transfer_raw_view_in_list', () => {
const u8 = new Uint8Array(1024);
new MessageChannel().port1.postMessage(u8, [u8]);
return 'accepted/' + u8.buffer.byteLength;
});
probe('transfer_object_with_views_ok', () => {
const soa = { x: new Float32Array(8) };
new MessageChannel().port1.postMessage(soa, [soa.x.buffer]);
return 'soa_byteLength=' + soa.x.byteLength;
});
probe('clone_typed_view_geometry_kept', () => {
const u8 = new Uint8Array(16);
const f32 = new Float32Array(u8.buffer, 4, 2);
const c = structuredClone({ f32, u8 });
return c.f32.constructor.name + '/' + c.f32.byteOffset + '/' + c.f32.length + '/' + c.f32.buffer.byteLength;
});
out.semantics = sem;
return out;
}
test-article-code.mjs ------ 文章代码自检(断言文中所有结论)
javascript
// test-article-code.mjs --- 验证「文章里贴的代码」真能跑
// 覆盖: rpc.mjs(文章版)、workers/rpc-worker.mjs(文章版)、Pool 类(文章版)
// 用法: node test-article-code.mjs (失败 exit 1)
import { launch, newPage } from './cdp.mjs';
import { serve } from './server.mjs';
const fails = [];
const ok = (cond, msg) => { if (!cond) fails.push(msg); console.log((cond ? ' ok ' : ' FAIL ') + msg); };
const iso = await serve('.', 0);
const noIso = await serve('.', 0, { isolate: false });
const browser = await launch({ port: 9420 });
// ---------- A. RPC 层 ----------
{
const p = await newPage(browser.port);
p.on('Runtime.exceptionThrown', (e) => console.log('[page-err]', (e.exceptionDetails?.exception?.description || '').slice(0, 200)));
await p.goto(iso.origin + '/rpc.html');
const r = await p.eval('window.__test()');
console.log('\nRPC(文章版 rpc.mjs + rpc-worker.mjs)');
ok(r.add === 5, '普通调用 add(2,3) === 5');
ok(r.scale === '10,20,30,40', '返回值自动转移(scale)');
ok(r.src_detached === true, '参数(Float32Array)调用后已分离');
ok(r.boom === 'RangeError/算不了', '错误 name + message 保真');
ok(r.timeout === 'TimeoutError', '超时 reject TimeoutError');
ok(r.abort.startsWith('AbortError') && r.abort.endsWith('progress=true'), '协作式取消 + 取消前有 progress');
ok(r.pending_after_2000 === 0, '2000 次并发后 pending 清空(无泄漏)');
p.close();
}
// ---------- B. Pool 类 ----------
{
const p = await newPage(browser.port);
p.on('Runtime.exceptionThrown', (e) => console.log('[page-err]', (e.exceptionDetails?.exception?.description || '').slice(0, 200)));
await p.goto(iso.origin + '/pool-test.html');
const r = await p.eval('window.__test()');
console.log('\nPool(文章版类 + count-worker)');
ok(r.jobs_done === 20, '20 个任务全部完成');
ok(r.seqs_ok === true, '回包序号无错配');
ok(r.distinct_workers <= 4, `worker 实例数不超过池上限(实测 ${r.distinct_workers})`);
ok(String(r.crash).startsWith('rejected:worker crashed'), 'worker 崩溃时 job 被 reject');
ok(r.recovered === true, '崩溃后池自动补人并继续服务');
p.close();
}
console.log(`\n失败 ${fails.length} 条`);
browser.kill(); iso.server.close(); noIso.server.close();
process.exit(fails.length ? 1 : 0);
pool.html ------ 演示页:Worker 池
html
<!doctype html>
<meta charset="utf-8">
<title>pool bench</title>
<script type="module">
import { run } from './bench-pool.mjs';
window.__run = run;
</script>
pool-test.html ------ 测试页:Worker 池并发行为
html
<!doctype html>
<meta charset="utf-8">
<title>pool test</title>
<script type="module">
import { Pool } from './pool-lib.mjs';
window.__test = async () => {
const out = {};
const pool = new Pool(4, './workers/count-worker.mjs', { warm: 4 });
// 等预热完成(本类在构造时就 spawn,boot 是异步回来的)
await new Promise((r) => setTimeout(r, 300));
const t0 = performance.now();
const jobs = await Promise.all(Array.from({ length: 20 }, (_, i) => pool.run({ cmd: 'task', seq: i })));
out.jobs_done = jobs.length;
out.wall_ms = +(performance.now() - t0).toFixed(1);
out.distinct_workers = new Set(jobs.map((j) => j.id)).size; // 应 ≤ 4
out.seqs_ok = jobs.map((j) => j.seq).sort((a, b) => a - b).join(',') === Array.from({length:20},(_,i)=>i).join(',');
// 崩溃路径:worker 抛未捕获错误 → onerror → job 必须被 reject,且池要补人
const crashed = await pool.run({ cmd: 'boom' }).then(() => 'resolved', (e) => 'rejected:' + e.message.slice(0, 20));
out.crash = crashed;
await new Promise((r) => setTimeout(r, 300));
// 崩溃后池必须还能干活
const after = await pool.run({ cmd: 'task', seq: 999 });
out.recovered = after.seq === 999;
pool.destroy();
return out;
};
</script>
rpc.html ------ 演示页:RPC 调用
html
<!doctype html>
<meta charset="utf-8">
<title>rpc test</title>
<script type="module">
import { createRpc } from './rpc.mjs';
window.__test = async () => {
const results = {};
const w = new Worker(new URL('./workers/rpc-worker.mjs', import.meta.url), { type: 'module' });
await new Promise((r) => w.addEventListener('message', function h(e) { if (e.data.cmd === 'boot') { w.removeEventListener('message', h); r(); } }));
const rpc = createRpc(w, { timeout: 5000 });
results.add = await rpc.call('add', 2, 3);
const src = new Float32Array([1, 2, 3, 4]);
const scaled = await rpc.call('scale', src, 10);
results.scale = Array.from(scaled).join(',');
results.src_detached = src.byteLength === 0; // 参数被转移走了
results.reply_byteLength = scaled.byteLength;
try { await rpc.call('boom'); } catch (e) { results.boom = e.name + '/' + e.message; }
// 超时
const fast = createRpc(w, { timeout: 20 });
try { await fast.call('longTask', 200); } catch (e) { results.timeout = e.name; }
fast.terminate();
// 取消
const ac = new AbortController();
setTimeout(() => ac.abort(), 30);
let progressSeen = 0;
try {
await rpc.call('longTask', 400, { onProgress: () => progressSeen++, signal: ac.signal });
results.abort = 'no-throw';
} catch (e) { results.abort = e.name + '|progress=' + (progressSeen > 0); }
// 并发压力:2000 次调用后 pending 必须清空
await Promise.all(Array.from({ length: 2000 }, (_, i) => rpc.call('add', i, 1)));
results.pending_after_2000 = rpc.pendingCount();
rpc.terminate();
w.terminate();
return results;
};
</script>
offscreen.html ------ 演示页:OffscreenCanvas
html
<!doctype html>
<meta charset="utf-8">
<title>offscreen bench</title>
<style>body{margin:0;background:#111}</style>
<script type="module">
import { run } from './bench-offscreen.mjs';
window.__run = run;
</script>
sab.html ------ 演示页:SharedArrayBuffer
html
<!doctype html>
<meta charset="utf-8">
<title>sab bench</title>
<script type="module">
import { run } from './bench-sab.mjs';
window.__run = run;
</script>
jank.html ------ 演示页:主线程卡顿
html
<!doctype html>
<meta charset="utf-8">
<title>jank bench</title>
<script type="module">
import { run } from './bench-jank.mjs';
window.__run = run;
</script>
clone.html ------ 演示页:structuredClone
html
<!doctype html>
<meta charset="utf-8">
<title>clone bench</title>
<script type="module">
import { run } from './bench-clone.mjs';
window.__run = run;
window.__ready = true;
</script>
isolation.html ------ 演示页:Worker 隔离
html
<!doctype html>
<meta charset="utf-8">
<title>isolation probe</title>
<script>
window.__probe = () => ({
crossOriginIsolated: crossOriginIsolated,
hasSharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
canConstructSAB: (() => { try { new SharedArrayBuffer(8); return true; } catch (e) { return 'THROW ' + e.name; } })(),
});
</script>