编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
        • 1. 案例与疑问引入
          • 1.1 一个实时图像滤镜
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 Dedicate
          • 2.2 为什么浏览器要把
        • 3. 专用Worker
          • 3.1 Worker
          • 3.2 postMess
          • 3.3 Worker
        • 4. 共享内存详解
          • 4.1 SAB 的内存模型
          • 4.2 Atomics
          • 4.3 SAB 的安全前提
        • 5. ServiceW
          • 5.1 生命周期:ins
          • 5.2 为什么 Serv
          • 5.3 ServiceW
        • 6. 并发模式演进
          • 6.1 回调 → Thu
          • 6.2 每一步解决的是上
        • 7. RxJS冷热
          • 7.1 Cold Obs
          • 7.2 Hot Obse
          • 7.3 Subject
        • 8. 定时器剖析详解
          • 8.1 4ms 夹持的精
          • 8.2 setTimeo
          • 8.3 setInter
        • 9. rAF与rIC
          • 9.1 rAF 与 Vs
          • 9.2 requestI
          • 9.3 React Sc
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 Worker
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 页面渲染像素原理
      • 网络接口存储架构
      • 服务端运行时编程
      • 模块系统双轨操作
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

工作线程并发调度

# 09.Worker 并发与调度时钟

📍 上接第 08 篇《事件循环 Promise 一体论》。主线程的异步调度已了然。本文回答:当 CPU 密集任务让主线程卡死时,Worker 怎么救场?SharedArrayBuffer 怎么让多线程安全共享内存?setTimeout 为什么最少 4ms?rAF 和 Vsync 之间隔了什么?

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 一个实时图像滤镜
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 Dedicate
    • 2.2 为什么浏览器要把
  • 3. 专用Worker
    • 3.1 Worker
    • 3.2 postMess
    • 3.3 Worker
  • 4. 共享内存详解
    • 4.1 SAB 的内存模型
    • 4.2 Atomics
    • 4.3 SAB 的安全前提
  • 5. ServiceW
    • 5.1 生命周期:ins
    • 5.2 为什么 Serv
    • 5.3 ServiceW
  • 6. 并发模式演进
    • 6.1 回调 → Thu
    • 6.2 每一步解决的是上
  • 7. RxJS冷热
    • 7.1 Cold Obs
    • 7.2 Hot Obse
    • 7.3 Subject
  • 8. 定时器剖析详解
    • 8.1 4ms 夹持的精
    • 8.2 setTimeo
    • 8.3 setInter
  • 9. rAF与rIC
    • 9.1 rAF 与 Vs
    • 9.2 requestI
    • 9.3 React Sc
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 Worker
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 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 动画停在半空
1
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 章
1
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 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
1
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 代理)                 │    │
│  └──────────────────────────────────────────────────────────┘    │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘
1
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?

论证:

  1. 安全隔离的粒度不同——DedicatedWorker 只和创建它的页面通信。一个 iframe 里的恶意脚本不能用 postMessage 向其他 Tab 的 DedicatedWorker 注入数据。SharedWorker 允许跨 Tab 共享,但这意味着你信任所有连接它的同源页面。ServiceWorker 更进一步——它能拦截所有同源页面的网络请求,这个权限必须由浏览器框架严格管理(install/activate 的生命周期流程就是为了确保"只有用户确认了网站想装 SW,SW 才能装")。

  2. 生命周期匹配不同的需要——DedicatedWorker 的"随页面消亡"是合理的默认值:我打开一个图像编辑页面→启动 Worker 做滤镜;我关闭这个页面→Worker 应该被回收(不需要再处理滤镜了)。但 ServiceWorker 需要独立于页面存活——推送通知到达时,页面可能根本没打开。把这两种生命周期混在一个 API 里会让开发者搞不清"Worker 什么时候会被杀掉"。

  3. API 权限不同——如果把所有权限(DOM、网络拦截、缓存、推送)都放在一个 Worker 类型里,攻击面的组合爆炸会让安全审查变得不可能。每种 Worker 类型只有"完成它职责所需要的最小 API 集合"——这就是"最小权限原则(principle of least privilege)"在浏览器架构中的体现。

  4. 反向验证: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 渲染      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
