Open Bulandent opened 3 years ago
作者:TAT. cntchen http://www.alloyteam.com/2020/07/14680/
Web Worker 作为浏览器多线程技术, 在页面内容不断丰富, 功能日趋复杂的当下, 成为缓解页面卡顿, 提升应用性能的可选方案. 但她的容颜, 隐藏在边缘试探的科普文章和不知深浅的兼容性背后; 对 JS 单线程面试题倒背如流的前端工程师, 对多线程开发有着天然的陌生感.
Web Worker 作为浏览器多线程技术, 在页面内容不断丰富, 功能日趋复杂的当下, 成为缓解页面卡顿, 提升应用性能的可选方案.
但她的容颜, 隐藏在边缘试探的科普文章和不知深浅的兼容性背后; 对 JS 单线程面试题倒背如流的前端工程师, 对多线程开发有着天然的陌生感.
文献综述(Literature Review) 是学术研究领域一个常见概念, 写过毕业论文的同学应该还有印象. 它向读者介绍与主题有关的详细资料、动态、进展、展望以及对以上方面的评述.
文献综述
近期笔者关注 Web Worker, 并落地到了大型复杂前端项目. 开源了 Worker 通信框架 alloy-worker, 正在写实践总结文章. 其间查阅了相关资料 (50+文章, 10+技术演讲), 独立写成这篇综述性文章.
前端同学对 Web Worker 应该不陌生, 即使没有动手实践过, 应该也在社区上看过相关文章. 在介绍和使用上, 官方文档是 MDN 的 Web Workers API. 其对 Web Worker 的表述是:
Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application.
如下图所示, Web Worker 实现了多线程运行 JS 能力. 之前页面更新要先串行 (Serial) 做 2 件事情; 使用 Worker 后, 2 件事情可并行 (Parallel) 完成.
可以直观地联想: 并行可能会提升执行效率; 运行任务拆分能减少页面卡顿. 后面应用场景章节将继续讨论.
Web Worker 属于 HTML 规范, 规范文档见 Web Workers Working Draft, 有兴趣的同学可以读一读. 而它并不是很新的技术, 如下图所示: 2009 年就提出了草案.
同年在 FireFox3.5 上率先实现, 可以在 using web workers: working smarter, not harder 中看到早期的实践. 2012 年发布的 IE10 也实现了 Web Worker, 标志着主流浏览器上的全面支持. IE10 的 Web Worker 能力测试如下图所示:
在预研 Worker 方案时, 开发人员会有兼容性顾虑. 这种顾虑的普遍存在, 主要由于业界 Worker 技术实践较少和社区推广不活跃. 单从发展历史看, Worker 从 2012 年起就广泛可用; 后面兼容性章节将继续讨论.
Web Worker 规范中包括: DedicatedWorker 和 SharedWorker; 规范并不包括 Service Worker, 本文也不会展开讨论.
如上图所示, DedicatedWorker 简称 Worker, 其线程只能与一个页面渲染进程 (Render Process) 进行绑定和通信, 不能多 Tab 共享. DedicatedWorker 是最早实现并最广泛支持的 Web Worker 能力.
而 SharedWorker 可以在多个浏览器 Tab 中访问到同一个 Worker 实例, 实现多 Tab 共享数据, 共享 webSocket 连接等. 看起来很美好, 但 safari 放弃了 SharedWorker 支持, 因为 webkit 引擎的技术原因. 如下图所示, 只在 safari 5\~6 中短暂支持过.
社区也在讨论 是否继续支持 SharedWorker; 多 Tab 共享资源的需求建议在 Service Worker 上寻找方案.
相比之下, DedicatedWorker 有着更广的兼容性和更多业务落地实践, 本文后面讨论中的 Worker 都是特指 DedicatedWorker.
用户使用浏览器一般会打开多个页面 (多 Tab), 现代浏览器使用单独的进程 (Render Process) 渲染每个页面, 以提升页面性能和稳定性, 并进行操作系统级别的内存隔离.
页面内, 内容渲染和用户交互主要由 Render Process 中的主线程进行管理. 主线程渲染页面每一帧 (Frame), 如下图所示, 会包含 5 个步骤: JavaScript → Style → Layout → Paint → Composite, 如果 JS 的执行修改了 DOM, 可能还会暂停 JS, 插入并执行 Style 和 Layout.
而我们熟知的 JS 单线程和 Event Loop, 是主线程的一部分. JS 单线程执行避免了多线程开发中的复杂场景 (如竞态和死锁). 但单线程的主要困扰是: 主线程同步 JS 执行耗时过久时 (浏览器理想帧间隔约 16ms), 会阻塞用户交互和页面渲染.
如上图所示, 长耗时任务执行时, 页面将无法更新, 也无法响应用户的输入/点击/滚动等操作. 如果卡死太久, 浏览器可能会抛出卡顿的提示. 如下图所示.
Web Worker 会创建操作系统级别的线程.
The Worker interface spawns real OS-level threads. -- MDN
JS 多线程, 是有独立于主线程的 JS 运行环境. 如下图所示: Worker 线程有独立的内存空间, Message Queue, Event Loop, Call Stack 等, 线程间通过 postMessage 通信.
多个线程可以并发运行 JS. 熟悉 JS 异步编程的同学可能会说, setTimeout / Promise.all 不就是并发吗, 我写得可溜了.
setTimeout
Promise.all
JS 单线程中的" 并发", 准确来说是 Concurrent. 如下图所示, 运行时只有一个函数调用栈, 通过 Event Loop 实现不同 Task 的上下文切换 (Context Switch). 这些 Task 通过 BOM API 调起其他线程为主线程工作, 但回调函数代码逻辑依然由 JS 串行运行.
Concurrent
Web Worker 是 JS 多线程运行技术, 准确来说是 Parallel. 其与 Concurrent 的区别如下图所示: Parallel 有多个函数调用栈, 每个函数调用栈可以独立运行 Task, 互不干扰.
Parallel
讨论完主线程和多线程, 我们能更好地理解 Worker 多线程的应用场景:
根据 Chrome 团队提出的用户感知性能模型 RAIL, 同步 JS 执行时间不能过长. 量化来说, 播放动画时建议小于 16ms, 用户操作响应建议小于 100ms, 页面打开到开始呈现内容建议小于 1000ms.
减少主线程卡顿的主要方法为异步化执行, 比如播放动画时, 将同步任务拆分为多个小于 16ms 的子任务, 然后在页面每一帧前通过 requestAnimationFrame 按计划执行一个子任务, 直到全部子任务执行完毕.
requestAnimationFrame
拆分同步逻辑的异步方案对大部分场景有效果, 但并不是一劳永逸的银弹. 有以下几个问题:
不是所有 JS 逻辑都可拆分. 比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务.
可以拆分的逻辑难以把控粒度. 如下图所示, 拆分的子任务在高性能机器 (iphoneX) 上可以控制在 16ms 内, 但在性能落后机器 (iphone6) 上就超过了 deadline. 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化, Google 给出的建议是再拆小到 3-4ms.
Worker 的多线程能力, 使得同步 JS 任务的拆分一步到位: 从宏观上将整个同步 JS 任务异步化. 不需要再去苦苦寻找原子逻辑, 逻辑异步化的设计上也更加简单和可维护.
这给我们带来更多的想象空间. 如下图所示, 在浏览器主线程渲染周期内, 将可能阻塞页面渲染的 JS 运行任务 (Jank Job) 迁移到 Worker 线程中, 进而减少主线程的负担, 缩短渲染间隔, 减少页面卡顿.
Worker 多线程并不会直接带来计算性能的提升, 能否提升与设备 CPU 核数和线程策略有关.
CPU 的单核 (Single Core) 和多核 (Multi Core) 离前端似乎有点远了. 但在页面上运用多线程技术时, 核数会影响线程创建策略.
进程是操作系统资源分配的基本单位,线程是操作系统调度 CPU 的基本单位. 操作系统对线程能占用的 CPU 计算资源有复杂的分配策略. 如下图所示:
一台设备上相同任务在各线程中运行耗时是一样的. 如下图所示: 我们将主线程 JS 任务交给新建的 Worker 线程, 任务在 Worker 线程上运行并不会比原本主线程更快, 而线程新建消耗和通信开销使得渲染间隔可能变得更久.
在单核机器上, 计算资源是内卷的, 新建的 Worker 线程并不能为页面争取到更多的计算资源. 在多核机器上, 新建的 Worker 线程和主线程都能做运算, 页面总计算资源增多, 但对单次任务来说, 在哪个线程上运行耗时是一样的.
真正带来性能提升的是多核多线程并发.
如多个没有依赖关系的同步任务, 在单线程上只能串行执行, 在多核多线程中可以并行执行. 如下图 alloy-worker 的图像处理 demo 所示, 在 iMac 上运行时创建了 6 条 Worker 线程, 图像处理总时间比主线程串行处理快了约 2000ms.
值得注意的是, 目前移动设备的核心数有限. 最新 iPhone Max Pro 上搭载的 A13 芯片 号称 6 核, 也只有 2 个高性能核芯 (2.61G), 另外 4 个是低频率的能效核心 (0.58G). 所以在创建多条 Worker 线程时, 建议区分场景和设备.
Worker 的应用场景, 本质上是从主线程中剥离逻辑, 让主线程专注于 UI 渲染. 这种架构设计并非 Web 技术上的独创.
Android 和 iOS 的原生开发中, 主线程负责 UI 工作; 前端领域热门的小程序, 实现原理上就是渲染和逻辑的完全分离.
本该如此.
如上图所示的 Worker 通信流程, Worker 通信 API 非常简单. 通俗中文教程可以参考 Web Worker 使用教程. 使用细节建议看官方文档.
双向通信示例代码如下图所示, 双向通信只需 7 行代码.
主要流程为:
new Worker(url)
url
postMessage
hello
onmesssage
onmessage
world
postMessage 会在接收线程创建一个 MessageEvent, 传递的数据添加到 event.data, 再触发该事件; MessageEvent 的回调函数进入 Message Queue, 成为待执行的宏任务. 因此 postMessage 顺序发送的信息, 在接收线程中会顺序执行回调函数. 而且我们无需担心实例化 Worker 过程中 postMessage 的信息丢失问题, 对此 Worker 内部机制已经处理.
event.data
Worker 事件驱动 (postMessage/onmessage) 的通信 API 虽然简洁, 但大多数场景下通信需要等待响应 (类似 HTTP 请求的 Request 和 Response), 并且多次同类型通信要匹配到各自的响应. 所以业务使用一般会封装原生 API, 如封装为 Promise 调用. 这也是笔者开发 alloy-worker 的原由之一.
在 Worker 线程中运行 JS, 会创建独立于主线程的 JS 运行环境, 称之为 DedicatedWorkerGlobalScope. 开发者需关注 Worker 环境和主线程环境的异同, 以及 Worker 在不同浏览器上的差异.
Worker 是无 UI 的线程, 无法调用 UI 相关的 DOM/BOM API. Worker 具体支持的 API 可参考 MDN 的 functions and classes available to workers.
上图展示了 Worker 线程与主线程的异同点. Worker 运行环境与主线程的共同点主要包括:
Navigator.userAgent
从共同点上看, Worker 线程其实很强大, 除了利用独立线程执行重度逻辑外, 其网络 I/O 和文件 I/O 能力给业务和技术方案带来很大的想象空间.
另一方面, Worker 线程运行环境和主线程的差异点有:
self.close
从差异点上看, Worker 线程无法染指 UI, 并受主线程控制, 适合默默干活.
各家浏览器实现 Worker 规范有差异, 对比主线程, 部分 API 功能不完备, 如:
好在这种场景并不多. 并且可以在运行时通过错误监控发现问题, 并定位和修复 (polyfill).
另一方面, 一些新增的 HTML 规范 API 只在较新的浏览器上实现, Worker 运行环境甚至主线程上没有, 使用 Worker 时需判断和兼容.
Worker 线程不支持 DOM, 这点和 Node.js 非常像. 我们在 Node.js 上做前后端同构的 SSR 时, 经常会遇到调用 BOM/DOM API 导致的报错. 如下图所示:
在开发 Worker 前端项目或迁移已有业务代码到 Worker 中时, 同构代码比例可能很高, 容易调到 BOM/DOM API. 可以通过构建变量区分代码逻辑, 或运行时动态判断所在线程, 实现同构代码在不同线程环境下运行.
Worker 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销. 如下图所示, 从线程 A 调用 postMessage 发送数据到线程 B onmessage 接收到数据有时间差, 这段时间差称为通信消耗.
提升的性能 = 并行提升的性能 – 通信消耗的性能. 在线程计算能力固定的情况下, 要通过多线程提升更多性能, 需要尽量减少通信消耗.
提升的性能 = 并行提升的性能 – 通信消耗的性能
而且主线程 postMessage 会占用主线程同步执行, 占用时间与数据传输方式和数据规模相关. 要避免多线程通信导致的主线程卡顿, 需选择合适的传输方式, 并控制每个渲染周期内的数据传输规模.
我们先来聊聊主线程和 Worker 线程的数据传输方式. 根据计算机进程模型, 主线程和 Worker 线程属于同一进程, 可以访问和操作进程的内存空间. 但为了降低多线程并发的逻辑复杂度, 部分传输方式直接隔离了线程间的内存, 相当于默认加了锁.
通信方式有 3 种: Structured Clone, Transfer Memory 和 Shared Array Buffer.
Structured Clone 是 postMessage 默认的通信方式. 如下图所示, 复制一份线程 A 的 JS Object 内存给到线程 B, 线程 B 能获取和操作新复制的内存.
Structured Clone 通过复制内存的方式简单有效地隔离不同线程内存, 避免冲突; 且传输的 Object 数据结构很灵活. 但复制过程中, 线程 A 要同步执行 Object Serialization, 线程 B 要同步执行 Object Deserialization; 如果 Object 规模过大, 会占用大量的线程时间.
Transfer Memory 意为转移内存, 它不需要 Serialization/Deserialization, 能大大减少传输过程占用的线程时间. 如下图所示 , 线程 A 将指定内存的所有权和操作权转给线程 B, 但转让后线程 A 无法再访问这块内存.
Transfer Memory 以失去控制权来换取高效传输, 通过内存独占给多线程并发加锁. 但只能转让 ArrayBuffer 等大小规整的二进制 (Raw Binary) 数据; 对矩阵数据 (如 RGB 图片) 比较适用. 实践上也要考虑从 JS Object 生成二进制数据的运算成本.
Shared Array Buffer 是共享内存, 线程 A 和线程 B 可以同时访问和操作同一块内存空间. 数据都共享了, 也就没有传输什么事了.
但多个并行的线程共享内存, 会产生竞争问题 (Race Conditions). 不像前 2 种传输方式默认加锁, Shared Array Buffers 把难题抛给开发者, 开发者可以用 Atomics 来维护这块共享的内存. 作为较新的传输方式, 浏览器兼容性可想而知, 目前只有 Chrome 68+ 支持.
使用 Structured Clone 传输数据时, 有个阴影一直笼罩着我们: postMessage 前要不要对数据 JSON.stringify 一把, 听说那样更快\?
2016 年的 High-performance Web Worker messages 进行了测试, 确实如此. 但是文章的测试结果也只能停留在 2016 年. 2019 年 Surma 进行新的测试: 如下图所示, 横轴上相同的数据规模, 直接 postMessage 的传输时间普遍比 JSON.stringify 更少.
2020 年的当下, 不需要再使用 JSON.stringify. 其一是 Structured Clone 内置的 serialize/deserialize 比 JSON.stringify 性能更高; 其二是 JSON.stringify 只适合序列化基本数据类型, 而 Structured Clone 还支持复制其他内置数据类型 (如 Map, Blob, RegExp 等, 虽然大部分应用场景只用到基本数据类型).
我们再来聊聊 Structured Clone 的数据传输规模. Structured Clone 的 serialize/deserialize 执行耗时主要受数据对象复杂度影响, 这很好理解, 因为 serialize/deserialize 至少要以某种方式遍历对象. 数据对象的复杂度本身难以度量, 可以用序列化后的数据规模 (size) 作为参考.
2015 年的 How fast are web workers 在中等性能手机上进行了测试: postMessage 发送数组的通信速率为 80KB/ms, 相当于理想渲染周期 (16ms) 内发送 1300KB.
2019 年 Surma 对 postMessage 的数据传输能力进行了更深入研究, 具体见 Is postMessage slow. 高性能机器 (macbook) 上的测试结果如下图所示:
其中:
低性能机器 (nokia2) 上的测试结果如下图所示:
不管用户侧的机器性能如何, 用户对流畅的感受是一致的: 前端同学的老朋友 16ms 和 100ms. Surma 兼顾低性能机型上 postMessage 容易造成主线程卡顿, 提出的数据传输规模建议是:
笔者认为, Surma 给出的建议偏保守, 传输规模可以再大一些.
总之, 数据传输规模并没有最佳实践. 而是充分理解 Worker postMessage 的传输成本, 在实际应用中, 根据业务场景去评估和控制数据规模.
兼容性是前端技术方案评估中需要关注的问题. 对 Web Worker 更是如此, 因为 Worker 的多线程能力, 要么业务场景完全用不上; 要么一用就是重度依赖的基础能力.
从前文 Worker 的历史和 兼容性视图 上看, Worker 的兼容性应该挺好的.
如上图所示, 主流浏览器在几年前就支持 Worker.
PC 端:
移动端:
使用 Worker 并不是一锤子买卖, 我们不止关注浏览器 Worker 能力的有或没有; 也关注 Worker 能力是否完备可用. 为此笔者设计了以下几个指标来评估 Worker 可用性:
window.Worker
new Worker()
有了可用性评估指标, 就可以给出量化的兼容性统计数据. 你将看到的, 是开放社区上唯一一份量化数据, 2019\~2020 年某大型前端项目 (亿级 MAU) 的统计结果 (By AlloyTeam alloy-worker).
可见当下浏览器已经较好地支持 Worker, 只要对 0.09\% 的不支持浏览器做好回退策略(如展示一个 tip), Worker 可以放心地应用到前端业务中.
前端工程师对 Worker 多线程开发方式比较陌生, 对开发中的 Worker 代码调试也是如此. 本章以 Chrome 和 IE10 为例简单介绍调试工具用法. 示例页面为 https://alloyteam.github.io/alloy-worker, 感兴趣的同学可以打开页面调试一把.
Chrome 已完善支持 Worker 代码调试, 开发者面板中的调试方式与主线程 JS 一致.
Console Panel 中可以查看页面全部的 JS 运行环境, 并通过下拉框切换调试的当前环境. 如下图所示, 其中 top 表示主线程的 JS 运行环境, alloyWorker--test 表示 Worker 线程的 JS 运行环境.
top
alloyWorker--test
切换到 alloyWorker--test 后, 就可以在 Worker 运行环境中执行调试代码. 如下图所示, Worker 环境的全局对象为 self, 类型为 DedicatedWorkerGlobalScope.
self
DedicatedWorkerGlobalScope
Worker 断点调试方式和主线程一致: 源码中添加 debugger 标识的代码位置会作为断点. 在 Sources Panel 查看页面源码时, 如下图所示, 左侧面板展示 Worker 线程的 alloy-worker.js 资源; 运行到 Worker 线程断点时, 右侧的 Threads 提示所在的运行环境是名为 alloyWorker--test 的 Worker 线程.
debugger
alloy-worker.js
Threads
使用 Performance Panel 的录制功能即可. 如下图红框所示, Performance 中也记录了 Worker 线程的运行情况.
Worker 的使用场景偏向数据和运算, 开发中适时回顾 Worker 线程的内存占用, 避免内存泄露干扰整个 Render Process. 如下图所示, 在 Memory Panel 中 alloyWorker-test 线程占用的内存为 1.2M.
alloyWorker-test
在比较极端的情况下, 我们需要到 IE10 这种老旧的浏览器上定位代码兼容性问题. 好在 IE10 也支持 Worker 源码调试. 可以参考微软官方文档, 具体步骤为:
F12
Start debugging
跨线程通信数据流是开发和调试中比较复杂的部分. 因为页面上可能有多个 Worker 实例; Worker 实例上有不同的数据类型 (payload); 而且相同类型的通信可能会多次发起.
通过 onmessage 回调打 log 调试数据流时, 建议添加当前 Worker 实例名称, 通信类型, 通信负载等信息. 以 alloy-worker 调试模式的 log 为例:
如上图所示:
现代化前端开发都采用模块化的方式组织代码, 使用 Web Worker 需将模块源码构建为单一资源 (worker.js). 另一方面, Worker 原生的 postMessage/onmessage 通信 API 在使用上并不顺手, 复杂场景下往往需要进行通信封装和数据约定.
worker.js
postMessage/onmessage
因此, 开源社区提供了相关的配套工具, 主解决 2 个关键问题:
下面介绍社区的一些主要工具, star 数统计时间为 2020.06.
Webpack 官方的 Worker loader. 负责将 Worker 源码打包为单个 chunk; chunk 可以是独立文件, 或 inline 的 Blob 资源. 输出内嵌 new Worker() 的 function, 通过调用该 function 实例化 Worker.
但 worker-loader 没有提供构建后的 Worker 资源 url, 上层业务进行定制有困难. 已有相关 issue 讨论该问题; worker-loader 也不对通信方式做额外处理.
GoogleChromeLabs 提供的 Webpack 构建 plugin.
作为 plugin, 支持 Worker 和 SharedWorker 的构建. 无需入侵源码, 通过解析源码中 new Worker 和 new SharedWorker 语法, 自动完成 JS 资源的构建打包. 也提供 loader 功能: 打包资源并且返回资源 url, 这点比 worker-loader 有优势.
new Worker
new SharedWorker
也来自 GoogleChromeLabs 团队, 由 Surma 开发. 基于 ES6 的 Proxy 能力, 对 postMessage 进行 RPC (Remote Procedure Call) 封装, 将跨线程的函数调用封装为 Promise 调用.
但它不涉及 Worker 资源构建打包, 需要其他配套工具. 且 Proxy 在部分浏览器中需要 polyfill, 可 polyfill 程度存疑.
目前社区比较完整, 且兼容性好的方案.
类似 worker-loader + comlink 的合体. 但不是基于 Proxy, 而在构建时根据源码 AST 提取出调用函数名称, 在另一线程内置同名函数; 封装跨线程函数为 RPC 调用.
与 workerize-loader 关联的另一个项目是 workerize (3.8k star). 支持手写文本函数, 内部封装为 RPC; 但手写文本函数实用性不强.
很有趣的项目, 将 Worker 封装为 React Hook. 基本原理是: 将传入 Hook 的函数处理为 BlobUrl 去实例化 Worker. 因为会把函数转为 BlobUrl 的字符串形式, 限制了函数不能有外部依赖, 函数体中也不能调用其他函数.
BlobUrl
比较适合一次性使用的纯函数, 函数复杂度受限.
现有的社区工具解决了 Worker 技术应用上的一些难点, 但目前还有些不足:
以上不足促使笔者开源了 alloy-worker, 面向事务的高可用 Web Worker 通信框架. 更加详细的工具讨论, 请查阅 alloy-worker 的业界方案对比.
2010 年, 文章 The Basics of Web Workers 列举的 Worker 可用场景如下:
2010 年的应用场景主要涉及数据处理, 文本处理, 图像/视频处理, 网络处理等.
2018 年, 文章 Parallel programming in JavaScript using Web Workers 列举的 Worker 可用场景如下:
可见, 近年来 Worker 的场景比 2010 年更丰富, 拓展到了 Canvas drawing(离屏渲染方面), Virtual DOM diffing(前端框架方面), indexedDB(本地存储方面), Webassembly(编译型语言方面) 等.
总的来说, Worker 对页面的计算任务/后台任务有用武之地. 接下来笔者将分享的一些具体 case, 并进行简析.
2017 年的文章, 非常好的实践. 在线表格排序是 CPU 密集型场景, 复杂任务原子化和异步化后依然难以消除页面卡顿. 将排序迁移到 Worker 后, 对 2500 行数据的排序操作, Scripting 时间从 9984ms 减少到 3650ms .
2020 年的文章, 使用生动的图例说明 TF.js 在主线程运行造成的掉帧. 以实时摄像头视频的动作检测为例子, 通过 Worker 实现视频动画不卡顿 (16ms 内); 动作检测耗时 50ms, 但是不阻塞视频, 也有约 15FPS.
2019 年开源的 Worker 驱动前端框架. 其将前端框架的拆分为 3 个 Worker: App Worker, Data Worker 和 Vdom Worker. 主线程只需要维护 DOM 和代理 DOM 事件到 App Worker 中; Data Worker 负责进行后台请求和托管数据 store; Vdom Worker 将模板字符串转换为虚拟节点, 并对每次变化生成增量去更新.
Google AMP 项目一部分. 在 Worker 中实现 DOM 操作 API 和 DOM 事件监听, 并将 DOM 变化应用到主线程真实 DOM 上. 官方 Demo 在 Worker 中直接引入 React 并实现 render!
Angular8 CLI 支持创建 Web Worker 指令, 并将耗 CPU 计算迁移到 Worker 中; 但是 Angular 本身并不能在 Worker 中运行. 官网 angular.io 也用 Worker 来提升搜索性能.
2019 年的文章. 将 Redux 的 action 部分迁移到 Worker 中, 开源了项目 redux-in-worker. 做了 Worker Redux 的 benchmark: 和主线程相差不大 (但是不卡了).
action
2019 年的文章. 简单分析 UI 线程过载和 Worker 并发能力. 对 Vue 数据流框架 Vuex 进行分解, 发现 action 可以包含异步操作, 适合迁移到 Worker. 实现了 action 的封装函数和质数生成的 demo.
PROXX 是 GoogleChromeLabs 开发的在线扫雷游戏, 其 Worker 能力由 Surma 开发的 Comlink 提供. Surma 特地开发了 Worker 版本和非 Worker 版本: 在高性能机型 Pixel3 和 MacBook 上, 两者差异不大; 但在低性能机型 Nokia2 上, 非 Worker 版本点击动作卡了 6.6s, Worker 版本点击回调需要 48ms.
2013 年的文章. 使用 Worker 将图片处理为复古色调. 在当年先进的 12 核机器上, 使用 4 个 Worker 线程后, 处理时间从 150ms 减低到 80ms; 在当年的双核机器上, 处理时间从 900ms 减低到 500ms.
2020 的文章. 基于 OpenCV 项目, 将项目编译为 webassembly, 并且在 Worker 中动态加载 opencv.js, 实现了图片的灰度处理.
Chrome69+ 支持, 能将主线程 Canvas 的绘制权 transfer 给 Worker 线程的 OffscreenCanvas, 在 Worker 中绘制后渲染直接到页面上; 也支持在 Worker 中新建 Canvas 绘制图形, 通过 imagebitmap transfer 到主线程展示.
hls 是基于 JS 实现的 HTTP 实时流媒体播放库. 其使用 Worker 用于流数据的解复用 (demuxer), 使用 Transfer Memory 来最小化传输的消耗.
判断浏览器是否支持 Worker 能力, 有 Worker 能力时将 pdf 文件解析 ( parsed and interpreted) 全部放在 Worker 线程中; Worker 能力不完备则在主线程运行.
2016 年的分享 ppt, Pokedex.org 项目在 Web Worker 中进行 Virtual DOM 的更新, 显著提升快速滚动下的渲染效率.
Chrome Dev Summit 2019, 非常精彩的分享, 来自 google 的工程师 Surma. 演讲指出页面主线程工作量过大, 特别是发展中国家有大量的低性能设备. 运算在 Worker 慢一点但页面不掉帧优于运算在主线程快一点但卡顿.
同样来自 Surma 的技术访谈. 主要讨论 postMessage 的性能问题. 本文在通信速度部分大量引用 Surma 的研究.
Surma 在 Worker 领域写了多篇文章, 并开源了 Comlink.
2019 年的演讲, 笔者前同事, 曾在 Worker 实践上紧密合作. 演讲讨论 Web Worker 的使用场景; Worker 的注意点和适应多线程的代码改造; 以及实践中遇到的问题和解决方案.
2019 年的演讲, 来自 Netflix 的工程师. 总结使用 Web Worker 遇到的 4 大问题, 并通过引入社区多个配套工具逐一解决.
2018 年的演讲, 讲多线程和 postMessage 数据传递部分图很漂亮. 将 Web Worker 应用在他开发的 Web 钢琴弹奏器.
2014 年的演讲, 使用生动的图例介绍主线程 Event Loop.
如上文所述, 社区已有许多 Worker 技术的应用实践. 如果你的业务也有使用 Worker 的需求, 以下是几个实践的建议.
使用 Worker 是有成本的: Worker 线程会占用系统资源; 同构代码和异步通信会增加维护成本; 多线程编程会挑战前端仔的思维.
David 的文章指出, 迫切需要 Worker 的场景并不多, 开发者需要考虑投入效益比. 简单来说, 如果页面的某个操作会耗时, 同时不想让用户察觉 (转菊花), 那就用 Worker 吧.
虽然 Worker 规范提供了 terminate API 来结束 Worker 线程, 但线程的频繁新建会消耗资源. 大多数场景下, Worker 线程应该用作常驻的线程. 开发中优先复用常驻线程.
terminate
这也很好理解, Worker 线程在争取 CPU 计算资源时, 受限于 CPU 的核心数, 过多的线程并不能线性地提升性能, 而每个 Worker 线程会有约 1M 的固有内存消耗.
多线程开发的思维和方式, 是个比较大的话题. 开发者需要控制线程间的通信规模, 减少线程间数据和状态的依赖, 尝试去了解和控制 Worker 线程.
本文试图梳理 2020 年当下 Web Worker 技术的现状和发展.
从现状上看, Worker 已经普遍可用, 业界也有业务和框架上的实践, 但在配套工具上仍有不足.
从发展趋势上看, Worker 的多线程能力有望成为复杂前端项目的标配, 在减少 UI 线程卡顿和压榨计算机性能上有收益. 但目前国内实践较少, 一方面是业务复杂程度未触及; 另一方面是社区缺少科普和实践分享.
前端多线程开发正当时. 笔者维护的 Worker 通信框架 alloy-worker 已经开源, 大型前端项目落地的文章正在路上. 鸡汤和勺子都给了, 加点老干妈, 真香!
不错 才看到
作者:TAT. cntchen
http://www.alloyteam.com/2020/07/14680/
Web Worker 文献综述
背景
文献综述
文献综述
(Literature Review) 是学术研究领域一个常见概念, 写过毕业论文的同学应该还有印象. 它向读者介绍与主题有关的详细资料、动态、进展、展望以及对以上方面的评述.近期笔者关注 Web Worker, 并落地到了大型复杂前端项目. 开源了 Worker 通信框架 alloy-worker, 正在写实践总结文章. 其间查阅了相关资料 (50+文章, 10+技术演讲), 独立写成这篇综述性文章.
主要内容
发展历史
简介
前端同学对 Web Worker 应该不陌生, 即使没有动手实践过, 应该也在社区上看过相关文章. 在介绍和使用上, 官方文档是 MDN 的 Web Workers API. 其对 Web Worker 的表述是:
如下图所示, Web Worker 实现了多线程运行 JS 能力. 之前页面更新要先串行 (Serial) 做 2 件事情; 使用 Worker 后, 2 件事情可并行 (Parallel) 完成.
可以直观地联想: 并行可能会提升执行效率; 运行任务拆分能减少页面卡顿. 后面应用场景章节将继续讨论.
技术规范
Web Worker 属于 HTML 规范, 规范文档见 Web Workers Working Draft, 有兴趣的同学可以读一读. 而它并不是很新的技术, 如下图所示: 2009 年就提出了草案.
同年在 FireFox3.5 上率先实现, 可以在 using web workers: working smarter, not harder 中看到早期的实践. 2012 年发布的 IE10 也实现了 Web Worker, 标志着主流浏览器上的全面支持. IE10 的 Web Worker 能力测试如下图所示:
在预研 Worker 方案时, 开发人员会有兼容性顾虑. 这种顾虑的普遍存在, 主要由于业界 Worker 技术实践较少和社区推广不活跃. 单从发展历史看, Worker 从 2012 年起就广泛可用; 后面兼容性章节将继续讨论.
DedicatedWorker 和 SharedWorker
Web Worker 规范中包括: DedicatedWorker 和 SharedWorker; 规范并不包括 Service Worker, 本文也不会展开讨论.
如上图所示, DedicatedWorker 简称 Worker, 其线程只能与一个页面渲染进程 (Render Process) 进行绑定和通信, 不能多 Tab 共享. DedicatedWorker 是最早实现并最广泛支持的 Web Worker 能力.
而 SharedWorker 可以在多个浏览器 Tab 中访问到同一个 Worker 实例, 实现多 Tab 共享数据, 共享 webSocket 连接等. 看起来很美好, 但 safari 放弃了 SharedWorker 支持, 因为 webkit 引擎的技术原因. 如下图所示, 只在 safari 5\~6 中短暂支持过.
社区也在讨论 是否继续支持 SharedWorker; 多 Tab 共享资源的需求建议在 Service Worker 上寻找方案.
相比之下, DedicatedWorker 有着更广的兼容性和更多业务落地实践, 本文后面讨论中的 Worker 都是特指 DedicatedWorker.
主线程和多线程
用户使用浏览器一般会打开多个页面 (多 Tab), 现代浏览器使用单独的进程 (Render Process) 渲染每个页面, 以提升页面性能和稳定性, 并进行操作系统级别的内存隔离.
主线程 (Main Thread)
页面内, 内容渲染和用户交互主要由 Render Process 中的主线程进行管理. 主线程渲染页面每一帧 (Frame), 如下图所示, 会包含 5 个步骤: JavaScript → Style → Layout → Paint → Composite, 如果 JS 的执行修改了 DOM, 可能还会暂停 JS, 插入并执行 Style 和 Layout.
而我们熟知的 JS 单线程和 Event Loop, 是主线程的一部分. JS 单线程执行避免了多线程开发中的复杂场景 (如竞态和死锁). 但单线程的主要困扰是: 主线程同步 JS 执行耗时过久时 (浏览器理想帧间隔约 16ms), 会阻塞用户交互和页面渲染.
如上图所示, 长耗时任务执行时, 页面将无法更新, 也无法响应用户的输入/点击/滚动等操作. 如果卡死太久, 浏览器可能会抛出卡顿的提示. 如下图所示.
多线程
Web Worker 会创建操作系统级别的线程.
JS 多线程, 是有独立于主线程的 JS 运行环境. 如下图所示: Worker 线程有独立的内存空间, Message Queue, Event Loop, Call Stack 等, 线程间通过 postMessage 通信.
多个线程可以并发运行 JS. 熟悉 JS 异步编程的同学可能会说,
setTimeout
/Promise.all
不就是并发吗, 我写得可溜了.JS 单线程中的" 并发", 准确来说是
Concurrent
. 如下图所示, 运行时只有一个函数调用栈, 通过 Event Loop 实现不同 Task 的上下文切换 (Context Switch). 这些 Task 通过 BOM API 调起其他线程为主线程工作, 但回调函数代码逻辑依然由 JS 串行运行.Web Worker 是 JS 多线程运行技术, 准确来说是
Parallel
. 其与Concurrent
的区别如下图所示: Parallel 有多个函数调用栈, 每个函数调用栈可以独立运行 Task, 互不干扰.应用场景
讨论完主线程和多线程, 我们能更好地理解 Worker 多线程的应用场景:
减少卡顿
根据 Chrome 团队提出的用户感知性能模型 RAIL, 同步 JS 执行时间不能过长. 量化来说, 播放动画时建议小于 16ms, 用户操作响应建议小于 100ms, 页面打开到开始呈现内容建议小于 1000ms.
逻辑异步化
减少主线程卡顿的主要方法为异步化执行, 比如播放动画时, 将同步任务拆分为多个小于 16ms 的子任务, 然后在页面每一帧前通过
requestAnimationFrame
按计划执行一个子任务, 直到全部子任务执行完毕.拆分同步逻辑的异步方案对大部分场景有效果, 但并不是一劳永逸的银弹. 有以下几个问题:
不是所有 JS 逻辑都可拆分. 比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务.
可以拆分的逻辑难以把控粒度. 如下图所示, 拆分的子任务在高性能机器 (iphoneX) 上可以控制在 16ms 内, 但在性能落后机器 (iphone6) 上就超过了 deadline. 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化, Google 给出的建议是再拆小到 3-4ms.
Worker 一步到位
Worker 的多线程能力, 使得同步 JS 任务的拆分一步到位: 从宏观上将整个同步 JS 任务异步化. 不需要再去苦苦寻找原子逻辑, 逻辑异步化的设计上也更加简单和可维护.
这给我们带来更多的想象空间. 如下图所示, 在浏览器主线程渲染周期内, 将可能阻塞页面渲染的 JS 运行任务 (Jank Job) 迁移到 Worker 线程中, 进而减少主线程的负担, 缩短渲染间隔, 减少页面卡顿.
性能提升
Worker 多线程并不会直接带来计算性能的提升, 能否提升与设备 CPU 核数和线程策略有关.
多线程与 CPU 核数
CPU 的单核 (Single Core) 和多核 (Multi Core) 离前端似乎有点远了. 但在页面上运用多线程技术时, 核数会影响线程创建策略.
进程是操作系统资源分配的基本单位,线程是操作系统调度 CPU 的基本单位. 操作系统对线程能占用的 CPU 计算资源有复杂的分配策略. 如下图所示:
Worker 线程策略
一台设备上相同任务在各线程中运行耗时是一样的. 如下图所示: 我们将主线程 JS 任务交给新建的 Worker 线程, 任务在 Worker 线程上运行并不会比原本主线程更快, 而线程新建消耗和通信开销使得渲染间隔可能变得更久.
在单核机器上, 计算资源是内卷的, 新建的 Worker 线程并不能为页面争取到更多的计算资源. 在多核机器上, 新建的 Worker 线程和主线程都能做运算, 页面总计算资源增多, 但对单次任务来说, 在哪个线程上运行耗时是一样的.
真正带来性能提升的是多核多线程并发.
如多个没有依赖关系的同步任务, 在单线程上只能串行执行, 在多核多线程中可以并行执行. 如下图 alloy-worker 的图像处理 demo 所示, 在 iMac 上运行时创建了 6 条 Worker 线程, 图像处理总时间比主线程串行处理快了约 2000ms.
值得注意的是, 目前移动设备的核心数有限. 最新 iPhone Max Pro 上搭载的 A13 芯片 号称 6 核, 也只有 2 个高性能核芯 (2.61G), 另外 4 个是低频率的能效核心 (0.58G). 所以在创建多条 Worker 线程时, 建议区分场景和设备.
把主线程还给 UI
Worker 的应用场景, 本质上是从主线程中剥离逻辑, 让主线程专注于 UI 渲染. 这种架构设计并非 Web 技术上的独创.
Android 和 iOS 的原生开发中, 主线程负责 UI 工作; 前端领域热门的小程序, 实现原理上就是渲染和逻辑的完全分离.
本该如此.
Worker API
通信 API
如上图所示的 Worker 通信流程, Worker 通信 API 非常简单. 通俗中文教程可以参考 Web Worker 使用教程. 使用细节建议看官方文档.
双向通信示例代码如下图所示, 双向通信只需 7 行代码.
主要流程为:
new Worker(url)
创建 Worker 实例,url
为 Worker JS 资源 url.postMessage
发送hello
, 在onmesssage
中监听 Worker 线程消息.onmessage
中监听主线程消息, 收到主线程的hello
; 通过postMessage
回复world
.world
信息.postMessage 会在接收线程创建一个 MessageEvent, 传递的数据添加到
event.data
, 再触发该事件; MessageEvent 的回调函数进入 Message Queue, 成为待执行的宏任务. 因此 postMessage 顺序发送的信息, 在接收线程中会顺序执行回调函数. 而且我们无需担心实例化 Worker 过程中 postMessage 的信息丢失问题, 对此 Worker 内部机制已经处理.Worker 事件驱动 (postMessage/onmessage) 的通信 API 虽然简洁, 但大多数场景下通信需要等待响应 (类似 HTTP 请求的 Request 和 Response), 并且多次同类型通信要匹配到各自的响应. 所以业务使用一般会封装原生 API, 如封装为 Promise 调用. 这也是笔者开发 alloy-worker 的原由之一.
运行环境
在 Worker 线程中运行 JS, 会创建独立于主线程的 JS 运行环境, 称之为 DedicatedWorkerGlobalScope. 开发者需关注 Worker 环境和主线程环境的异同, 以及 Worker 在不同浏览器上的差异.
Worker 环境和主线程环境的异同
Worker 是无 UI 的线程, 无法调用 UI 相关的 DOM/BOM API. Worker 具体支持的 API 可参考 MDN 的 functions and classes available to workers.
上图展示了 Worker 线程与主线程的异同点. Worker 运行环境与主线程的共同点主要包括:
Navigator.userAgent
识别浏览器.从共同点上看, Worker 线程其实很强大, 除了利用独立线程执行重度逻辑外, 其网络 I/O 和文件 I/O 能力给业务和技术方案带来很大的想象空间.
另一方面, Worker 线程运行环境和主线程的差异点有:
self.close
自行销毁.从差异点上看, Worker 线程无法染指 UI, 并受主线程控制, 适合默默干活.
Worker 在不同浏览器上的差异
各家浏览器实现 Worker 规范有差异, 对比主线程, 部分 API 功能不完备, 如:
好在这种场景并不多. 并且可以在运行时通过错误监控发现问题, 并定位和修复 (polyfill).
另一方面, 一些新增的 HTML 规范 API 只在较新的浏览器上实现, Worker 运行环境甚至主线程上没有, 使用 Worker 时需判断和兼容.
多线程同构代码
Worker 线程不支持 DOM, 这点和 Node.js 非常像. 我们在 Node.js 上做前后端同构的 SSR 时, 经常会遇到调用 BOM/DOM API 导致的报错. 如下图所示:
在开发 Worker 前端项目或迁移已有业务代码到 Worker 中时, 同构代码比例可能很高, 容易调到 BOM/DOM API. 可以通过构建变量区分代码逻辑, 或运行时动态判断所在线程, 实现同构代码在不同线程环境下运行.
通信速度
Worker 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销. 如下图所示, 从线程 A 调用 postMessage 发送数据到线程 B onmessage 接收到数据有时间差, 这段时间差称为通信消耗.
提升的性能 = 并行提升的性能 – 通信消耗的性能
. 在线程计算能力固定的情况下, 要通过多线程提升更多性能, 需要尽量减少通信消耗.而且主线程 postMessage 会占用主线程同步执行, 占用时间与数据传输方式和数据规模相关. 要避免多线程通信导致的主线程卡顿, 需选择合适的传输方式, 并控制每个渲染周期内的数据传输规模.
数据传输方式
我们先来聊聊主线程和 Worker 线程的数据传输方式. 根据计算机进程模型, 主线程和 Worker 线程属于同一进程, 可以访问和操作进程的内存空间. 但为了降低多线程并发的逻辑复杂度, 部分传输方式直接隔离了线程间的内存, 相当于默认加了锁.
通信方式有 3 种: Structured Clone, Transfer Memory 和 Shared Array Buffer.
Structured Clone
Structured Clone 是 postMessage 默认的通信方式. 如下图所示, 复制一份线程 A 的 JS Object 内存给到线程 B, 线程 B 能获取和操作新复制的内存.
Structured Clone 通过复制内存的方式简单有效地隔离不同线程内存, 避免冲突; 且传输的 Object 数据结构很灵活. 但复制过程中, 线程 A 要同步执行 Object Serialization, 线程 B 要同步执行 Object Deserialization; 如果 Object 规模过大, 会占用大量的线程时间.
Transfer Memory
Transfer Memory 意为转移内存, 它不需要 Serialization/Deserialization, 能大大减少传输过程占用的线程时间. 如下图所示 , 线程 A 将指定内存的所有权和操作权转给线程 B, 但转让后线程 A 无法再访问这块内存.
Transfer Memory 以失去控制权来换取高效传输, 通过内存独占给多线程并发加锁. 但只能转让 ArrayBuffer 等大小规整的二进制 (Raw Binary) 数据; 对矩阵数据 (如 RGB 图片) 比较适用. 实践上也要考虑从 JS Object 生成二进制数据的运算成本.
Shared Array Buffers
Shared Array Buffer 是共享内存, 线程 A 和线程 B 可以同时访问和操作同一块内存空间. 数据都共享了, 也就没有传输什么事了.
但多个并行的线程共享内存, 会产生竞争问题 (Race Conditions). 不像前 2 种传输方式默认加锁, Shared Array Buffers 把难题抛给开发者, 开发者可以用 Atomics 来维护这块共享的内存. 作为较新的传输方式, 浏览器兼容性可想而知, 目前只有 Chrome 68+ 支持.
传输方式小结
JSON.stringify 更快\?
使用 Structured Clone 传输数据时, 有个阴影一直笼罩着我们: postMessage 前要不要对数据 JSON.stringify 一把, 听说那样更快\?
2016 年的 High-performance Web Worker messages 进行了测试, 确实如此. 但是文章的测试结果也只能停留在 2016 年. 2019 年 Surma 进行新的测试: 如下图所示, 横轴上相同的数据规模, 直接 postMessage 的传输时间普遍比 JSON.stringify 更少.
2020 年的当下, 不需要再使用 JSON.stringify. 其一是 Structured Clone 内置的 serialize/deserialize 比 JSON.stringify 性能更高; 其二是 JSON.stringify 只适合序列化基本数据类型, 而 Structured Clone 还支持复制其他内置数据类型 (如 Map, Blob, RegExp 等, 虽然大部分应用场景只用到基本数据类型).
数据传输规模
我们再来聊聊 Structured Clone 的数据传输规模. Structured Clone 的 serialize/deserialize 执行耗时主要受数据对象复杂度影响, 这很好理解, 因为 serialize/deserialize 至少要以某种方式遍历对象. 数据对象的复杂度本身难以度量, 可以用序列化后的数据规模 (size) 作为参考.
2015 年的 How fast are web workers 在中等性能手机上进行了测试: postMessage 发送数组的通信速率为 80KB/ms, 相当于理想渲染周期 (16ms) 内发送 1300KB.
2019 年 Surma 对 postMessage 的数据传输能力进行了更深入研究, 具体见 Is postMessage slow. 高性能机器 (macbook) 上的测试结果如下图所示:
其中:
低性能机器 (nokia2) 上的测试结果如下图所示:
其中:
不管用户侧的机器性能如何, 用户对流畅的感受是一致的: 前端同学的老朋友 16ms 和 100ms. Surma 兼顾低性能机型上 postMessage 容易造成主线程卡顿, 提出的数据传输规模建议是:
笔者认为, Surma 给出的建议偏保守, 传输规模可以再大一些.
总之, 数据传输规模并没有最佳实践. 而是充分理解 Worker postMessage 的传输成本, 在实际应用中, 根据业务场景去评估和控制数据规模.
兼容性
兼容性是前端技术方案评估中需要关注的问题. 对 Web Worker 更是如此, 因为 Worker 的多线程能力, 要么业务场景完全用不上; 要么一用就是重度依赖的基础能力.
兼容性还不错
从前文 Worker 的历史和 兼容性视图 上看, Worker 的兼容性应该挺好的.
如上图所示, 主流浏览器在几年前就支持 Worker.
PC 端:
移动端:
可用性评估指标
使用 Worker 并不是一锤子买卖, 我们不止关注浏览器 Worker 能力的有或没有; 也关注 Worker 能力是否完备可用. 为此笔者设计了以下几个指标来评估 Worker 可用性:
window.Worker
来判断.new Worker()
是否报错来判断.统计数据
有了可用性评估指标, 就可以给出量化的兼容性统计数据. 你将看到的, 是开放社区上唯一一份量化数据, 2019\~2020 年某大型前端项目 (亿级 MAU) 的统计结果 (By AlloyTeam alloy-worker).
其中:
小结
可见当下浏览器已经较好地支持 Worker, 只要对 0.09\% 的不支持浏览器做好回退策略(如展示一个 tip), Worker 可以放心地应用到前端业务中.
调试工具用法
前端工程师对 Worker 多线程开发方式比较陌生, 对开发中的 Worker 代码调试也是如此. 本章以 Chrome 和 IE10 为例简单介绍调试工具用法. 示例页面为 https://alloyteam.github.io/alloy-worker, 感兴趣的同学可以打开页面调试一把.
Chrome 调试
Chrome 已完善支持 Worker 代码调试, 开发者面板中的调试方式与主线程 JS 一致.
Console 调试
Console Panel 中可以查看页面全部的 JS 运行环境, 并通过下拉框切换调试的当前环境. 如下图所示, 其中
top
表示主线程的 JS 运行环境,alloyWorker--test
表示 Worker 线程的 JS 运行环境.切换到
alloyWorker--test
后, 就可以在 Worker 运行环境中执行调试代码. 如下图所示, Worker 环境的全局对象为self
, 类型为DedicatedWorkerGlobalScope
.断点调试
Worker 断点调试方式和主线程一致: 源码中添加
debugger
标识的代码位置会作为断点. 在 Sources Panel 查看页面源码时, 如下图所示, 左侧面板展示 Worker 线程的alloy-worker.js
资源; 运行到 Worker 线程断点时, 右侧的Threads
提示所在的运行环境是名为alloyWorker--test
的 Worker 线程.性能调试
使用 Performance Panel 的录制功能即可. 如下图红框所示, Performance 中也记录了 Worker 线程的运行情况.
查看内存占用
Worker 的使用场景偏向数据和运算, 开发中适时回顾 Worker 线程的内存占用, 避免内存泄露干扰整个 Render Process. 如下图所示, 在 Memory Panel 中
alloyWorker-test
线程占用的内存为 1.2M.IE10 调试
在比较极端的情况下, 我们需要到 IE10 这种老旧的浏览器上定位代码兼容性问题. 好在 IE10 也支持 Worker 源码调试. 可以参考微软官方文档, 具体步骤为:
F12
打开调试工具, 在 Script Panel 中, 开始是看不到 Worker 线程源码的, 点击Start debugging
, 就能看到 Worker 线程的alloy-worker.js
源码.数据流调试
跨线程通信数据流是开发和调试中比较复杂的部分. 因为页面上可能有多个 Worker 实例; Worker 实例上有不同的数据类型 (payload); 而且相同类型的通信可能会多次发起.
通过 onmessage 回调打 log 调试数据流时, 建议添加当前 Worker 实例名称, 通信类型, 通信负载等信息. 以 alloy-worker 调试模式的 log 为例:
如上图所示:
社区配套工具
现代化前端开发都采用模块化的方式组织代码, 使用 Web Worker 需将模块源码构建为单一资源 (
worker.js
). 另一方面, Worker 原生的postMessage/onmessage
通信 API 在使用上并不顺手, 复杂场景下往往需要进行通信封装和数据约定.因此, 开源社区提供了相关的配套工具, 主解决 2 个关键问题:
下面介绍社区的一些主要工具, star 数统计时间为 2020.06.
worker-loader (1.1k star)
Webpack 官方的 Worker loader. 负责将 Worker 源码打包为单个 chunk; chunk 可以是独立文件, 或 inline 的 Blob 资源.
输出内嵌
new Worker()
的 function, 通过调用该 function 实例化 Worker.但 worker-loader 没有提供构建后的 Worker 资源 url, 上层业务进行定制有困难. 已有相关 issue 讨论该问题; worker-loader 也不对通信方式做额外处理.
worker-plugin (1.6k star)
GoogleChromeLabs 提供的 Webpack 构建 plugin.
作为 plugin, 支持 Worker 和 SharedWorker 的构建. 无需入侵源码, 通过解析源码中
new Worker
和new SharedWorker
语法, 自动完成 JS 资源的构建打包. 也提供 loader 功能: 打包资源并且返回资源 url, 这点比 worker-loader 有优势.comlink (6.2k star)
也来自 GoogleChromeLabs 团队, 由 Surma 开发. 基于 ES6 的 Proxy 能力, 对 postMessage 进行 RPC
(Remote Procedure Call) 封装, 将跨线程的函数调用封装为 Promise 调用.
但它不涉及 Worker 资源构建打包, 需要其他配套工具. 且 Proxy 在部分浏览器中需要 polyfill, 可 polyfill 程度存疑.
workerize-loader (1.7k star)
目前社区比较完整, 且兼容性好的方案.
类似 worker-loader + comlink 的合体. 但不是基于 Proxy, 而在构建时根据源码 AST 提取出调用函数名称, 在另一线程内置同名函数; 封装跨线程函数为 RPC 调用.
与 workerize-loader 关联的另一个项目是 workerize (3.8k star). 支持手写文本函数, 内部封装为 RPC; 但手写文本函数实用性不强.
userWorker (1.8k star)
很有趣的项目, 将 Worker 封装为 React Hook. 基本原理是: 将传入 Hook 的函数处理为
BlobUrl
去实例化 Worker. 因为会把函数转为BlobUrl
的字符串形式, 限制了函数不能有外部依赖, 函数体中也不能调用其他函数.比较适合一次性使用的纯函数, 函数复杂度受限.
其他可参考项目
现有工具缺陷
现有的社区工具解决了 Worker 技术应用上的一些难点, 但目前还有些不足:
以上不足促使笔者开源了 alloy-worker, 面向事务的高可用 Web Worker 通信框架.
更加详细的工具讨论, 请查阅 alloy-worker 的业界方案对比.
业界实践回顾
实践场景
Web Worker 作为浏览器多线程技术, 在页面内容不断丰富, 功能日趋复杂的当下, 成为缓解页面卡顿, 提升应用性能的可选方案.
2010 年
2010 年, 文章 The Basics of Web Workers 列举的 Worker 可用场景如下:
2010 年的应用场景主要涉及数据处理, 文本处理, 图像/视频处理, 网络处理等.
当下
2018 年, 文章 Parallel programming in JavaScript using Web Workers 列举的 Worker 可用场景如下:
可见, 近年来 Worker 的场景比 2010 年更丰富, 拓展到了 Canvas drawing(离屏渲染方面), Virtual DOM diffing(前端框架方面), indexedDB(本地存储方面), Webassembly(编译型语言方面) 等.
总的来说, Worker 对页面的计算任务/后台任务有用武之地. 接下来笔者将分享的一些具体 case, 并进行简析.
重度计算场景
石墨表格之 Web Worker 应用实战
2017 年的文章, 非常好的实践. 在线表格排序是 CPU 密集型场景, 复杂任务原子化和异步化后依然难以消除页面卡顿. 将排序迁移到 Worker 后, 对 2500 行数据的排序操作, Scripting 时间从 9984ms 减少到 3650ms .
Making TensorflowJS work faster with WebWorkers
2020 年的文章, 使用生动的图例说明 TF.js 在主线程运行造成的掉帧. 以实时摄像头视频的动作检测为例子, 通过 Worker 实现视频动画不卡顿 (16ms 内); 动作检测耗时 50ms, 但是不阻塞视频, 也有约 15FPS.
前端框架场景
neo -- webworkers driven UI framework
2019 年开源的 Worker 驱动前端框架. 其将前端框架的拆分为 3 个 Worker: App Worker, Data Worker 和 Vdom Worker. 主线程只需要维护 DOM 和代理 DOM 事件到 App Worker 中; Data Worker 负责进行后台请求和托管数据 store; Vdom Worker 将模板字符串转换为虚拟节点, 并对每次变化生成增量去更新.
worker-dom
Google AMP 项目一部分. 在 Worker 中实现 DOM 操作 API 和 DOM 事件监听, 并将 DOM 变化应用到主线程真实 DOM 上. 官方 Demo 在 Worker 中直接引入 React 并实现 render!
Angular
Angular8 CLI 支持创建 Web Worker 指令, 并将耗 CPU 计算迁移到 Worker 中; 但是 Angular 本身并不能在 Worker 中运行. 官网 angular.io 也用 Worker 来提升搜索性能.
数据流场景
Off-main-thread React Redux with Performance
2019 年的文章. 将 Redux 的
action
部分迁移到 Worker 中, 开源了项目 redux-in-worker.做了 Worker Redux 的 benchmark: 和主线程相差不大 (但是不卡了).
Off Main Thread Architecture with Vuex
2019 年的文章. 简单分析 UI 线程过载和 Worker 并发能力. 对 Vue 数据流框架 Vuex 进行分解, 发现
action
可以包含异步操作, 适合迁移到 Worker. 实现了 action 的封装函数和质数生成的 demo.可视化场景
PROXX
PROXX 是 GoogleChromeLabs 开发的在线扫雷游戏, 其 Worker 能力由 Surma 开发的 Comlink 提供. Surma 特地开发了 Worker 版本和非 Worker 版本: 在高性能机型 Pixel3 和 MacBook 上, 两者差异不大; 但在低性能机型 Nokia2 上, 非 Worker 版本点击动作卡了 6.6s, Worker 版本点击回调需要 48ms.
图片风格处理
2013 年的文章. 使用 Worker 将图片处理为复古色调. 在当年先进的 12 核机器上, 使用 4 个 Worker 线程后, 处理时间从 150ms 减低到 80ms; 在当年的双核机器上, 处理时间从 900ms 减低到 500ms.
OpenCV directly in the browser (webassembly + webworker)
2020 的文章. 基于 OpenCV 项目, 将项目编译为 webassembly, 并且在 Worker 中动态加载 opencv.js, 实现了图片的灰度处理.
大型项目
OffscreenCanvas
Chrome69+ 支持, 能将主线程 Canvas 的绘制权 transfer 给 Worker 线程的 OffscreenCanvas, 在 Worker 中绘制后渲染直接到页面上; 也支持在 Worker 中新建 Canvas 绘制图形, 通过 imagebitmap transfer 到主线程展示.
hls.js
hls 是基于 JS 实现的 HTTP 实时流媒体播放库. 其使用 Worker 用于流数据的解复用 (demuxer), 使用 Transfer Memory 来最小化传输的消耗.
pdf.js
判断浏览器是否支持 Worker 能力, 有 Worker 能力时将 pdf 文件解析 ( parsed and interpreted) 全部放在 Worker 线程中; Worker 能力不完备则在主线程运行.
相关视频/分享 PPT
Web Workers -- I like the way you work it
2016 年的分享 ppt, Pokedex.org 项目在 Web Worker 中进行 Virtual DOM 的更新, 显著提升快速滚动下的渲染效率.
The main thread is overworked \& underpaid
Chrome Dev Summit 2019, 非常精彩的分享, 来自 google 的工程师 Surma. 演讲指出页面主线程工作量过大, 特别是发展中国家有大量的低性能设备. 运算在 Worker 慢一点但页面不掉帧优于运算在主线程快一点但卡顿.
Is postMessage slow\? - HTTP 203
同样来自 Surma 的技术访谈. 主要讨论 postMessage 的性能问题. 本文在通信速度部分大量引用 Surma 的研究.
Surma 在 Worker 领域写了多篇文章, 并开源了 Comlink.
前端項目上 Web Worker 實踐
2019 年的演讲, 笔者前同事, 曾在 Worker 实践上紧密合作. 演讲讨论 Web Worker 的使用场景; Worker 的注意点和适应多线程的代码改造; 以及实践中遇到的问题和解决方案.
Weaving Webs of Workers
2019 年的演讲, 来自 Netflix 的工程师. 总结使用 Web Worker 遇到的 4 大问题, 并通过引入社区多个配套工具逐一解决.
Web Workers: A graphical introduction
2018 年的演讲, 讲多线程和 postMessage 数据传递部分图很漂亮. 将 Web Worker 应用在他开发的 Web 钢琴弹奏器.
What the heack is the event Loop anyway
2014 年的演讲, 使用生动的图例介绍主线程 Event Loop.
实践建议
如上文所述, 社区已有许多 Worker 技术的应用实践. 如果你的业务也有使用 Worker 的需求, 以下是几个实践的建议.
也许你不需要 Worker
使用 Worker 是有成本的: Worker 线程会占用系统资源; 同构代码和异步通信会增加维护成本; 多线程编程会挑战前端仔的思维.
David 的文章指出, 迫切需要 Worker 的场景并不多, 开发者需要考虑投入效益比. 简单来说, 如果页面的某个操作会耗时, 同时不想让用户察觉 (转菊花), 那就用 Worker 吧.
Worker 应该是常驻线程
虽然 Worker 规范提供了
terminate
API 来结束 Worker 线程, 但线程的频繁新建会消耗资源. 大多数场景下, Worker 线程应该用作常驻的线程. 开发中优先复用常驻线程.控制 Worker 线程数目
这也很好理解, Worker 线程在争取 CPU 计算资源时, 受限于 CPU 的核心数, 过多的线程并不能线性地提升性能, 而每个 Worker 线程会有约 1M 的固有内存消耗.
理解多线程开发方式
多线程开发的思维和方式, 是个比较大的话题. 开发者需要控制线程间的通信规模, 减少线程间数据和状态的依赖, 尝试去了解和控制 Worker 线程.
展望
本文试图梳理 2020 年当下 Web Worker 技术的现状和发展.
从现状上看, Worker 已经普遍可用, 业界也有业务和框架上的实践, 但在配套工具上仍有不足.
从发展趋势上看, Worker 的多线程能力有望成为复杂前端项目的标配, 在减少 UI 线程卡顿和压榨计算机性能上有收益. 但目前国内实践较少, 一方面是业务复杂程度未触及; 另一方面是社区缺少科普和实践分享.
前端多线程开发正当时. 笔者维护的 Worker 通信框架 alloy-worker 已经开源, 大型前端项目落地的文章正在路上. 鸡汤和勺子都给了, 加点老干妈, 真香!
References