multiple web workers的实现

2,243 阅读7分钟
原文链接: zhuanlan.zhihu.com

一. 背景

先交代下业务背景,去年十月做了一个视频上传的相关业务,部分需求如下:

  1. 视频文件的MD5计算
  2. 并行上传,可配置最大并行数。
  3. 分片上传
  4. 可随时中断,取消上传。

以上只是上传部分的功能,对于我这种第一次做上传的人来说,看了真是一头雾水。不仅要解决上述的需求,还要考虑其他的设计和性能问题,比如:

  1. js单线程:当上传一个5G+的大文件,计算MD5的时间约几分钟,在主线程中无法同时计算多个视频的md5,此后添加的文件都在队列中,需要一个一个计算MD5。
  2. 并行上传:js主线程基于eventloop,无法做到真正意义上的并行上传。
  3. 分片上传:切片视频,频繁的读写视频文件。
  4. 维护上传队列:当文件上传完成或者取消时,自动添加上传文件。

对于这种需求,webworker 是最合是不过的了。

所以当时按照如下方式分解了业务功能,解决了上述问腿。

  • js的主线程负责创建web worker,相关UI视图,更新UI。
  • worker 负责 文件计算MD5,切片,上传,计算相关数据。
  • 处理文件,上传时 如需更新UI,worker将相关数据传递给主线程,主线程更新相关UI视图。
  • 主线程需要对文件 ,上传 进行计算 和 处理时,通知worker,worker完成相关操作

基于以上,觉得可以把worker定义为一个执行复杂运算的线程,将想要执行的方法通过postmessage 方法 传递给 worker,当worker接收到后开始执行,并将结果返回给主线程。所以写了个multi-worker。


二.什么是web worker

在 HTML5 中,工作线程的出现使得在 Web 页面中进行多线程编程成为可能。众所周知,传统页面中(HTML5 之前)的 JavaScript 的运行都是以单线程的方式工作的,虽然有多种方式实现了对多线程的模拟(例如:JavaScript 中的 setinterval 方法,setTimeout 方法等),但是在本质上程序的运行仍然是由 JavaScript 引擎以单线程调度的方式进行的。在 HTML5 中引入的工作线程使得浏览器端的 JavaScript 引擎可以并发地执行 JavaScript 代码,从而实现了对浏览器端多线程编程的良好支持。
HTML5 中的 Web Worker 可以分为两种不同线程类型,一个是专用线程 Dedicated Worker,一个是共享线程 Shared Worker。两种类型的线程各有不同的用途

web-worker兼容性:

worker中可用的函数和接口

你可以在web worker中使用大多数的标准javascript特性,包括

你可以在web worker中使用大多数的标准javascript特性,包括

