一文搞懂 Web Worker(原理到实践)
共 12835字,需浏览 26分钟
·
2021-01-28 13:30
作者:poetry
原文地址:https://mp.weixin.qq.com/s/XF7qOhbBtYlwADCiyxbT-w
Web Worker 作为浏览器多线程技术,在页面内容不断丰富,功能日趋复杂的当下,成为缓解页面卡顿,提升应用性能的可选方案。
发展历史
简介
曾经的浏览器对于 JS 的处理模式是单线程模式,页面更新要先 串行 做 2 件事情。
随着 Web Worker 的发布,2 件事情可以 并行 完成。
可以直观地联想:并行可能会 提升执行效率;运行任务拆分能 减少页面卡顿。
技术规范
Web Worker 属于 HTML 规范,规范文档见 Web Workers Working Draft ,2009 年就提出了草案。
目前,除了 ie10 以下,主流浏览器都已经得到了兼容。
DediactedWorker 和 SharedWorker
Web Worker 规范中包括:DedicatedWorker 和 SharedWorker 。
如上图所示,DedicatedWorker 简称 Worker,其线程只能与一个页面渲染进程(Render Process)进行绑定和通信,不能多 Tab 共享。DedicatedWorker 是 最早实现并广泛支持的 Web Worker 能力。
而 SharedWorker 可以多个浏览器 Tab 中访问到同一个 Worker 实例,实现多 Tab 共享数据,共享 websocket 等,但是 safari 放弃了 SharedWorker 支持,因为 webkit 引擎的技术原因。如下图所示,只在 safari 5~6 中短暂支持过。
因此,社区中主要针对兼容性更好的 DedicatedWorker 进行拓展,文章后续也主要以 DedicatedWorker 进行讲解。
主线程和多线程
用户使用浏览器一般会打开多个页面(Tab),现代浏览器使用单独的进程渲染每个页面,以提升页面性能和稳定性,并进行操作系统级别的内存隔离。
主线程(Main Thread)
页面中,内容渲染和用户交互主要由 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。
这里的 并行 区别于单线程中的 并发,单线程中的 并发 准确的说叫 Concurrent,如下图所示,运行时 只有一个函数调用栈,通过 Event Loop 实现不同 Task 的上下文切换(Context Switch),这些 Task 通过 BOM API 调起其他线程为主线程工作,但回调函数代码逻辑 仍然由 JS 串行运行。
Web Worker 是 JS 多线程运行技术,准确来说是 Parallel,其与 Concurrent,如下图所示,运行时 有多个函数调用栈,每个调用栈可以独立运行 Task,互不干扰。
应用场景
讨论完主线程和多线程,我们能更好地理解 Worker 多线程的应用场景:
可以减少主线程卡顿 可能会带来性能提升
减少卡顿
目前主流显示器的刷新率为 60Hz,即一帧为 16ms,因此播放动画时建议小于 16ms,用户操作响应建议小于 100ms,页面打开到开始呈现内容建议小于 1000ms。-- 根据 Chrome 团队提出的用户感知性能模型 RAIL。
逻辑异步化
减少主线程卡顿的主要方法是逻辑异步化,比如播放动画,将同步任务拆分为多个小于 16ms 的子任务,然后在页面的每一帧前通过 requestAnimationFrame
按计划执行一个子任务,直到全部子任务执行完毕。
拆分同步逻辑的异步方案对大部分场景有效果,但并非一劳永逸,有以下几个问题:
并非所有 JS 逻辑都可拆分:比如数组排序,树的递归查找,图像处理算法,执行中需要维护当前状态,且调用上非线性,无法轻易拆分成子任务;
可以拆分的逻辑难以把控颗粒度:如下图所示,拆分的子任务在高性能机器上可以控制在 16ms 以内,但在性能落后的机器上就超过了 deadline。16ms 的用户感知时间并不会因为用户手上机器的差别而变化,Google 的建议是再拆小到 3~4ms;
拆分的子任务并不稳定:对同步 JS 逻辑的拆分,需要根据业务场景寻找原子逻辑,而原子逻辑会随着业务发生变化,每次改动业务都需要去 review 原子逻辑。
Worker 一步到位
Worker 的多线程能力,使得同步 JS 任务拆分一步到位:从宏观上将整个同步 JS 任务异步化。不需要再去苦苦寻找原子逻辑,逻辑异步化的设计上也更加简单和可维护。
这给我们带来更多的想象空间,如下图所示,在浏览器主线程渲染周期内,将可能阻塞页面渲染的 JS 任务迁移到 Worker 线程中,进而减少主线程的负担,缩短渲染间隔,减少页面卡顿。
性能提升
Worker 多线程并不会直接带来计算性能的提升,能否提升与设备 CPU 核数和线程策略有关。
多线程和 CPU 核数
CPU 的单核和多核离前端似乎有点遥远,但在页面运用多线程技术时,核数会影响线程创建策略。
进程是操作系统 资源分配 的基本单位,线程是操作系统 调度 CPU 的基本单位,操作系统对线程能占用的 CPU 计算资源有复杂的分配策略,如下图所示:
单核多线程通过时间切片交替执行; 多核多线程可在不同核中真正并行。
Worker 线程策略
一台设备上相同任务在个线程中运行的时间是一样的,如下图所示:我们将主线程 JS 任务交给新建的 Worker 线程,任务在 Worker 线程上运行并不会比原本主线程更快,而线程新建消耗和通信开销使得渲染间隔可能变得更久。
在单核机器上,计算资源是内卷的,新建的 Worker 线程并不能为页面争取到更多的计算资源。在多核机器上,新建的 Worker 线程和主线程都能做运算,页面总计算资源增多,但对单词任务来说,在哪个线程上运行耗时是一样的。
真正带来性能提升的是 多核多线程并行。
如多个没有依赖关系的同步任务,在单线程上只能串行执行,在多核多线程中可以并行执行。
值得注意的是,目前移动设备的核心数有限,受限于功耗,移动设备 CPU 中的多核通常是大小核,所以在创建多条 Worker 线程时建议区分场景和设备。
把主线程还给 UI
Worker 的应用场景,本质上是把主线程的逻辑剥离,让主线程专注于 UI 渲染,这种架构设计并非 Web 独创。
安卓和 IOS 原生开发中,主线程负责 UI 工作;前端领域热门的小程序,实现原理上就是渲染和逻辑完全分离。
本该如此。
Worker API
通信 API
如上图所示,Worker 的通信十分简单,具体可以参考 Web Worker 使用教程。
双向通信代码十分简单,只需要 7 行:
// main.js
const worker = new Worker('./worker.js')
worker.postMessage('hello')
worker.onmessage = event => {
console.log(event.data) // 'world'
}
// worker.js
self.onmessage = event => {
console.log(event.data) // 'hello'
postMessage('world')
}
postMessage 会在接收线程创建一个 MessageEvent,传递的数据添加到 event.data
,再触发该事件;MessageEvent 的回调函数进入 Message Queue,成为 待执行的宏任务。因此 postMessage 顺序发送 的消息,在接收线程中会 顺序执行回调函数。而且我们无需担心实例化 Worker 过程中 postMessage 的信息丢失问题,对此 Worker 内部机制已经处理。
Worker 事件驱动的通信 API 虽然简洁,但大多数场景下通信需要等待响应,并且多次同类型通信要匹配到各自的响应,所以业务使用一般会封装成 Promise。
运行环境
在 Worker 线程中运行 JS,会创建 独立于主线程的 JS 运行环境,称之为 DedicatedWorkerGlobalScope,开发者需要关注 Worker 环境和主线程环境的异同,以及 Worker 在不同浏览器中的差异。
Worker 环境和主线程环境的异同
Worker 是无 UI 线程,无法调用 UI 相关的 DOM/BOM API,具体可参考 MDN 的 functions and classes available to workers。
上图展示了 Worker 线程和主线程的异同,它们的共同点包括:
包含完整的 JS 运行时,支持 ES 规范定义的语言语法和内置对象; 支持 XMLHTTPRequest,能独立发送网络请求和后端进行交互; 包含只读的 Location,指向 Worker 线程执行的 script url,可通过 url 传递参数给 Worker 环境; 包含只读的 Navigator,用于获取浏览器信息; 支持 setTimeout / setInterval 计时器,可用于实现异步逻辑; 支持 WebSocket 进行网络 I / O,支持 IndexDB 进行文件 I / O。
从共同点上说,Worker 线程非常强大,除了利用线程独立执行重度逻辑以外,其网络 I / O 和文件 I / O 能力给业务和技术方案带来很大的想象空间。
另一方面,Worker 线程运行环境和主线程的差异点有:
Worker 线程没有 DOM API,无法新建和操作 DOM,也无法访问到主线程的 DOM Element; Worker 线程和主线程内存独立,Worker 线程无法访问页面上的全局变量(window,document等)和 JS 函数; Worker 线程不能调用 alert()
和confirm()
等 UI 相关的 BOM API;Worker 线程被主线程控制,主线程可以新建和销毁 Worker; Worker 线程可以通过 self.close
自行销毁。
从差一点上看,Worker 线程无法操作 UI,并受主线程控制。
Worker 在不同浏览器中的差异
各家浏览器实现 Worker 规范有差异,对比主线程,部分 API 功能不完备,如:
IE10 发送的 ajax 请求没有 referer,请求可能会被后端拒绝响应; Edge18 上字符编码 / Buffer 的实现有问题。
好家伙,都是你浓眉大眼的微软系浏览器,解决这些问题得通过 polyfil。
另一方面,一些新增的 HTML 规范 API 只在较新的浏览器中得到实现,Worker 运行环境甚至主线程上没有,使用 Worker 时需要进行判断和兼容。
多线程同构代码
Worker 线程不支持 DOM,这一点和 node.js 非常像,我们在使用 node.js 做前后端 ssr 时,经常会遇到调用 BOM / DOM API 的错误。
在开发 Worker 前端项目,或迁移已有业务代码到 Worker 时,可以通过构建变量区分代码逻辑,或运行时动态判断所在线程,实现同构代码在不同线程环境下运行。
通信速度
Worker 多线程虽然实现了 JS 的并行运行,但是也带来了额外的 通信开销。如下图所示,从线程 A 调用 postMessage 发送数据到线程 B,onmessage 接收到数据有时间差,这段时间差成为 通信消耗。
在线程计算能力的前提下,要通过多线程提升更多的性能,需要尽量 减少通信消耗。
而且主线程 postMessage 会占用主线程同步执行,占用时间与数据传输方式和数据规模相关。要避免多线程通信导致的主线程卡顿,需选择合适的传输方式,并控制每个渲染周期内的数据传输规模。
数据传输方式
我们先来聊聊主线程和 Worker 线程的数据传输方式。根据计算机进程模型,主线程和 Worker 进程同属一个进程,可以访问和操作进程的内存空间,但为了降低多线程并发的逻辑复杂度,部分传输方式直接隔离了线程间的内存,相当于默认加了锁。
通信方式有三种:
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 意味着转移内存,它不需要序列化和反序列化,能大大减少传输过程占用的线程时间。如下图所示,线程 A 将制定内存的所有权和操作权转交给线程 B,但转然后线程 A 无法在访问这块内存。
Transfer Memory 以失去控制权来换取高效传输,通过内存独占给多线程并发加锁,但只能转让 ArrayBuffer 等大小规整的二进制数据,对矩阵数据(比如 RGB图片)比较适用,实践上要考虑从 js object 生成二进制数据的运算成本。
Shared Array Buffers
Shared Array Buffers 是共享内存,线程 A 和线程 B 可以 同时访问和操作 同一块内存空间,数据都共享了,也就没什么传输的事了。
但多个并行的线程共享内存,会产生竞争问题,不像前两种传输方式默认加锁,Shared Array Buffers 把难题抛给了开发者,开发者可以用 Atomics 来维护这块共享的内存。作为较新的传输方式,浏览器兼容性可想而知,目前只有 Chrome 68+ 支持。
传输方式小结
全浏览器兼容的 Structured Clone 是较好的选择,但要考虑数据传输的规模,下文我们会详细展开; Transfer Memory 兼容性也不错(IE11+),但数据独占和数据类型的限制,使得它是特定场景的最优解,而不是常规解; Shared Array Buffers 当下糟糕的兼容性和线程锁的开发成本,建议先暗中观察。
数据传输规模
Structured Clone 的序列化和反序列化执行耗时 主要受数据对象复杂度影响,这很好理解,因为序列化和反序列化至少要以某种方式遍历对象。数据对象的复杂度本身难易度量,可以用序列化后的数据规模作为参考。
2015 年的 How fast are web workers 在中等性能手机上进行了测试: postMessage 发送数组的通信速率为 80KB/ms,相当于理想渲染周期(16ms)内发送 1300KB。
2019 年 Surma 对 postMessage 的数据传输能力进行了更深入研究,具体见 Is postMessage slow。高性能机器(macbook) 上的测试结果如下图所示:
其中:
测试数据为嵌套层数 1 到 6 层(payload depth,图中纵坐标),每层节点的子节点 1 到 6 个(payload breadth,图中横坐标)的对象,数据规模从 10B 到 10MB 在 MacBook 上,10MB 的数据传递耗时 47ms,16ms 内可以传递 1MB 级别的数据
**低性能机器(nokia2)**上的测试结果如下:
其中:
在 nokia2 上传输 10MB 数据的耗时是 638ms,16ms 内可以传递 10KB 级别的数据 高性能机器和低性能机器有超过 10 倍的传输效率差距
综上,不管用户的机器性能如何,用户对流畅的感受是一致的:前端同学的老朋友 60ms 和 100ms。
Surma 兼顾低性能机型上 postMessage 容易造成主线程卡顿,提出的数据传输规模的建议是:
如果 JS 代码里面不包括动画渲染(100ms),数据传输规模应该保持在 100KB 一下; 如果 JS 代码里面包括动画渲染(16ms),数据传输规模应该保持在 10KB 一下。
兼容性
兼容性是前端最关键的一点,毕竟我们无法控制用户使用哪一款浏览器的那一个版本。对 Web Worker 更是如此,因为 Worker 的多线程能力要么业务场景根本用不上,要么就是重度依赖。
从 caniuse 上面可以看到,Web Worker 的兼容性做的还是挺不错的。
使用 Web Worker 并非一锤子买卖,我们不止关注浏览器 Worker 能力有无,还需要关注它是否完备可用,因此可以用以下几个指标来进行评测:
是否有 Worker 能力:通过浏览器是否有 window.Worker
来判断能否实例化 Worker:通过监控 new Worker()
是否报错来判断;能否跨线程通信:通过测试双向通信来验证,并设置超时; 首次通信耗时:页面开始加载 Worker 脚本到首次通讯完成的耗时,该指标与 JS 资源加载时长,同步逻辑执行耗时相关。
Service Worker
模仿 Web Worker,利用现代浏览器支持多线程运行的机制,实现了一个独立于主线程的子线程。
作用
离线缓存 消息推送 后台数据同步 响应来自其他源的资源请求 集中接收计算成本高的数据更新,比如地理位置和陀螺仪信息,这样多个页面就可以利用同一组数据 在客户端运行 CoffeeScript,Less,CJS / AMD 等模块编译和依赖管理(用于开发目的) 后台服务钩子 自定义模版用于特定 URL 模式 性能增强,比如预取用户可能需要的资源,比如相册中后面数张照片
局限性
https:Service Worker 必须运行在 HTTPS 协议上,但在本地环境中 http://localhost 或者 http://127.0.0.1 也可以
浏览器的兼容性
我们可以看到 IE 完全不兼容,早期的 IOS 也不兼容。
调试
以 Google Chrome 为例:
chrome://serviceworker-internals
开发者模式的 Application
生命周期
Service Worker 生命周期的反应:
installing installed activating activated
其中,installed 用来缓存文件,activated 用来更新缓存
用法
html 中
if ('serviceWorker' in navigator) {
// 开始注册service workers
navigator.serviceWorker.register('./sw-demo-cache.js', {
scope: './'
}).then(function (registration) {
console.log('注册成功');
var serviceWorker;
if (registration.installing) {
serviceWorker = registration.installing;
console.log('安装installing');
} else if (registration.waiting) {
serviceWorker = registration.waiting;
console.log('等待waiting');
} else if (registration.active) {
serviceWorker = registration.active;
console.log('激活active');
}
console.log('=>serviceWorker:', serviceWorker);
if (serviceWorker) {
console.log(serviceWorker.state);
serviceWorker.addEventListener('statechange', function (e) {
console.log(' 状态变化为', e.target.state);
});
// 创建信道
var channel = new MessageChannel();
// port1留给自己
channel.port1.onmessage = e => {
console.log('main thread receive message...');
console.log(e);
}
console.log('给对方', window.RES_MAP);
// port2给对方
serviceWorker.postMessage(window.RES_MAP, [channel.port1]);
serviceWorker.addEventListener('statechange', function (e) {
// logState(e.target.state);
});
}
}).catch(function (error) {
console.log('注册没有成功');
});
} else {
console.log('不支持');
}引进 sw-demo-cache.js
// sw
self.addEventListener('message', ev => {
console.log('sw receive message..');
console.log(ev);
fileMap = ev.data.RES_MAP;
var arr1 = [].slice.call(fileMap); // ['a', 'b', 'c']
// 取main thread传来的port2
ev.ports[0].postMessage('Hi, hello too');
});
// var fs = require('fs');
// console.log(fs);
// 缓存
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(VERSION).then(function(cache) {
return cache.addAll([
'./index.html',
]);
})
);
});
// 缓存更新
self.addEventListener('activate', function(event) {
console.log('two now ready to handle fetches!');
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
console.log('cacheName:', cacheName);
// 如果当前版本和缓存版本不一致
if (cacheName !== VERSION) {
return caches.delete(cacheName);
}
})
);
})
);
});
// 捕获请求并返回缓存数据
self.addEventListener('fetch', function (event) {
try{
event.respondWith(
caches.match(event.request).then(function(res){
if(res){
return res;
}
requestBackend(event);
})
)
} catch {
console.log(event);
}
});
function requestBackend(event){
var url = event.request.clone();
return fetch(url).then(function(res){
//if not a valid response send the error
if(!res || res.status !== 200 || res.type !== 'basic'){
return res;
}
var response = res.clone();
console.log('VERSION:', VERSION);
caches.open(VERSION).then(function(cache){
cache.put(event.request, response);
});
return res;
})
}webpack 中获取文件目录,引入第三个模块 glob,递归获取打包后的文件目录
exports.resMap = function () {
var entryFiles = glob.sync(PAGE_PATH + '/*/*.js')
var map = {}
entryFiles.forEach((filePath) => {
var filename = filePath.substring(filePath.lastIndexOf('\/') + 1, filePath.lastIndexOf('.'))
map[filename] = filePath;
})
var entryFiles2 = glob.sync(PAGE_PATH2 + '/*')
var map2 = {}
findPath(entryFiles2, map2);
console.log('map2', map2);
return map2;
};
function findPath(entryFiles2, map2) {
entryFiles2.forEach(filePath => {
var filename = filePath.substring(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.'));
if (filePath.indexOf('.') <= 0) {
let pathRes = path.resolve(__dirname, filePath);
let files = glob.sync(pathRes + '/*');
findPath(files, map2);
map2[filename] = filePath;
}
map2[filename] = filePath;
});
}导出目录:通过 webpack 的 DefinePlugin 插件,导出上一步获取的目录
Web 和 Service Worker 的通信:通过 postMessage 实现 Web 和 Service Worker 间的通信
// 创建信道
var channel = new MessageChannel();
// port1留给自己
channel.port1.onmessage = e => {
console.log('main thread receive message...');
console.log(e);
}
console.log('给对方', window.RES_MAP);
// port2给对方
serviceWorker.postMessage(window.RES_MAP, [channel.port1]);
serviceWorker.addEventListener('statechange', function (e) {
// logState(e.target.state);
});
// sw
self.addEventListener('message', ev => {
console.log('sw receive message..');
console.log(ev);
fileMap = ev.data.RES_MAP;
var arr1 = [].slice.call(fileMap); // ['a', 'b', 'c']
// 取main thread传来的port2
ev.ports[0].postMessage('Hi, hello too');
});