1
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 核心上)。
1
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       │
│       │      指向的是**同一块物理内存**                             │
│                                                                 │
│  全程耗时:修改两个页表项 + 一次系统调用 ≈ &lt;1ms                      │
│                                                                 │
│  类比:结构化克隆 = 把一整本书逐字手抄一份                             │
│        Transferable = 把书的"产权证"从你名下转到我名下                  │
│        书还是那一本(同一个物理页),只是所有者变了                       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
1
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
1
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   │                           │
│  └───────────┘        └───────────┘                           │
│                                                                │
└────────────────────────────────────────────────────────────────┘
1
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);
1
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, &amp;i32[0], 0, nullptr)            │
│          → 内核把当前线程放到 SAB 地址的等待队列上           │
│          → 当前线程被挂起(操作系统调度器把它移出运行队列)     │
│          → 它不消耗任何 CPU 时间——完全"睡着了"              │
│                                                           │
│  另一个线程调用:Atomics.notify(i32, 0, 1)                   │
│      │                                                    │
│      ▼ V8 调用 futex(FUTEX_WAKE, &amp;i32[0], 1)               │
│      → 内核把等待队列中最多 1 个线程唤醒                     │
│      → 被唤醒的线程重新获得 i32[0] 的值 → 发现已经变了 → 返回 │
│                                                           │
│  如果 i32[0] 在被 wait 之前就已经变了(!== expected):       │
│      → Atomics.wait 立即返回 "not-equal"——不阻塞           │
│                                                           │
└───────────────────────────────────────────────────────────┘
1
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!
// 原因:如果主线程被阻塞 → 渲染停止 → 用户交互冻结 → 浏览器"死了"
// 浏览器设计者明确:主线程上不能有阻塞操作——即使你想要也不行
1
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');
};
1
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 头)                 │
│                                                               │
└───────────────────────────────────────────────────────────────┘
1
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 事件——开始拦截所有同名域的网络请求                        │
│                                                                │
└────────────────────────────────────────────────────────────────┘
1
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'))
  );
});
1
2
3
4
5
6
7
8
9
10
11

# 5.2 为什么 Serv

疑惑:SW 跑在独立线程上——给它一个"只读 DOM 的权限"(如 dom.querySelector)为什么也不行?

论证——不是"不给",是"物理上做不到":

  1. DOM 树是主线程渲染引擎的独占数据结构——它不是多线程安全的。Blink 的 LayoutTree 在被访问时不需要任何锁(因为只有主线程能动它)。如果允许 SW 读取 DOM,要么 Blink 必须在每次 DOM 访问时加锁(开销不可接受——每个 element.offsetWidth 都要 acquire 锁 → 60fps 渲染不可能),要么 SW 读到的不一致数据(没有锁 → 竞态)。

  2. SW 的生命周期可能覆盖多个 DOM——SW 为一个域的所有页面提供服务。一个用户可能有 3 个 Tab 都打开了同一个网站——每个 Tab 有自己的 DOM 树。SW 读"哪一个 DOM"?

  3. SW 可能在页面不存在时运行——Push 通知到达时,浏览器启动 SW 处理事件——此时没有任何页面被打开,没有任何 DOM 树存在。

SW 的职责边界:

ServiceWorker 的职责 = 网络层代理(不是 DOM 管理层)

它应该:
  ✅ 拦截 fetch → 返回缓存的 Response 或网络 Response
  ✅ 接收 push 事件 → 显示 notification
  ✅ 在后台同步数据

它不应该:
  ❌ 读取页面的 DOM(不存在于 SW 的 V8 Isolate 中)
  ❌ 修改页面上的元素(页面可能不存在)
  ❌ 访问 localStorage(同步 API——在 SW 中阻塞会导致缓存命中延迟)
1
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——函数式组合                │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
1
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——不是所有异步操作都只有一个结果)
1
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
1
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);
1
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 个!
1
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 &lt; 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             │
│                                                           │
└───────────────────────────────────────────────────────────┘
1
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 负载影响)
1
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 不适合精确计时          │
│                                                         │
└─────────────────────────────────────────────────────────┘
1
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 ← 接近完美
1
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 阶段就把它算出来 → 画在同一帧里            │
│  不浪费一帧、不延迟一帧                                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘
1
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
}
1
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();  // ← 只在剩余的空闲时间里执行        │
│    }                                                      │
│  });                                                      │
│                                                           │
└───────────────────────────────────────────────────────────┘
1
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)
1
2
3
4

# 9.3 React Sc

React 16 开始的 Fiber 架构和并发模式需要"可中断的渲染"——把一大块 JS 计算(React 的 reconciler 遍历组件树)切成多个 ~5ms 的时间片。

React 为什么不用 requestIdleCallback:

  1. Safari 不支持 rIC——React 需要跨浏览器
  2. rIC 优先级太低——浏览器可能在连续好几帧内都不给 rIC 执行(如果每帧都接近 16ms 预算)
  3. React 需要更高优先级的调度策略——用户输入 > 动画 > 数据更新 > 离线任务

React Scheduler 替代方案——MessageChannel + 微任务:

React Scheduler 的时间切片实现(简化):
  1. 为每 5ms 创建一个"时间片"
  2. 每个时间片内:执行 reconciler 的一小部分工作
  3. 时间片结束后:通过 postMessage 把"继续工作"放到下一个宏任务中
  4. 效果:主线程每做 ~5ms 的 Render 工作就"让出"控制权,
     让浏览器有机会处理用户输入/渲染 → 用户感觉不到卡顿
1
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]);  // 结果零拷贝传回
};
1
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;
}
1
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 在独立线程中并行,不影响主线程
1
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.浏览器渲染像素之路。

上次更新: 2026/06/16, 12:36:20
事件循环承诺机制
页面渲染像素原理

← 事件循环承诺机制 页面渲染像素原理→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式