Navigator
XMLHttpReque
Array,Date,Math, and String
WindowTimers.setTimeout`and WindowTimers.setInterval


在一个worker中最主要的你不能做的事情就是直接影响父页面。包括操作父页面的节点以及使用页面中的对象。你只能间接地实现,通过self.postMessage回传消息给主脚本,然后从主脚本那里执行操作或变化。

特性:

1.为 JavaScript引入真正的线程,不必再使用 setTimeout()、setInterval()、XMLHttpRequest 来模拟并行
2.Worker 利用类似线程的消息传递实现并行。这非常适合确保对 UI 的刷新、性能以及对用户的响应。
3.Web Worker 的三大主要特征:能够长时间运行(响应),理想的启动性能以及理想的内存消耗。


适用场景

1.使用专用线程进行数学运算
Web Worker最简单的应用就是用来做后台计算,而这种计算并不会中断前台用户的操作
2.图像处理
通过使用从<canvas>或者<video>元素中获取的数据,可以把图像分割成几个不同的区域并且把它们推送给并行的不同Workers来做计算
3.大量数据的检索
当需要在调用 ajax后处理大量的数据,如果处理这些数据所需的时间长短非常重要,可以在Web Worker中来做这些,避免冻结UI线程。
4.背景数据分析
由于在使用Web Worker的时候,我们有更多潜在的CPU可用时间,我们现在可以考虑一下JavaScript中的新应用场景。


限制

1.不能访问DOMBOM对象(alert不支持,console.log部分浏览器支持,在safari中不能使用console,否则会报错)
2.Locationnavigator的只读访问,并且navigator封装成WorkerNavigator对象,有部分属性被更改。
3.无法读取本地文
4.全局变量中不存在thisthis并不指向window。有self,指向worker本身
5.子线程和父级线程的通讯是通过值拷贝,子线程对通信内容的修改,不会影响到主线程。在通讯过程中值过大也会影响到性能(解决这个问题可以用transferable objects
6.条数限制,大多浏览器能创建web worker线程的条数是有限制的,可以手动去拓展,但是如果不设置的话,基本上都在20条以内,每条线程大概5M左右,需要手动关掉一些不用的线程才能够创建新的线程(相关解决方案


通信方法:

  • 发送消息
主线程 :worker.postMessage();
worker线程 :self.postMessage();
  • 接收消息
主线程:worker.message();
worker线程:self.message();
  • 监听异常
主线程:worker.error();
worker线程:self.error();
  • 销毁方法
主线程:worker.terminate();
worker线程:self.close();

三.API设计


背景需求里要求实现队列,所以在multi-worker 里增加了队列控制,可以在创建multi-worker实例时配置最大并行执行的worker数量,默认是window.navigator.hardwareConcurrency。

config: {
   maxWorkers : (window.navigator && window.navigator.hardwareConcurrency) || 3,
   minWorkers : 1
}

对于一个worker的维护队列主要提供增,删,查三种方法就够了,每个worker都会分配一个id,方便我们操作指定worker。每个方法都会返回worker的实例。

add(config = { //增
    id:id, 
    fn:fn,
    args:args,
}) { 
 return new worker(config);
}
getWorker(id) // 查
getIdleWorker(id) //查
removeWorker(id) // 删

此外,还提供race / all 方法: 返回最先 / 全部 在worker中执行完成的结果。因为postmessage本身是个一来一回的异步的行为,包装成promise的肯定更为合适和易用。

all/race(excuFns){
        let racePWorkers = [];
        let promises = [];

        excuFns.forEach((excuFn) => {
            let worker = this.add();
            racePWorkers.push(worker);
            promises.push(worker.reslover.promise);
        })

        racePWorkers.forEach((worker, index) => {
            worker.excu(excuFns[index].fn, excuFns[index].args);
        });

        return Promise.race(promises)
}


worker 方法:

worker只提供一个excu(fn,args)方法,用于执行指定的函数方法。返回一个promise,异步接收worker中执行的结果。


excu(fn,args){
        if(this.busy)throw new Error (`id:${this.id} worker is busy`);

        let _fn , _args;
        
        if (fn && typeof fn === 'function') {
            _fn = GeneralUtils.serializeFunction(fn );
            _args = GeneralUtils.stringifyJson(args);
        }

        this.worker.postMessage({ _fn, _args})
        
        this.busy = true;

        return this.reslover.promise;
    }


下面我们看一下,具体的使用方法:

const multiWorker = new mWorker({
    maxWorkers: 1
});


function recurFib(n) {
    if (n == 1 || n == 2) {

        return 1;
    }
    return recurFib(n - 1) + recurFib(n - 2);
}


// 创建指定id的worker,计算
multiWorker.add({id:10}).excu(recurFib,[10]).then((res)=>{
    console.log('创建指定id worker,计算');
    console.log(res);
    document.write(`Fibonacci(${10}):${res}<br>`) //---> output 55
})

//all 方法
var allWorker = multiWorker.all([{ fn: recurFib, args: 20 }, { fn: recurFib, args: 10 }]);
allWorker.then((res) => {                                                                           
    console.log('all方法', res);// [6765,55];
    document.write(`<mark>all</mark> : Fibonacci(${20}),Fibonacci(${10}):<mark>${res}</mark><br>`)   //6765,55
}).then(()=>{
   //终止 全部worker
    setTimeout(() => {
        multiWorker.removeWorker();
        document.write(`全部worker已销毁`);
    }, 2000)
})


四.实现原理


实现其实蛮简单的。

  1. web-worker受同源策略的限制,Worker 不能读取本地文件,所以这个脚本必须来自网络。通过worker-loader在编译打包时,把本地worker文件处理。
  2. worker线程如何处理主线程传来的方法:主线程把需要在worker中执行的方法通过postmessage传给worker,worker接收到后,通过eval执行此方法,运行结束后,得到结果再通过postmessage传递给主线程。
web-worker 中 用eval执行主线程传递的方法
eval('(' + _fn + ')');


五.优化

Worker 与“主线程”之间的数据传递默认是通过结构化克隆(Structured Clone)。但数据量较大时,克隆过程会比较耗时,会影响 postMessage 和 onmessage 函数的执行时间。可以先通过 JSON.stringify 将对象序列化,接收之后再用 JSON.parse 还原。国外大神测试使用JSON.stringifyJSON.parse 的性能对比。 测试版本有点低,不过能说明使用stringify的性能更好一些。







(还有一种避开克隆传值的方法,就是使用Transferable Objects,主要是采用二进制的存储方式,采用地址引用,解决数据交换的实时性问题;Transferable Objects支持的常用数据类型有ArrayBuffer,ImageBitmap)

六.总结和问题

写完发现有几个类似的库,实现原理差不多。multi-worker的有点是promise化,增加了队列和race/all方法。

但是有两个问题,如果有什么解决办法欢迎提意见。

1.由于webworker也是基于eventloop,所以,在worker中执行方法时,无法通过主线程的worker.terminate()终止此worker,仅能等待当前任务执行完毕后才能终止。

2.通postmessage传进来的函数,无法引用此函数以外的函数,因为在postmessage前,会通过Json.stringify 序列化。所以有一点鸡肋的地方就是我们需要把整段业务代码全写在一个方法里。




参考文献:

Web Workersw3c.github.io
High-performance Web Worker messagesnolanlawson.com图标深入 HTML5 Web Worker 应用实践:多线程编程www.ibm.com图标