工作线程并发调度
# 09.Worker 并发与调度时钟
📍 上接第 08 篇《事件循环 Promise 一体论》。主线程的异步调度已了然。本文回答:当 CPU 密集任务让主线程卡死时,Worker 怎么救场?SharedArrayBuffer 怎么让多线程安全共享内存?setTimeout 为什么最少 4ms?rAF 和 Vsync 之间隔了什么?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. 专用Worker
- 4. 共享内存详解
- 5. ServiceW
- 6. 并发模式演进
- 7. RxJS冷热
- 8. 定时器剖析详解
- 9. rAF与rIC
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 一个实时图像滤镜
先看一段在生产环境真实跑过的代码——一个在线图像滤镜工具,允许用户同时上传多张图片,在浏览器端逐张应用高斯模糊滤镜:
// filter_engine.js —— 浏览器端图像处理引擎(故障版本)
async function applyFilterToImages(files) {
const canvases = [];
for (const file of files) {
// 1. 解码图片(IO 密集——但浏览器在 worker 中异步做,这步不卡)
const img = await createImageBitmap(file); // ~50ms
// 2. 画到 Canvas 上
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 3. 拿到像素数据(Uint8ClampedArray,这个操作在原地取像素——不拷贝)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 4. 逐像素应用高斯模糊(CPU 密集——4000×3000 = 1200 万像素)
applyGaussianBlur(imageData.data, canvas.width, canvas.height); // ← ~600ms!
// 5. 写回 Canvas
ctx.putImageData(imageData, 0, 0);
canvases.push(canvas);
}
return canvases;
}
// 用户上传 5 张 4000×3000 的照片
// → 5 次 createImageBitmap → 5 × 50ms = 250ms(异步,不卡主线程)
// 但 5 次 applyGaussianBlur → 5 × 600ms = 3000ms(同步,全部卡在主线程!)
// → 页面冻结 3 秒:鼠标拖不动、按钮点不了、CSS 动画停在半空
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
现象:
- 单张图片处理:600ms(明显卡顿,但能接受——用户看到 loading spinner 在转)
- 5 张同时处理:3000ms——浏览器弹出"页面无响应"对话框
- Performance 面板火焰图:一条 3 秒的黄色长条(JS 执行),其中
applyGaussianBlur的for循环占满整条
直觉怀疑:是不是 getImageData 太慢了?单独测试——4000×3000 的 getImageData 只需要 ~2ms。真正的瓶颈在 applyGaussianBlur——它对 1200 万个像素做 5×5 的卷积核运算(每个像素 25 次乘法 + 1 次除法),1200 万 × 26 ≈ 3.12 亿次浮点运算——纯 CPU 密集型,且全部在主线程上执行。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:能不能把
applyGaussianBlur改成异步的(requestAnimationFrame分帧执行)?——可以,每个像素处理约 0.00005ms,1200 万像素分到 300 帧 → 每帧 4 万像素 → 2ms。代价是:用户要等 300 帧(~5 秒)才能看到结果。这对"实时滤镜预览"来说太慢了。 - 假设 2:能不能用 Wasm 加速?——可以,SIMD 优化的 Wasm 高斯模糊比纯 JS 快 3~5 倍。但它仍然在主线程上跑——3 亿次运算变成 0.8 亿次运算,耗时从 600ms 降到 ~120ms——还是卡一帧预算(16.67ms)。
- 假设 3:那为什么不用 Web Worker?——Worker 在独立线程上跑,主线程完全不受影响。但
getImageData返回的ImageData挂在 Canvas 上(Canvas 属于主线程 DOM 树),不能直接传给 Worker——必须通过postMessage复制一份像素数组传过去。 - 假设 4:复制 1200 万像素(4 bytes/pixel = 48MB)的
postMessage需要多久?——结构化克隆拷贝 48MB 约 85ms——加上 Worker 中 600ms 的处理 + 把结果 48MB 传回来又是 85ms → 总耗时 770ms,比主线程直接跑的 600ms 还慢! - 假设 5:那 Transferable 呢?——Transferable 把
ImageData.data.buffer的所有权转移给 Worker,零拷贝。主线程在 Worker 处理期间失去对该 buffer 的访问权(这正是我们需要的——因为 Worker 会完全重写它)。传出去 <1ms,Worker 处理 600ms,传回来 <1ms → 总耗时 602ms,而且主线程在所有 600ms 内都空闲。
# 1.3 我们要回答什么
这段代码里至少藏着 7 个原理点:
① Worker 到底是"怎么借了一个线程"?Blink 的线程池是怎么管理的? → 第 3 章
② postMessage 的结构化克隆为什么要 85ms?Transferable 为什么 <1ms? → 第 3.2 节
③ SharedArrayBuffer 怎么做到"两个线程共享同一块内存"?不拷贝? → 第 4 章
④ Atomics.wait 为什么会阻塞线程?主线程为什么不能用? → 第 4.2 节
⑤ ServiceWorker 的生命周期 install→activate 之间的 waiting 状态从哪来? → 第 5 章
⑥ 从回调到 Observable,每一步都在解决上一步的什么问题? → 第 6 章
⑦ setTimeout 的 4ms 夹持为什么只在浏览器有?Node 为什么没有? → 第 8 章
2
3
4
5
6
7
本篇路线:
架构总图(第 2 章)
↓
DedicatedWorker(第 3 章)──→ 解开"Worker 怎么在独立线程上跑 JS"
↓
SAB + Atomics(第 4 章)──→ 解开"多线程怎么安全地共享同一块内存"
↓
ServiceWorker(第 5 章)──→ 解开"独立于页面的网络代理怎么工作"
↓
并发模式演进(第 6 章)──→ 解开"六种异步模式之间的因果链"
↓
RxJS 冷热(第 7 章)──→ 解开"Cold 和 Hot 的数据生产时机差异"
↓
setTimeout/rAF/rIC(第 8~9 章)──→ 解开"四种调度时钟的精确行为和优先级"
↓
综合案例(第 10 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:上接事件循环篇(第 08 篇)、下接渲染管线篇。本篇是 JS 并发的枢纽篇——你知道了主线程怎么调度异步任务(第 08 篇),现在学怎么把任务移出主线程(Worker)和怎么在帧内精确定时(rAF/rIC)。读完本篇后,你就能设计"Worker 处理数据 + 主线程只负责渲染"的经典性能架构。
# 2. 架构全景概览
# 2.1 Dedicate
┌──────────────────────────────────────────────────────────────────┐
│ 浏览器多线程 Worker 全景 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │DedicatedWorker│ │SharedWorker │ │ ServiceWorker │ │
│ │ (专有 Worker) │ │ (共享 Worker) │ │ (服务 Worker) │ │
│ ├─────────────┤ ├─────────────┤ ├─────────────────────┤ │
│ │ 线程:1对1 │ │ 线程:1对多 │ │ 线程:1对所有同源页面 │ │
│ │ 生命:随页面 │ │ 生命:最后引用 │ │ 生命:浏览器管理 │ │
│ │ DOM:❌ │ │ DOM:❌ │ │ DOM:❌ │ │
│ │ 网络:❌ │ │ 网络:❌ │ │ 网络:✅(fetch拦截) │ │
│ │ 存储:❌ │ │ 存储:❌ │ │ 存储:✅(Cache API) │ │
│ │ 通信:postMsg │ │ 通信:port │ │ 通信:postMessage │ │
│ │ 场景:CPU密集 │ │ 场景:跨Tab共享│ │ 场景:离线/推送/缓存 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ 主线程(DOM / UI) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 负责:渲染、事件处理、用户交互 │ │
│ │ 不负责:CPU 密集计算(交给 DedicatedWorker) │ │
│ │ 离线缓存和请求拦截(ServiceWorker 代理) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| 维度 | DedicatedWorker | SharedWorker | ServiceWorker |
|---|---|---|---|
| 线程关系 | 1:1(创建它的页面) | 1:N(多个同源页面共享) | 1:N(所有同源页面) |
| 生命周期 | 随页面创建/关闭 | 最后一个引用页关闭后自动销毁 | 独立于页面——浏览器管理注册/更新/销毁 |
| DOM 访问 | ❌ | ❌ | ❌ |
| 网络拦截 | ❌ | ❌ | ✅ fetch 事件 |
| 离线缓存 | ❌ | ❌ | ✅ Cache API |
| push 通知 | ❌ | ❌ | ✅ Push API |
| 后台同步 | ❌ | ❌ | ✅ Background Sync |
| 通信方式 | postMessage(结构化克隆/Transferable) | MessagePort | postMessage(给 clients) |
| 典型场景 | 图像处理/加密/大量计算 | 多个 Tab 共享 WebSocket 连接 | PWA 离线/网络缓存/推送通知 |
# 2.2 为什么浏览器要把
疑惑:直接给 JS 一个"多线程 API"(像 new Thread(fn))不行吗?为什么要搞三种不同的 Worker?
论证:
安全隔离的粒度不同——DedicatedWorker 只和创建它的页面通信。一个 iframe 里的恶意脚本不能用
postMessage向其他 Tab 的 DedicatedWorker 注入数据。SharedWorker 允许跨 Tab 共享,但这意味着你信任所有连接它的同源页面。ServiceWorker 更进一步——它能拦截所有同源页面的网络请求,这个权限必须由浏览器框架严格管理(install/activate 的生命周期流程就是为了确保"只有用户确认了网站想装 SW,SW 才能装")。生命周期匹配不同的需要——DedicatedWorker 的"随页面消亡"是合理的默认值:我打开一个图像编辑页面→启动 Worker 做滤镜;我关闭这个页面→Worker 应该被回收(不需要再处理滤镜了)。但 ServiceWorker 需要独立于页面存活——推送通知到达时,页面可能根本没打开。把这两种生命周期混在一个 API 里会让开发者搞不清"Worker 什么时候会被杀掉"。
API 权限不同——如果把所有权限(DOM、网络拦截、缓存、推送)都放在一个 Worker 类型里,攻击面的组合爆炸会让安全审查变得不可能。每种 Worker 类型只有"完成它职责所需要的最小 API 集合"——这就是"最小权限原则(principle of least privilege)"在浏览器架构中的体现。
反向验证:Node.js 的
worker_threads模块没有分类型——它统一提供new Worker()给开发者。结果呢?开发者在 Worker 里做 CPU 计算、做 I/O 轮询、做 HTTP 代理——全部混在同一个 API 里。Web 平台的"三种 Worker 类型"恰恰是吸取了这教训:API 的设计应该让"做正确的事"容易、"做危险的事"需要明确表态。
结论:三种 Worker 不是"标准委员会各派妥协的产物"——它们分别对应浏览器架构中三种不同级别的事件处理需求。DedicatedWorker = 把 CPU 密集型任务移出主线程。SharedWorker = 把需要跨 Tab 共享的有状态逻辑移到独立线程。ServiceWorker = 把"网络层的逻辑"从主线程中完全剥离出来。
# 3. 专用Worker
# 3.1 Worker
疑惑:new Worker('worker.js') 是怎么在操作系统层面创建一个新线程的?它和主线程有什么关系?
论证——这不是简单的 pthread_create:
┌─────────────────────────────────────────────────────────────────┐
│ new Worker('worker.js') 的底层生命周期(Blink + V8) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ① 主线程 JS 调用 new Worker('worker.js') │
│ ↓ │
│ ② Blink 的 WorkerMessagingProxy 被创建 │
│ → 这是"主线程侧"的代理对象——所有主线程→Worker 的消息都通过它 │
│ ↓ │
│ ③ WorkerThread(继承自 base::SimpleThread)被创建 │
│ → 浏览器进程的线程池中分配一个 OS 线程(Linux: pthread_create) │
│ → 这个线程有自己的**独立任务队列**(不是 libuv 的!) │
│ ↓ │
│ ④ 在新线程中初始化一个**全新的 V8 Isolate** │
│ → 每个 V8 Isolate 有自己的堆(Heap)、自己的 GC 线程 │
│ → 主线程的 V8 Isolate 和 Worker 的 V8 Isolate 是完全隔离的 │
│ → 它们共享的唯一东西是:底层的 C++ 运行时代码(Blink 共享库) │
│ ↓ │
│ ⑤ 在新 Isolate 中执行 worker.js │
│ → Ignition 解释器开始执行 → TurboFan 编译热点 │
│ → Worker 全局作用域是 WorkerGlobalScope(不是 Window!) │
│ ↓ │
│ ⑥ Worker 自己的事件循环开始运转 │
│ → 它不是主线程的那个事件循环! │
│ → 它也轮询消息队列,但**仅包含** postMessage 消息和 setTimeout │
│ → 没有 DOM 事件、没有 requestAnimationFrame、没有 UI 渲染 │
│ │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
两个独立的事件循环:
主线程事件循环(第 08 篇的 six-stage 模型) Worker 线程事件循环
┌──────────────────────────────┐ ┌──────────────────────┐
│ 1. task (macroTask) │ │ 1. task │
│ - script 执行 │ │ - onmessage 回调 │
│ - 用户交互事件 │ │ - setTimeout 回调│
│ - fetch 回调 │ │ 2. microTask │
│ 2. microTask │ │ - Promise.then │
│ - Promise.then │ │ 3. 检查是否需要退出 │
│ 3. render(如有需要) │ │ 4. 回到 1 │
│ 4. 回到 1 │ │ │
└──────────────────────────────┘ └──────────────────────┘
关键:两个循环是完全独立的!
主线程的一个 task 可以和 Worker 的一个 task 同时在跑(在不同的 CPU 核心上)。
2
3
4
5
6
7
8
9
10
11
12
13
14
结论:Worker 不是"一条轻量级的协程"——它是一套完整的、独立的 JS 运行时。它有自己独立的 V8 堆、自己的事件循环、自己独立的 OS 线程。开发者感受不到多线程的复杂度(不需要锁、不需要 sleep、不需要 volatile),是因为V8 Isolate 之间的隔离替你扛下了所有的线程安全问题。
# 3.2 postMess
疑惑:为什么同是传 80MB 数据,结构化克隆要 85ms,Transferable 只要 <1ms?
论证——两者的内存操作路径完全不同:
┌─────────────────────────────────────────────────────────────────┐
│ 结构化克隆(Structured Clone)——深拷贝 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 主线程的 V8 堆 → 序列化(遍历对象树,收集所有叶子) │
│ │ → IPC(进程间通信)发送序列化字节 │
│ │ → Worker 线程反序列化(重新构建对象树) │
│ │ → 写入 Worker 的 V8 堆 │
│ │
│ 对 80MB ArrayBuffer: │
│ memcpy 80MB(主线程侧串行拷贝) ≈ 60ms │
│ + IPC 发送 80MB 字节(通过共享内存或 socket)≈ 20ms │
│ + memcpy 80MB(Worker 侧串行拷贝) ≈ 60ms │
│ ——————————————————————————————————— │
│ 总计 ≈ 140ms(但这个拷贝是异步的,不阻塞发送方?不—— │
│ 结构化克隆的序列化阶段在发送方线程上同步执行!) │
│ 实际阻塞时间 ≈ 60ms(序列化阶段) │
│ │
├─────────────────────────────────────────────────────────────────┤
│ Transferable(所有权转移)——零拷贝 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 主线程:ArrayBuffer → 标记为"已转让" │
│ │ → 在主线程的 V8 堆中,这个 ArrayBuffer 的 backing store │
│ │ 指针被置空(byteLength → 0,访问它会抛 TypeError) │
│ │ → 把 backing store 的"物理内存页"的所有权转给 IPC 通道 │
│ │ │
│ IPC 通道:不需要拷贝字节!只需要传递"内存页的页表项" │
│ │ → 把这块物理内存从主线程进程的页表中移除 │
│ │ → 映射到 Worker 线程进程的页表中(同一个物理页!) │
│ │ │
│ Worker:接收 backing store 指针 → 新的 ArrayBuffer 指向它 │
│ │ → Worker 可以正常读写 → 和主线程之前的 ArrayBuffer │
│ │ 指向的是**同一块物理内存** │
│ │
│ 全程耗时:修改两个页表项 + 一次系统调用 ≈ <1ms │
│ │
│ 类比:结构化克隆 = 把一整本书逐字手抄一份 │
│ Transferable = 把书的"产权证"从你名下转到我名下 │
│ 书还是那一本(同一个物理页),只是所有者变了 │
│ │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Transferable 支持的完整类型清单:
| 类型 | 说明 | 转让后的主线程行为 |
|---|---|---|
ArrayBuffer | 定长二进制缓冲区 | buf.byteLength → 0(typeError 不可用) |
MessagePort | 双向通信通道 | 通道所有权转移——旧的 port 不可用 |
ReadableStream | 可读流 | 流的所有权转移 |
WritableStream | 可写流 | 同上 |
TransformStream | 转换流 | 同上 |
ImageBitmap | 解码后的位图 | ImageBitmap.close() 后不可用 |
OffscreenCanvas | 离屏 Canvas | Canvas 所有权转移——主线程的 canvas 失去关联 |
// 验证:Transferable 后主线程失去访问权
const buffer = new ArrayBuffer(1024);
const view = new Uint8Array(buffer);
view[0] = 42;
console.log(view[0]); // → 42
worker.postMessage(buffer, [buffer]);
console.log(buffer.byteLength); // → 0 ← 所有权已转移!
console.log(view[0]); // → TypeError ← 不能访问已转让的 buffer
2
3
4
5
6
7
8
9
10
结论:Transferable 的"零拷贝"不是"魔法优化"——它借用 OS 的虚拟内存页表机制,把一块物理内存从进程 A 的页表转移到进程 B 的页表。这块物理内存在转移过程中从未被拷贝过——只是指针的所有权发生了变化。
# 3.3 Worker
| 能力 | 主线程 | Worker | 原因 |
|---|---|---|---|
DOM / window / document | ✅ | ❌ | 渲染线程独占——DOM 不是线程安全的,加锁会让 60fps 渲染不可能 |
fetch / XMLHttpRequest | ✅ | ✅ | 网络请求不涉及渲染——可以在任意线程发起 |
setTimeout / setInterval | ✅ | ✅ | 定时器是 V8 的能力——每个 Isolate 有自己的定时器堆 |
WebSocket | ✅ | ✅ | 同上——独立的网络连接 |
IndexedDB | ✅ | ✅ | 数据库访问线程安全(底层是 SQLite 的 WAL 模式 + 浏览器的 IPC 代理) |
navigator / location(只读) | ✅ | ✅ | 只读访问——不修改状态 |
localStorage | ✅ | ❌ | 同步 API + 磁盘写入——在 Worker 线程中调用会阻塞 Worker 的事件循环 |
| Canvas API | ✅ | ✅ (OffscreenCanvas) | 主线程:绑定到 DOM 树上的 <canvas>;Worker:独立的离屏 Canvas |
importScripts() | ❌ | ✅ | 同步加载脚本——只能用在 Worker 中(主线程不该同步加载) |
为什么 localStorage 在 Worker 中不可用——深入分析:
localStorage 的 setItem 是同步的——它把数据写入浏览器底层的一个 SQLite 数据库文件中。这个写入操作需要约 1-5ms(取决于磁盘繁忙度)。在主线程上,这 1-5ms 已经够坏了——它偷走渲染帧的预算。如果在 Worker 线程中被调用,Worker 的事件循环也会被 1-5ms 的磁盘写入阻塞——对于需要低延迟的 Worker(如音视频处理的 AudioWorklet),这是不可接受的。
浏览器设计者选择"完全禁止 Worker 中的 localStorage",而不是"允许但慢"——因为"慢"意味着开发者在 Worker 热路径中调了 localStorage.setItem → Worker 响应延迟 → 投诉"Worker 性能不行"→ 但其实不是 Worker 的问题,是 localStorage 的问题。"禁止"比"允许但慢"更诚实——它把性能坑在 API 层面堵死了。
# 4. 共享内存详解
# 4.1 SAB 的内存模型
疑惑:postMessage(sab) 传 SharedArrayBuffer——不拷贝?浏览器怎么做到"两个 V8 Isolate 指向同一块物理内存"?
论证——SAB 绕过了 V8 Isolate 之间的隔离边界:
┌────────────────────────────────────────────────────────────────┐
│ SharedArrayBuffer 的内存共享机制 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 主线程 V8 堆 Worker 线程 V8 堆 │
│ ┌───────────┐ ┌───────────┐ │
│ │ 普通对象 │ │ 普通对象 │ │
│ │ JSObject │ │ JSObject │ │
│ ├───────────┤ ├───────────┤ │
│ │ 普通 │ │ 普通 │ │
│ │ArrayBuffer│ │ArrayBuffer│ │
│ │ (隔离—— │ │ (隔离—— │ │
│ │ 各管各的) │ │ 各管各的) │ │
│ ├───────────┤ ├───────────┤ │
│ │ │ │ │ │
│ │ Shared │ ╔══════════╗ │ Shared │ │
│ │ Array │ ║ 同一块物理 ║ │ Array │ │
│ │ Buffer │──║ 内存页 ║───│ Buffer │ │
│ │ (SAB) │ ║ ║ │ (SAB) │ │
│ │ │ ╚══════════╝ │ │ │
│ └───────────┘ └───────────┘ │
│ │
│ SAB 的 backing store 不归任何一个 V8 Isolate "独占" │
│ → V8 用"外部内存"机制管理它——GC 不会回收 SAB 的物理内存 │
│ → 两个 Isolate 的 PageTable 都映射到同一块物理页 │
│ → 任何一个 Isolate 中的写入 → 另一个 Isolate 立即可见 │
│ │
│ 对比普通 ArrayBuffer(隔离——每个 Isolate 有自己的副本): │
│ ┌───────────┐ ┌───────────┐ │
│ │ 主线程 AB │──拷贝──│ Worker AB │ ← 两块独立物理内存 │
│ │ 物理页 A │ │ 物理页 B │ │
│ └───────────┘ └───────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 验证:SAB 的共享性——写入立即可见
const sab = new SharedArrayBuffer(4);
const view1 = new Int32Array(sab);
const worker = new Worker('sab_worker.js');
worker.postMessage(sab); // ← 不拷贝!传的是对同一块 SAB 的"引用"
view1[0] = 0;
// worker.js 内部:
// self.onmessage = ({ data }) => {
// const view2 = new Int32Array(data);
// console.log(view2[0]); // → 0 ← 立即可见主线程写入的值
// view2[0] = 42; // ← 写入
// };
setTimeout(() => {
console.log(view1[0]); // → 42 ← 主线程立即可见 Worker 的写入!
}, 200);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 4.2 Atomics
疑惑:Atomics.wait(i32, idx, expected) 会让当前线程"睡着"——浏览器是怎么做到的?JS 不是单线程、无阻塞的吗?
论证——Atomics.wait 不是 JS 层的"忙等循环",而是调用了操作系统的同步原语:
┌───────────────────────────────────────────────────────────┐
│ Atomics.wait/notify 的底层实现链路 │
├───────────────────────────────────────────────────────────┤
│ │
│ Atomics.wait(i32, 0, 0) │
│ │ │
│ ▼ 检查 i32[0] === 0 ? │
│ │ YES → 没有变化 → 当前线程进入"阻塞等待" │
│ └──→ V8 调用 futex(2) / WaitOnAddress(Windows) │
│ futex(FUTEX_WAIT, &i32[0], 0, nullptr) │
│ → 内核把当前线程放到 SAB 地址的等待队列上 │
│ → 当前线程被挂起(操作系统调度器把它移出运行队列) │
│ → 它不消耗任何 CPU 时间——完全"睡着了" │
│ │
│ 另一个线程调用:Atomics.notify(i32, 0, 1) │
│ │ │
│ ▼ V8 调用 futex(FUTEX_WAKE, &i32[0], 1) │
│ → 内核把等待队列中最多 1 个线程唤醒 │
│ → 被唤醒的线程重新获得 i32[0] 的值 → 发现已经变了 → 返回 │
│ │
│ 如果 i32[0] 在被 wait 之前就已经变了(!== expected): │
│ → Atomics.wait 立即返回 "not-equal"——不阻塞 │
│ │
└───────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
为什么主线程不能用 Atomics.wait:
// ❌ 主线程——绝不能用 Atomics.wait!
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
view[0] = 0;
// ↓ 主线程执行这一行 → 浏览器检测到"主线程试图阻塞" →
// 直接抛 TypeError: "Atomics.wait cannot be called in this context"
Atomics.wait(view, 0, 0); // → TypeError!
// 原因:如果主线程被阻塞 → 渲染停止 → 用户交互冻结 → 浏览器"死了"
// 浏览器设计者明确:主线程上不能有阻塞操作——即使你想要也不行
2
3
4
5
6
7
8
9
10
Atomics 完整操作表:
| 操作 | 作用 | 阻塞线程? | 可用线程 |
|---|---|---|---|
Atomics.add(arr, idx, val) | arr[idx] += val(原子) | ❌ | 主线程 + Worker |
Atomics.sub(arr, idx, val) | arr[idx] -= val(原子) | ❌ | 同上 |
Atomics.and/or/xor | 位运算(原子) | ❌ | 同上 |
Atomics.compareExchange(arr, idx, old, new) | CAS(原子比较并交换) | ❌ | 同上 |
Atomics.store(arr, idx, val) | arr[idx] = val(原子写) | ❌ | 同上 |
Atomics.load(arr, idx) | 原子读 | ❌ | 同上 |
Atomics.wait | 阻塞等待值变化 | ✅ | 仅 Worker |
Atomics.notify | 唤醒等待线程 | ❌ | 主线程 + Worker |
生产者-消费者模式:
// main.js —— 主线程(消费者)
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
const worker = new Worker('worker.js');
worker.postMessage(sab);
// 主线程不能 wait——改成"等 Worker 发 postMessage 通知"
worker.onmessage = () => {
console.log('Worker done, result:', view[0]);
};
// worker.js —— Worker(生产者)
self.onmessage = ({ data }) => {
const view = new Int32Array(data);
// 模拟 CPU 密集计算
let sum = 0;
for (let i = 0; i < 1e7; i++) sum += i;
view[0] = sum;
// 通知主线程——已完成
postMessage('done');
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 4.3 SAB 的安全前提
疑惑:为什么 SharedArrayBuffer 一度被所有浏览器全线禁用,后来又回来了?COOP + COEP 两个头怎么堵住漏洞?
论证——这是一个完整的安全攻防链:
┌───────────────────────────────────────────────────────────────┐
│ Spectre 攻击 + SAB 被禁用 + COOP+COEP 修复的完整故事 │
├───────────────────────────────────────────────────────────────┤
│ │
│ Spectre 漏洞(CVE-2017-5753, CVE-2017-5715)的核心原理: │
│ ① CPU 的"分支预测器"猜错了代码的分支方向 │
│ ② 在 CPU 发现猜错之前(几十个时钟周期), │
│ 被猜错的那条路径上的指令已经执行了——包括访存操作 │
│ ③ 这条"幽灵路径"虽然最终会被回滚,但它在 CPU cache 中留下了痕迹 │
│ ④ 攻击者通过精确测量"哪些地址的访问速度快"(cache 命中 vs miss), │
│ 可以推断出幽灵路径读取了哪些内存——从而"侧信道"读取任意地址的数据 │
│ │
│ SAB 是怎么被 Spectre 利用的? │
│ ① 攻击网站上加载一个恶意脚本 │
│ ② 恶意脚本创建一个 Worker,通过 SAB 与主线程共享一块内存 │
│ ③ Worker 中的代码构造一个精心设计的 Spectre gadget │
│ 利用高精度计时器(performance.now)→ 测量 cache 访问延迟 │
│ ④ Worker 中的 Spectre 攻击可以读取到**同进程**中其他 Tab 的内存 │
│ (因为 Chrome 当时是多 Tab 共享一个渲染进程的——"site isolation"之前)│
│ │
│ 浏览器应对:2018 年初 → 全线禁用 SAB(Firefox/Chrome/Edge/Safari) │
│ │
│ 修复方案(2020 年重新启用 SAB 的前置条件): │
│ │
│ ① Chrome 的 Site Isolation(2018 年开始逐步推广) │
│ 每个不同源的 Tab 运行在独立的渲染进程中 → 跨源数据根本不在同一进程 │
│ │
│ ② COOP(Cross-Origin-Opener-Policy): │
│ Cross-Origin-Opener-Policy: same-origin │
│ → 不同源的弹出窗口不能通过 window.opener 访问当前窗口 │
│ → 堵死了"从一个 Tab 跳转到另一个 Tab 的内存空间" │
│ │
│ ③ COEP(Cross-Origin-Embedder-Policy): │
│ Cross-Origin-Embedder-Policy: require-corp │
│ → 页面加载的所有跨域资源必须有明确的 CORS 许可 │
│ → 堵死了"通过跨域资源注入把恶意代码带进同一进程" │
│ │
│ COOP + COEP + Site Isolation = 三道防线 → Spectre 无法读取跨源内存 │
│ 2020年 SAB 被重新启用(需要网站正确设置这两个 HTTP 头) │
│ │
└───────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
当前浏览器 SAB 支持状态:
| 浏览器 | SAB 支持 | 前置条件 |
|---|---|---|
| Chrome 92+ | ✅ | COOP + COEP 头 |
| Firefox 79+ | ✅ | COOP + COEP 头 |
| Safari 15.2+ | ✅ | COOP + COEP 头(默认在某些条件下开启) |
| Node.js | ✅ | 无需头(没有浏览器的跨域上下文) |
结论:SAB 的禁用与回归是 Web 平台安全史上的一个标志性事件——它说明"硬件漏洞(Spectre)可以通过软件协议(COOP+COEP)来缓解"。两个 HTTP 头换回一个高性能的多线程共享内存 API——这个 tradeoff 是值得的。
# 5. ServiceW
# 5.1 生命周期:ins
疑惑:为什么 ServiceWorker 有 waiting 状态?不是应该"装完就激活"吗?
论证——waiting 是 ServiceWorker 设计中最精妙的一点:
┌────────────────────────────────────────────────────────────────┐
│ ServiceWorker 完整生命周期状态机 │
├────────────────────────────────────────────────────────────────┤
│ │
│ ① 页面调用 navigator.serviceWorker.register('/sw.js') │
│ ↓ │
│ ② 浏览器下载 sw.js → 逐字节比对:与当前已安装的版本是否相同? │
│ ├─ 相同 → nothing happens(已有这个版本在运行) │
│ └─ 不同(哪怕相差 1 字节)↓ │
│ │
│ ③ install 事件 │
│ ┌──────────────────────────────────────────┐ │
│ │ 新 SW 的 install 事件触发 │ │
│ │ → event.waitUntil(caches.open('v2')) │ │
│ │ → 预缓存关键资源(JS/CSS/HTML) │ │
│ │ → install 完成 → 进入 waiting │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ④ waiting 状态 ← ⚠️ 不能立即 activate! │
│ ┌──────────────────────────────────────────┐ │
│ │ 为什么?因为还有**旧版本的 SW 在运行**, │ │
│ │ 且这个旧 SW 正在为"当前已打开的页面"服务。 │ │
│ │ │ │
│ │ 如果立即激活新 SW: │ │
│ │ → 旧 SW 被强行 kill → 正在进行的 fetch 拦截被中断 │ │
│ │ → 页面请求的响应可能不完整 │ │
│ │ → 新 SW 接管但不知道旧 SW 缓存了什么 │ │
│ │ │ │
│ │ waiting 的退出条件: │ │
│ │ ├─ 用户关闭了所有使用旧 SW 的页面 │ │
│ │ └─ 或:调用 self.skipWaiting()(强制跳过等待│ │
│ │ — 但可能中断当前页面!) │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ▼ (旧 SW 释放所有页面后) │
│ │
│ ⑤ activate 事件 │
│ ┌──────────────────────────────────────────┐ │
│ │ 新 SW 激活! │ │
│ │ → event.waitUntil(caches.delete('v1')) │ │
│ │ → 清理旧缓存 │ │
│ │ → 此时新 SW 还不能拦截 fetch(为什么?) │ │
│ │ 因为"当前页面"是在旧 SW 活跃时加载的, │ │
│ │ 这个页面的 fetch 仍然走旧 SW 的路径 │ │
│ │ → 需要调用 clients.claim() 立即接管! │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ⑥ fetch 事件——开始拦截所有同名域的网络请求 │
│ │
└────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
skipWaiting() 和 clients.claim() 的组合:
// sw.js —— 立即激活模式(适合每次更新都希望用户立刻用新版本)
self.addEventListener('install', event => {
self.skipWaiting(); // ← 不等旧 SW 释放页面了——立即激活!
});
self.addEventListener('activate', event => {
event.waitUntil(
clients.claim() // ← 立即接管所有打开的页面
.then(() => caches.delete('old-cache'))
);
});
2
3
4
5
6
7
8
9
10
11
# 5.2 为什么 Serv
疑惑:SW 跑在独立线程上——给它一个"只读 DOM 的权限"(如 dom.querySelector)为什么也不行?
论证——不是"不给",是"物理上做不到":
DOM 树是主线程渲染引擎的独占数据结构——它不是多线程安全的。Blink 的 LayoutTree 在被访问时不需要任何锁(因为只有主线程能动它)。如果允许 SW 读取 DOM,要么 Blink 必须在每次 DOM 访问时加锁(开销不可接受——每个
element.offsetWidth都要 acquire 锁 → 60fps 渲染不可能),要么 SW 读到的不一致数据(没有锁 → 竞态)。SW 的生命周期可能覆盖多个 DOM——SW 为一个域的所有页面提供服务。一个用户可能有 3 个 Tab 都打开了同一个网站——每个 Tab 有自己的 DOM 树。SW 读"哪一个 DOM"?
SW 可能在页面不存在时运行——Push 通知到达时,浏览器启动 SW 处理事件——此时没有任何页面被打开,没有任何 DOM 树存在。
SW 的职责边界:
ServiceWorker 的职责 = 网络层代理(不是 DOM 管理层)
它应该:
✅ 拦截 fetch → 返回缓存的 Response 或网络 Response
✅ 接收 push 事件 → 显示 notification
✅ 在后台同步数据
它不应该:
❌ 读取页面的 DOM(不存在于 SW 的 V8 Isolate 中)
❌ 修改页面上的元素(页面可能不存在)
❌ 访问 localStorage(同步 API——在 SW 中阻塞会导致缓存命中延迟)
2
3
4
5
6
7
8
9
10
11
# 5.3 ServiceW
| 触发条件 | 浏览器行为 |
|---|---|
| 用户首次访问有 SW 的页面 | 下载 SW 脚本 → install → activate → 开始拦截 fetch |
| 用户再次访问(无新 SW) | 已有的 SW 脚本在内存中——不需要重新下载/install |
| SW 脚本内容有变化(字节级更新) | 新 SW 下载 → install → waiting → 用户关闭页面或 skipWaiting → activate |
| 最后一个使用此 SW 的页面被关闭后 30 秒 | 浏览器可以回收 SW 线程(节省内存)——但不是必须 |
| Push 事件到达(SW 已被回收) | 浏览器重新启动 SW 线程 → fire push 事件 → 处理完后再次回收 |
| 用户清除浏览器缓存 | SW 被注销——下次访问时重新走注册流程 |
# 6. 并发模式演进
# 6.1 回调 → Thu
疑惑:这六种模式中,每一次跃迁到底解决了什么"只有上一种模式才有的问题"?
论证——每一步跃迁都是对上一步某个"不可回避的痛点"的精确打击:
┌─────────────────────────────────────────────────────────────────┐
│ 六次范式跃迁——每一步解决的上一步问题 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ① 回调(Callback)——最原始 │
│ 形态:fn(err, result) │
│ 问题:回调地狱(callback hell)——嵌套 5 层后缩进把你推到屏幕右边 │
│ 根本问题:"控制权反转"——你把自己的回调交给了 fn,fn 决定 │
│ 什么时候调、调几次、调不调——你完全失控 │
│ │
│ ② Thunk(极简惰性求值) │
│ 形态:fn = () => callback │
│ 改进:把"带参数的调用"延迟为"无参数的可调用对象" │
│ 问题:仍然是回调——没有解决控制反转 │
│ │
│ ③ Promise(ES2015) │
│ 形态:fn().then(onFulfilled, onRejected) │
│ 核心改进:**把"控制权"从函数手里拿回来给了调用者** │
│ - Promise 只 resolve 一次——你不能被调两次 │
│ - Promise 必须 resolve 或 reject——不能"忘了调" │
│ - .then() 返回新 Promise——链式调用消除了嵌套 │
│ 问题①:不可取消——发了 fetch,不能中途"算了不发了" │
│ 问题②:单值——只能 resolve 一次。无法处理持续的异步事件流(如 WS) │
│ │
│ ④ Generator + co(过渡方案——在 async/await 标准化之前流行) │
│ 形态:yield + co 库自动执行 │
│ 改进:用同步语法写异步(yield 一个 Promise) │
│ 问题:需要 co 库、需要手动标记 function* ——被 async/await 取代 │
│ │
│ ⑤ async/await(ES2017) │
│ 形态:const result = await promise │
│ 改进:Generator + co 的标准化语法糖——零依赖 │
│ 问题:仍然是 Promise 的封装——不可取消、单值、不能处理事件流 │
│ const res = await fetch('/api') 可以,但 │
│ await websocket.onmessage 不行——这是个持续的事件流 │
│ │
│ ⑥ Observable(RxJS / TC39 Stage 1 proposal) │
│ 形态:observable$.pipe(map, filter).subscribe(observer) │
│ 核心改进:**把"多值"和"取消"加入异步一等公民** │
│ - 多值:Observable 可以 next 多次——天然适配事件流 │
│ - 取消:unsubscribe = 告诉生产者"我不需要更多数据了" │
│ - 操作符:map/filter/merge/debounce——函数式组合 │
│ │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 6.2 每一步解决的是上
关键洞察——为什么这个顺序是不可逆的:
你不能先发明 Observable 再发明 Promise。
因为 Observable 的 "多值 + 取消" 是两个正交的需求——
"取消"的前提是你有"一个可以被取消的持续数据流"(即"多值")。
而"多值"的前提是你有"一个值"(即 Promise 的 resolve 单值)。
六步演进 = 把六个正交的需求逐个加进异步编程模型:
1. 异步执行(回调——把"代码在未来运行"这个概念本身建模)
2. 惰性求值(Thunk——异步操作需要被"调用"而非直接"写出来")
3. 控制反转 + 链式(Promise——执行者不能失控,操作可以组合)
4. 同步语法(async/await——人类阅读代码的最佳实践)
5. 取消(TC39 的 AbortController——不完整的资源浪费了时间和带宽)
6. 多值流(Observable——不是所有异步操作都只有一个结果)
2
3
4
5
6
7
8
9
10
11
12
| 模式 | 取消 | 多值 | 操作符组合 | 同步语法 | Promise 互操作 |
|---|---|---|---|---|---|
| 回调 | ❌(手动) | ❌(每次 fn 是新调用) | ❌ | ❌ | ❌ |
| Promise | ❌ | ❌(单值) | .then 链 | ❌ | — |
| async/await | ❌ | ❌(单值) | 有限 | ✅ | ✅ |
| Observable | ✅ unsubscribe | ✅ next 多次 | ✅ pipe(map/filter/...) | ❌ | ✅ from(promise) |
# 7. RxJS冷热
# 7.1 Cold Obs
疑惑:new Observable(fn) 创建的 Observable——为什么每次 subscribe 都会重新执行 fn?
论证——Cold Observable 的"生产函数"内嵌在 fn 里,subscribe 就是调用它:
// Cold:每次 subscribe 都重新执行"生产逻辑"
const cold$ = new Observable(subscriber => {
console.log('Producer started'); // ← 每次 subscribe 都会打印
const value = Math.random(); // ← 每个订阅者拿到不同的随机值
subscriber.next(value);
subscriber.complete();
// 返回 unsubscribe 函数(cleanup)
return () => console.log('Producer cleaned up');
});
cold$.subscribe(v => console.log('Subscriber A:', v));
// → Producer started
// → Subscriber A: 0.523
// → Producer cleaned up
cold$.subscribe(v => console.log('Subscriber B:', v));
// → Producer started
// → Subscriber B: 0.187 ← 不同的值!每次 subscribe 都重新启动生产
// → Producer cleaned up
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Cold Observable 的典型场景:
| 来源 | 行为 | 为什么是 Cold |
|---|---|---|
of(1, 2, 3) | 每次 subscribe 从头推送 1, 2, 3 | 数据序列是固定的——每个订阅者应该收到完整序列 |
from(fetch(url)) | 每次 subscribe 发起一次新的 HTTP 请求 | 每次订阅应该发出一次独立的请求(可能拿到不同结果) |
new Observable(fn) | 每次 subscribe 执行 fn | fn 是数据的"生产函数"——每个订阅者应该有自己的生产过程 |
# 7.2 Hot Obse
// Hot:生产者独立运行,订阅者只是"开始监听"
const click$ = fromEvent(document, 'click');
click$.subscribe(e => console.log('A clicked at:', e.clientX));
// → 用户点击 → A clicked at: 120
// → 用户点击 → A clicked at: 340
// 生产者是用户的鼠标——它不依赖 subscribe 而存在
setTimeout(() => {
click$.subscribe(e => console.log('B clicked at:', e.clientX));
// → 用户点击 → A clicked at: 510, B clicked at: 510 ← 同时输出!
// B 错过了之前的点击——它 subscribe 时才加入监听
}, 3000);
2
3
4
5
6
7
8
9
10
11
12
13
Cold vs Hot 的核心差异:
| 维度 | Cold | Hot |
|---|---|---|
| 数据生产者 | 在 Observable 的函数内部创建 | 独立于 Observable 预先存在 |
| 生产启动时机 | subscribe 时(懒启动) | subscribe 之前就已经在生产 |
| 多订阅者 | 各自独立的生产流程 | 共享同一个数据源 |
| unsubscribe | 停止生产(无订阅者就不需要生产了) | 生产者继续存在——只是这个订阅者不再接收 |
| 典型来源 | of()/from()/ajax()/new Observable() | fromEvent()/WebSocket subject/interval() |
# 7.3 Subject
// Subject = 既是 Observable(可订阅),又是 Observer(可 next 数据)
const subject$ = new Subject();
// 消费者 1:订阅
subject$.subscribe(v => console.log('A:', v));
// 生产者:往 Subject 里推数据
subject$.next(1); // → A: 1
subject$.next(2); // → A: 2
// 消费者 2:晚来的订阅者——错过了 1 和 2!
subject$.subscribe(v => console.log('B:', v));
subject$.next(3); // → A: 3, B: 3 ← 只有从此刻之后的数据才会收到
// BehaviorSubject = 带"当前值"的 Subject——新订阅者立刻拿到最新值
const bs$ = new BehaviorSubject(0); // 初始值 0
bs$.subscribe(v => console.log('C:', v)); // → C: 0 ← 立刻触发!
bs$.next(1); // → C: 1
bs$.subscribe(v => console.log('D:', v)); // → D: 1 ← 拿到最新值!
// ReplaySubject = 带"历史缓冲区"的 Subject——新订阅者拿到最近 N 个值
const rs$ = new ReplaySubject(2); // 缓存最近 2 个值
rs$.next('a');
rs$.next('b');
rs$.next('c');
rs$.subscribe(v => console.log('E:', v)); // → E: b, E: c ← 拿到最近 2 个!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 8. 定时器剖析详解
# 8.1 4ms 夹持的精
疑惑:setTimeout(fn, 0) 真的最少要等 4ms 吗?为什么实测有时 1ms,有时 4.7ms?
论证——4ms 夹持的触发取决于嵌套层级,不是所有情况:
┌───────────────────────────────────────────────────────────┐
│ setTimeout 的延迟规则——HTML Spec §8.6 │
├───────────────────────────────────────────────────────────┤
│ │
│ 规则 1:第一次调用 setTimeout(fn, 0) │
│ → 浏览器把 timer 放入 timer 队列 │
│ → 下一个事件循环的 timer 阶段执行 │
│ → 实际延迟 ≈ 1ms(Chrome)/ ≈ 4ms(Firefox) │
│ → 因为这受限于"下一次事件循环何时进入 timer 阶段" │
│ │
│ 规则 2:如果 setTimeout 的回调里又调了 setTimeout(fn, 0) │
│ → 嵌套层级 + 1 │
│ → 第 2~5 层嵌套:Chrome 不夹持——延迟 ≈ 1ms │
│ │
│ 规则 3:嵌套层级 ≥ 5 时(从第 5 层开始计数) │
│ → HTML Spec §8.6: "If nesting level > 5, │
│ and timeout < 4, then set timeout to 4." │
│ → 浏览器强制把 delay 抬高到 ≥ 4ms │
│ → Chrome 实测:5ms 嵌套后 ≈ 4.7ms(4ms + V8 执行开销) │
│ │
│ 规则 4:页面在后台 Tab 中(不可见) │
│ → 浏览器会把 setTimeout 的最小延迟提高到 1000ms(1秒) │
│ → 目的:节省 CPU/电量——后台页面不需要高频更新 │
│ │
│ 为什么要有 4ms 夹持? │
│ 假设没有夹持: │
│ function loop() { setTimeout(loop, 0); heavy(); } │
│ → 这个 loop 会以接近 1ms 的间隔无限运行 │
│ → 每秒 ~1000 次 heavy() → timer 占满事件循环 │
│ → 其他 task(用户交互、渲染)永远得不到执行 → 页面死锁 │
│ 4ms 夹持 = 强制留出"至少 4ms 的窗口"给其他 task │
│ │
└───────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
代码验证——嵌套层级与延迟的关系:
function testNesting(n, delay) {
if (n === 0) {
console.timeEnd('nested-10');
return;
}
setTimeout(() => testNesting(n - 1, delay), delay);
}
console.time('nested-10');
testNesting(10, 0);
// Chrome 输出(Performance 面板 + console.time):
// nested-10: ~48ms(最后 5 层被 4ms 夹持)
//
// 计算:前 5 层 ~1ms × 5 = 5ms + 后 5 层 ~4.7ms × 5 = 23.5ms + 额外开销
// ≈ 30-50ms(受 CPU 负载影响)
2
3
4
5
6
7
8
9
10
11
12
13
14
# 8.2 setTimeo
| 维度 | 浏览器(Chrome) | Node.js |
|---|---|---|
setTimeout(fn, 0) 首层延迟 | ~1ms | ~1ms |
| 嵌套 ≥ 5 层后的延迟 | ~4.7ms(HTML §8.6 夹持) | ~1ms(libuv 无夹持) |
| 页面在后台时的延迟 | 最少 1000ms! | N/A(Node 没有"后台"概念) |
setImmediate(fn) | ❌(仅在 IE 中非标准实现) | ✅——在 I/O 回调之后、下一个 timer 之前执行 |
| 精度 | 整数毫秒(向下取整?不——规范要求"不小于") | 同浏览器 |
Node.js 中没有 4ms 夹持的根因:
libuv 的
uv__run_timers()在检查定时器是否到期时,用的是timer->timeout <= uv_now(loop)——没有最大嵌套层级的判断,没有"clamp to 4ms"的逻辑。因为 Node.js 没有渲染管线(不需要为了防饿死渲染而给 timer 设下限),libuv 的 timer 精度只受 poll 阶段处理 I/O 回调的时间影响。
# 8.3 setInter
疑惑:setInterval(fn, 100) 每次触发应该是精确的 100ms 间隔——但实际是 100.3ms → 100.5ms → 101.2ms → 103ms——为什么误差在累积?
论证——setInterval 不知道"回调本身花了多少时间":
┌─────────────────────────────────────────────────────────┐
│ setInterval 的误差累积 │
├─────────────────────────────────────────────────────────┤
│ │
│ setInterval(fn, 100) │
│ │
│ 理想时间轴(如果 fn 耗时 0ms): │
│ 0ms ── fn ── 100ms ── fn ── 200ms ── fn ── 300ms │
│ │
│ 实际时间轴(fn 耗时 8ms): │
│ 0ms ── fn ─────────── 108ms ── fn ─────────── 216ms │
│ ↑ 8ms 执行 ↑ 从上次结束开始 + 100ms │
│ 而不是从上次"开始"! │
│ setInterval 的算法: │
│ "上一次回调执行完毕后 + interval 毫秒 → 触发下一次" │
│ 而不是"从上一次触发开始的绝对时间 + interval → 触发下一次" │
│ │
│ 如果 fn 稳定耗时 8ms:每次实际间隔 = 108ms(100 + 8) │
│ 10 次后:累计延迟 = 800ms │
│ 如果 fn 的执行时间波动(8ms/12ms/6ms): │
│ 间隔 = (100 + 8) / (100 + 12) / (100 + 6) │
│ 这种不规则的"漂移"让 setInterval 不适合精确计时 │
│ │
└─────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
修复——用 setTimeout 递归实现精确间隔:
function preciseInterval(fn, interval) {
let expected = performance.now() + interval;
function step() {
// 执行回调(不阻塞 setTimeout 的重新调度)
fn();
// 计算漂移:实际当前时间 vs 预期应该触发的时间
const drift = performance.now() - expected;
// 下一次应该在"绝对时间 expected" + interval
expected += interval;
// 下一次 setTimeout 只等 "interval - drift"
// 如果 drift 是正的(我们慢了) → 减少等待时间 → 修正!
// 如果 drift 是负的(我们快了,不太可能但理论上) → Math.max(0, ...) 防负值
setTimeout(step, Math.max(0, interval - drift));
}
setTimeout(step, interval);
}
// 实测:100ms 间隔 × 100 次
// setInterval: 平均 108.3ms/次,累计偏差 +830ms
// preciseInterval: 平均 100.1ms/次,累计偏差 +10ms ← 接近完美
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 9. rAF与rIC
# 9.1 rAF 与 Vs
疑惑:requestAnimationFrame(fn) 为什么是"在浏览器下一次绘制之前"而不是"下一个宏任务"?
论证——rAF 不是 timer,它是渲染管线内部的一个钩子:
┌──────────────────────────────────────────────────────────────┐
│ 一帧(16.67ms @ 60Hz)中 rAF 的精确位置 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 显示器发出 Vsync 信号(电子束扫到屏幕右下角 → 准备开始新帧) │
│ │ │
│ ▼ │
│ ═══ JS 执行(宏任务 + 微任务)════════════════════════ │
│ │ rAF 回调在这里执行! │
│ │ "你想在这一帧里更新什么东西?" │
│ │ │
│ ▼ │
│ ═══ Style Recalc ══════════════════════════ │
│ │ 如果 JS/rAF 回调修改了 CSS 属性 → 重新计算样式 │
│ ▼ │
│ ═══ Layout ════════════════════════════════ │
│ │ 如果 JS/rAF 回调修改了 DOM → 重新计算布局 │
│ ▼ │
│ ═══ Paint ═════════════════════════════════ │
│ │ 生成绘制指令 │
│ ▼ │
│ ═══ Composite ══════════════════════════════ │
│ │ GPU 合成各层 → 写入帧缓冲 │
│ ▼ │
│ 显示器读取帧缓冲 → 用户看到新帧 │
│ │
│ rAF 的时机 = "在 Style Recalc 之前" │
│ 这个位置确保:你在 rAF 中修改的 CSS/DOM → RUST 浏览器在下一次的 │
│ Style Recalc 和 Layout 阶段就把它算出来 → 画在同一帧里 │
│ 不浪费一帧、不延迟一帧 │
│ │
└──────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
rAF vs setTimeout 的性能差异——动画场景:
// ❌ setTimeout 做动画——帧率取决于主线程忙不忙
function animateStep() {
element.style.left = pos + 'px';
pos += 1;
setTimeout(animateStep, 16); // ← 16ms 后再次执行
// 问题:如果主线程正在处理一个大 task(比如解析 JSON),
// setTimeout 会被推迟到 task 结束后 → 帧间隔可能变成 30ms/50ms → 掉帧
}
// ✅ rAF 做动画——帧率对齐到 Vsync
function animateStep() {
element.style.transform = `translateX(${pos}px)`;
pos += 1;
requestAnimationFrame(animateStep); // ← 下一帧绘制前自动执行
// 优势:
// - 对齐 Vsync → 每帧只触发一次 → 不会过度绘制(setTimeout 可能一帧触发多次)
// - 页面隐藏时自动暂停 → 不浪费 CPU → 省电
// - 浏览器知道"这是动画"→ 可以在 Composite 阶段做 transform → 0ms Layout
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 9.2 requestI
┌───────────────────────────────────────────────────────────┐
│ 一帧的完整时间预算(16.67ms @ 60Hz) │
├───────────────────────────────────────────────────────────┤
│ │
│ ═══ JS / rAF ═══════════════════ 5ms(假设) │
│ ═══ Style + Layout + Paint ════ 3ms │
│ ═══ Composite ══════════════════ 2ms │
│ │
│ 总耗时:10ms → 还剩 6.67ms 空闲! │
│ ↓ │
│ requestIdleCallback(deadline => { │
│ // deadline.timeRemaining() → 6.67ms │
│ // deadline.didTimeout → false(没有超时) │
│ while (deadline.timeRemaining() > 0) { │
│ processNextTask(); // ← 只在剩余的空闲时间里执行 │
│ } │
│ }); │
│ │
└───────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
rIC 的两个参数:
requestIdleCallback(fn, { timeout: 2000 });
// ↑
// 如果浏览器太忙,2 秒内都没空闲时间 →
// 强制触发 fn(deadline.didTimeout = true)
2
3
4
# 9.3 React Sc
React 16 开始的 Fiber 架构和并发模式需要"可中断的渲染"——把一大块 JS 计算(React 的 reconciler 遍历组件树)切成多个 ~5ms 的时间片。
React 为什么不用 requestIdleCallback:
- Safari 不支持 rIC——React 需要跨浏览器
- rIC 优先级太低——浏览器可能在连续好几帧内都不给 rIC 执行(如果每帧都接近 16ms 预算)
- React 需要更高优先级的调度策略——用户输入 > 动画 > 数据更新 > 离线任务
React Scheduler 替代方案——MessageChannel + 微任务:
React Scheduler 的时间切片实现(简化):
1. 为每 5ms 创建一个"时间片"
2. 每个时间片内:执行 reconciler 的一小部分工作
3. 时间片结束后:通过 postMessage 把"继续工作"放到下一个宏任务中
4. 效果:主线程每做 ~5ms 的 Render 工作就"让出"控制权,
让浏览器有机会处理用户输入/渲染 → 用户感觉不到卡顿
2
3
4
5
6
调度时钟优先级速查:
| 优先级 | API | 触发时机 | 适合什么任务 |
|---|---|---|---|
| 最高 | queueMicrotask | 当前宏任务结束后、渲染之前 | Promise.then 的实现、确保在渲染前完成的 DOM 操作 |
| 高 | requestAnimationFrame | 下一帧绘制前(Vsync 同步) | 动画、DOM 批量更新、与渲染相关的 JS 操作 |
| 中 | setTimeout(fn, 0) | 下一个事件循环的 timer 阶段 | 需要延迟到"稍后"但不必对齐渲染的任务 |
| 低 | requestIdleCallback | 帧内空闲时间 | 日志上报、预加载数据、分析统计 |
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的图像滤镜工具——七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① Worker 到底是怎么"借了一个线程"? | 第 3.1 节:Blink 从浏览器线程池分配 OS 线程 → 初始化独立 V8 Isolate → 启动独立事件循环。Worker 和主线程通过 IPC + postMessage 通信——它们在不同的 V8 Isolate 中,物理上隔离 |
| ② 结构化克隆 vs Transferable 的差异 | 第 3.2 节:克隆 = 逐字节深拷贝(85ms/80MB)→ Transferable = 修改页表项转移物理内存所有权(<1ms),零拷贝 |
| ③ SharedArrayBuffer 怎么做到共享 | 第 4.1 节:SAB 的 backing store 不是任何一个 V8 Isolate "独占"的内存——它绕过 V8 堆管理,两个 Isolate 的页表映射到同一块物理页。写入立即可见 |
| ④ Atomics.wait 为什么阻塞线程 | 第 4.2 节:wait 调用了 OS 的 futex 系统调用——内核把当前线程放到地址的等待队列上并挂起。主线程禁止 wait——会冻结 UI |
| ⑤ ServiceWorker waiting 状态的目的 | 第 5.1 节:防止新 SW 激活时中断旧 SW 正在服务的请求——waiting = "等旧 SW 释放所有客户端"。跳过等待用 skipWaiting() |
| ⑥ 六种并发模式之间的因果链 | 第 6 章:回调→Promise(控制反转+链式)→async/await(同步语法)→ Observable(多值+取消)。每一步解决的是上一步"不可回避的痛点" |
| ⑦ setTimeout 的 4ms 夹持为什么只在浏览器 | 第 8 章:HTML Spec §8.6 的浏览器专属规范(防 timer "饿死"渲染)。Node.js 的 libuv 没有渲染管线→不需要这个夹持 |
修复方案(按代价从小到大):
方案 A:主线程分帧执行(治标)
把 600ms 的高斯模糊切成 300 帧 × 2ms:
- 总耗时:300 帧 × 16.67ms ≈ 5 秒
- 代价:用户等 5 秒("实时滤镜"?不是)
- 适用:非实时预览(如点"应用滤镜"按钮后出结果)
方案 B:Worker + Transferable(治本)
// main.js
async function applyFilterInWorker(imageData) {
const worker = new Worker('filter.worker.js');
return new Promise(resolve => {
worker.onmessage = ({ data }) => {
resolve(new ImageData(data, imageData.width, imageData.height));
};
// Transferable——零拷贝传 48MB 像素数据
worker.postMessage(imageData, [imageData.data.buffer]);
});
}
// filter.worker.js
self.onmessage = ({ data }) => {
applyGaussianBlur(data.data, data.width, data.height); // 600ms
self.postMessage(data, [data.data.buffer]); // 结果零拷贝传回
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 主线程阻塞时间:<1ms(只有 postMessage 调用的开销)
- Worker 处理时间:600ms(并行,不影响主线程)
- 总用户体验:600ms 无卡顿(主线程继续响应用户交互)
方案 C:多 Worker 并行处理(规模扩展)
async function applyFilterToMultipleImages(files) {
const workers = [];
const promises = files.map(async (file, i) => {
const img = await createImageBitmap(file);
const imageData = await getImageData(img);
// 每个文件用一个独立的 Worker
const worker = new Worker('filter.worker.js');
workers.push(worker);
return new Promise(resolve => {
worker.onmessage = ({ data }) => resolve(data);
worker.postMessage(imageData, [imageData.data.buffer]);
});
});
const results = await Promise.all(promises);
// 5 张图片 × 600ms → 并行 → 总耗时 ~600ms(而不是 5×600 = 3000ms)
// CPU 核心数 = 天然并行度上限
workers.forEach(w => w.terminate()); // 用完销毁
return results;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 10.2 Worker
// main.js —— 完整架构:Worker 负责数据处理,主线程只负责渲染
const worker = new Worker('data-processor.worker.js');
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
let latestData = null;
// ① Worker 返回处理好的数据
worker.onmessage = ({ data }) => {
latestData = data; // 暂存最新数据
};
// ② rAF 只负责渲染——读 latestData + 画到 Canvas
function renderFrame() {
if (latestData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / latestData.length;
for (let i = 0; i < latestData.length; i++) {
const height = latestData[i] * canvas.height;
ctx.fillRect(i * barWidth, canvas.height - height, barWidth - 1, height);
}
}
requestAnimationFrame(renderFrame); // 下一帧继续渲染
}
// ③ 主循环:每隔 2 秒把新数据发给 Worker 处理
function updateData() {
const raw = new Float64Array(
Array.from({ length: 50000 }, () => Math.random())
);
worker.postMessage(raw.buffer, [raw.buffer]); // Transferable——零拷贝传 400KB
}
setInterval(updateData, 2000);
requestAnimationFrame(renderFrame);
// data-processor.worker.js
self.onmessage = ({ data }) => {
const arr = new Float64Array(data);
arr.sort(); // 排序 5 万浮点数——~15ms
self.postMessage(arr.buffer, [arr.buffer]); // 零拷贝传回
};
// 主线程的帧率:始终 60fps(渲染不卡)
// Worker 的排序:15ms 在独立线程中并行,不影响主线程
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 10.3 设计哲学回扣
哲学一·「单线程的 JS、多线程的浏览器——隔离即安全」
JS 是单线程的——但浏览器给 JS 开了"借用其他线程"的窗口(Worker)。这保持了 JS 语言本身的简单性(开发者不需要锁、不需要 volatile、不需要 sleep),同时让计算密集型任务有了逃离主线程的通道。Worker 不共享内存(除非你用 SAB 并显式处理同步)——这是"默认隔离"的设计哲学:安全性的默认值是"你不能碰到我",而不是"你可以碰到我但我有锁保护你"。
哲学二·「调度是对时间的分级定价——不是所有任务都值得同一级别的 CPU 配额」
queueMicrotask(最快、最贵——在下一帧之前就跑)→ requestAnimationFrame(和显示器同步——动画专属)→ setTimeout(毫秒级延迟——非关键回调)→ requestIdleCallback(帧内空闲时才跑——纯低优先级)。这套四级调度体系用不同 API 显式区分了不同的"时间价值"。一个好的调度系统不是"让所有的任务尽量快",而是"让重要的任务快,让不重要的任务让路"。
哲学三·「Transferable 的所有权转移——用资源所有权替代锁,是比线程安全更激进的保证」
Transferable 的"转让后,原持有方不能再访问"不是限制——它是最强的保证:保证同一时刻只有一个线程持有这片内存。没有竞态、不需要锁、不需要原子操作、不需要 volatile。JS 用"所有权转移"替代了 C++/Java 的"锁"——前者在 API 层面让竞态不可能发生,后者在运行时让竞态可以被检测和规避。"不可能出错"优于"可以检查是否出错"。
哲学四·「Cold vs Hot——数据生产的控制权归属是异步编程的根本问题」
Cold Observable 的生产函数在 subscribe 时才启动——数据生产由订阅者控制。Hot Observable 的数据生产独立于订阅——它像一个"一直在播的广播电台",订阅只是"打开收音机"。这两种模式对应两种完全不同的系统设计:拉取(pull)——消费者驱动生产;推送(push)——生产者驱动消费。
在数据库查询(SELECT * FROM ...)中,消费者拉取数据。在 WebSocket 中,服务器推送数据。在 React 中,用户交互(点击)推送数据,useEffect 拉取数据。Cold/Push、Hot/Pull 不是在 RxJS 里才有——它是所有带"时间"的系统中必须回答的核心问题:数据是在你看的时候生产的,还是在你看之前就已经在生产了?
# 10.4 速查表
Worker 三种模式对比:
| 维度 | DedicatedWorker | SharedWorker | ServiceWorker |
|---|---|---|---|
| 创建 | new Worker('w.js') | new SharedWorker('w.js') | navigator.serviceWorker.register('sw.js') |
| 线程关系 | 1:1 | 1:N | 1:N |
| 生命周期 | 随页面 | 最后引用关闭后销毁 | 浏览器管理 |
| DOM | ❌ | ❌ | ❌ |
| 网络拦截 | ❌ | ❌ | ✅ |
| 通信 | postMessage | MessagePort.postMessage | postMessage (Client) |
| 场景 | CPU 密集 | 跨 Tab 共享状态 | 离线缓存/推送/后台同步 |
postMessage 传数据方式:
| 方式 | 速度(80MB) | 机制 | 发送方能否继续访问 |
|---|---|---|---|
| 结构化克隆 | ~85ms | 深拷贝 → IPC → 反序列化 | ✅(两份独立副本) |
| Transferable | <1ms | 页表项转移所有权 | ❌(byteLength → 0) |
| SAB | <1ms | 两个 V8 Isolate 共享同一块物理内存 | ✅(共享——需 Atomics 同步) |
调度时钟优先级全景:
| 优先级 | API | 触发时机 | 页面隐藏 | 典型延迟 | 适合任务 |
|---|---|---|---|---|---|
| 最高 | queueMicrotask | 当前 task 结束 | 正常 | <0.1ms | Promise.then、MutationObserver |
| 高 | requestAnimationFrame | 下一帧 Vsync 前 | 暂停 | ~16.67ms(60Hz) | 动画、批量 DOM 更新 |
| 中 | setTimeout(fn, 0) | 下一轮 timer 阶段 | 节流至 1s | ≥1ms(首层)/ ≥4ms(嵌套≥5) | 延迟回调、debounce |
| 低 | requestIdleCallback | 帧内空闲时 | 暂停 | 不一定(可能几帧才触发一次) | 日志上报、预加载、非关键分析 |
RxJS 冷/热 + Subject 家族:
| 类型 | 数据生产时机 | 多订阅者行为 | 新订阅者 | 典型场景 |
|---|---|---|---|---|
| Cold Observable | subscribe 时启动 | 各自独立生产 | 从头接收 | of(), from(fetch()) |
| Hot Observable | 独立于 subscribe | 共享同一源 | 只收到未来的值 | fromEvent(), WebSocket Subject |
| Subject | 手动 .next() | 共享 | 只收到未来的 | 事件总线 |
| BehaviorSubject | 手动 .next() + 初始值 | 共享 | 立刻收到最新值 | 当前状态(用户是否登录) |
| ReplaySubject(N) | 手动 .next() + 缓冲区 | 共享 | 收到最近 N 个值 | 回放最近几秒的数据 |
下一步:Worker 让排序不卡主线程了。但 DOM 操作本身也会卡——浏览器怎么把 HTML/CSS 变成屏幕像素?进入 10.浏览器渲染像素之路。