Web Workers

如果你有一些处理密集型的任务,但你不想让它们在主线程上运行(那样会使浏览器/UI变慢),你可能会希望JavaScript可以以多线程的方式操作。

在第一章中,我们详细地谈到了关于JavaScript如何是单线程的。那仍然是成立的。但是单线程不是组织你程序运行的唯一方法。

想象将你的程序分割成两块儿,在UI主线程上运行其中的一块儿,而在一个完全分离的线程上运行另一块儿。

这样的结构会引发什么我们需要关心的问题?

其一,你会想知道运行在一个分离的线程上是否意味着它在并行运行(在多CPU/内核的系统上),如此在第二个线程上长时间运行的处理将 不会 阻塞主程序线程。否则,“虚拟线程”所带来的好处,不会比我们已经在异步并发的JS中得到的更多。

而且你会想知道这两块儿程序是否访问共享的作用域/资源。如果是,那么你就要对付多线程语言(Java,C++等等)的所有问题,比如协作式或抢占式锁定(互斥,等)。这是很多额外的工作,而且不应当轻易着手。

换一个角度,如果这两块儿程序不能共享作用域/资源,你会想知道它们将如何“通信”。

所有这些我们需要考虑的问题,指引我们探索一个在近HTML5时代被加入web平台的特性,称为“Web Worker”。这是一个浏览器(也就是宿主环境)特性,而且几乎和JS语言本身没有任何关系。也就是说,JavaScript 当前 并没有任何特性可以支持多线程运行。

但是一个像你的浏览器那样的环境可以很容易地提供多个JavaScript引擎实例,每个都在自己的线程上,并允许你在每个线程上运行不同的程序。你的程序中分离的线程块儿中的每一个都称为一个“(Web)Worker”。这种并行机制叫做“任务并行机制”,它强调将你的程序分割成块儿来并行运行。

在你的主JS程序(或另一个Worker)中,你可以这样初始化一个Worker:

  1. var w1 = new Worker( "http://some.url.1/mycoolworker.js" );

这个URL应当指向JS文件的位置(不是一个HTML网页!),它将会被加载到一个Worker。然后浏览器会启动一个分离的线程,让这个文件在这个线程上作为独立的程序运行。

注意: 这种用这样的URL创建的Worker称为“专用(Dedicated)Wroker”。但与提供一个外部文件的URL不同的是,你也可以通过提供一个Blob URL(另一个HTML5特性)来创建一个“内联(Inline)Worker”;它实质上是一个存储在单一(二进制)值中的内联文件。但是,Blob超出了我们要在这里讨论的范围。

Worker不会相互,或者与主程序共享任何作用域或资源——那会将所有的多线程编程的噩梦带到我们面前——取而代之的是一种连接它们的基本事件消息机制。

w1Worker对象是一个事件监听器和触发器,它允许你监听Worker发出的事件也允许你向Worker发送事件。

这是如何监听事件(实际上,是固定的"message"事件):

  1. w1.addEventListener( "message", function(evt){
  2. // evt.data
  3. } );

而且你可以发送"message"事件给Worker:

  1. w1.postMessage( "something cool to say" );

在Worker内部,消息是完全对称的:

  1. // "mycoolworker.js"
  2. addEventListener( "message", function(evt){
  3. // evt.data
  4. } );
  5. postMessage( "a really cool reply" );

要注意的是,一个专用Worker与它创建的程序是一对一的关系。也就是,"message"事件不需要消除任何歧义,因为我们可以确定它只可能来自于这种一对一关系——不是从Wroker来的,就是从主页面来的。

通常主页面的程序会创建Worker,但是一个Worker可以根据需要初始化它自己的子Worker——称为subworker。有时将这样的细节委托给一个“主”Worker十分有用,它可以生成其他Worker来处理任务的一部分。不幸的是,在本书写作的时候,Chrome还没有支持subworker,然而Firefox支持。

要从创建一个Worker的程序中立即杀死它,可以在Worker对象(就像前一个代码段中的w1)上调用terminate()。突然终结一个Worker线程不会给它任何机会结束它的工作,或清理任何资源。这和你关闭浏览器的标签页来杀死一个页面相似。

如果你在浏览器中有两个或多个页面(或者打开同一个页面的多个标签页!),试着从同一个文件URL中创建Worker,实际上最终结果是完全分离的Worker。待一会儿我们就会讨论“共享”Worker的方法。

注意: 看起来一个恶意的或者是呆头呆脑的JS程序可以很容易地通过在系统上生成数百个Worker来发起拒绝服务攻击(Dos攻击),看起来每个Worker都在自己的线程上。虽然一个Worker将会在存在于一个分离的线程上是有某种保证的,但这种保证不是没有限制的。系统可以自由决定有多少实际的线程/CPU/内核要去创建。没有办法预测或保证你能访问多少,虽然很多人假定它至少和可用的CPU/内核数一样多。我认为最安全的臆测是,除了主UI线程外至少有一个线程,仅此而已。

Worker 环境

在Worker内部,你不能访问主程序的任何资源。这意味着你不能访问它的任何全局变量,你也不能访问页面的DOM或其他资源。记住:它是一个完全分离的线程。

然而,你可以实施网络操作(Ajax,WebSocket)和设置定时器。另外,Worker可以访问它自己的几个重要全局变量/特性的拷贝,包括navigatorlocationJSON,和applicationCache

你还可以使用importScripts(..)加载额外的JS脚本到你的Worker中:

  1. // 在Worker内部
  2. importScripts( "foo.js", "bar.js" );

这些脚本会被同步地加载,这意味着在文件完成加载和运行之前,importScripts(..)调用会阻塞Worker的执行。

注意: 还有一些关于暴露<canvas>API给Worker的讨论,其中包括使canvas成为Transferable的(见“数据传送”一节),这将允许Worker来实施一些精细的脱线程图形处理,在高性能的游戏(WebGL)和其他类似应用中可能很有用。虽然这在任何浏览器中都还不存在,但是很有可能在近未来发生。

Web Worker的常见用途是什么?

  • 处理密集型的数学计算
  • 大数据集合的排序
  • 数据操作(压缩,音频分析,图像像素操作等等)
  • 高流量网络通信

数据传送

你可能注意到了这些用途中的大多数的一个共同性质,就是它们要求使用事件机制穿越线程间的壁垒来传递大量的信息,也许是双向的。

在Worker的早期,将所有数据序列化为字符串是唯一的选择。除了在两个方向上进行序列化时速度上变慢了,另外一个主要缺点是,数据是被拷贝的,这意味着内存用量翻了一倍(以及在后续垃圾回收上的流失)。

谢天谢地,现在我们有了几个更好的选择。

如果你传递一个对象,在另一端一个所谓的“结构化克隆算法(Structured Cloning Algorithm)”(https://developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/The_structured_clone_algorithm)会用于拷贝/复制这个对象。这个算法相当精巧,甚至可以处理带有循环引用的对象复制。to-string/from-string的性能劣化没有了,但用这种方式我们依然面对着内存用量的翻倍。IE10以上版本,和其他主流浏览器都对此有支持。

一个更好的选择,特别是对大的数据集合而言,是“Transferable对象”(http://updates.html5rocks.com/2011/12/Transferable-Objects-Lightning-Fast)。它使对象的“所有权”被传送,而对象本身没动。一旦你传送一个对象给Worker,它在原来的位置就空了出来或者不可访问——这消除了共享作用域的多线程编程中的灾难。当然,所有权的传送可以双向进行。

选择使用Transferable对象不需要你做太多;任何实现了Transferable接口(https://developer.mozilla.org/en-US/docs/Web/API/Transferable)的数据结构都将自动地以这种方式传递(Firefox和Chrome支持此特性)。

举个例子,有类型的数组如Uint8Array(见本系列的 ES6与未来)是一个“Transferables”。这是你如何用postMessage(..)来传送一个Transferable对象:

  1. // `foo` 是一个 `Uint8Array`
  2. postMessage( foo.buffer, [ foo.buffer ] );

第一个参数是未经加工的缓冲,而第二个参数是要传送的内容的列表。

不支持Transferable对象的浏览器简单地降级到结构化克隆,这意味着性能上的降低,而不是彻底的特性失灵。

共享的Workers

如果你的网站或应用允许多个标签页加载同一个网页(一个常见的特性),你也许非常想通过防止复制专用Worker来降低系统资源的使用量;这方面最常见的资源限制是网络套接字链接,因为浏览器限制同时连接到一个服务器的连接数量。当然,限制从客户端来的链接数也缓和了你的服务器资源需求。

在这种情况下,创建一个单独的中心化Worker,让你的网站或应用的所有网页实例可以 共享 它是十分有用的。

这称为SharedWorker,你会这样创建它(仅有Firefox与Chrome支持此特性):

  1. var w1 = new SharedWorker( "http://some.url.1/mycoolworker.js" );

因为一个共享Worker可以连接或被连接到你的网站上的多个程序实例或网页,Worker需要一个方法来知道消息来自哪个程序。这种唯一的标识称为“端口(port)”——联想网络套接字端口。所以调用端程序必须使用Worker的port对象来通信:

  1. w1.port.addEventListener( "message", handleMessages );
  2. // ..
  3. w1.port.postMessage( "something cool" );

另外,端口连接必须被初始化,就像这样:

  1. w1.port.start();

在共享Worker内部,一个额外的事件必须被处理:"connect"。这个事件为这个特定的连接提供端口object。保持多个分离的连接最简单的方法是在port上使用闭包,就像下面展示的那样,同时在"connect"事件的处理器内部定义这个连接的事件监听与传送:

  1. // 在共享Worker的内部
  2. addEventListener( "connect", function(evt){
  3. // 为这个连接分配的端口
  4. var port = evt.ports[0];
  5. port.addEventListener( "message", function(evt){
  6. // ..
  7. port.postMessage( .. );
  8. // ..
  9. } );
  10. // 初始化端口连接
  11. port.start();
  12. } );

除了这点不同,共享与专用Worker的功能和语义是一样的。

注意: 如果在一个端口的连接终结时还有其他端口的连接存活着的话,共享Worker也会存活下来,而专用Worker会在与初始化它的程序间接终结时终结。

填补 Web Workers

对于并行运行的JS程序在性能考量上,Web Worker十分吸引人。然而,你的代码可能运行在对此缺乏支持的老版本浏览器上。因为Worker是一个API而不是语法,所以在某种程度上它们可以被填补。

如果浏览器不支持Worker,那就根本没有办法从性能的角度来模拟多线程。Iframe通常被认为可以提供并行环境,但在所有的现代浏览器中它们实际上和主页运行在同一个线程上,所以用它们来模拟并行机制是不够的。

正如我们在第一章中详细讨论的,JS的异步能力(不是并行机制)来自于事件轮询队列,所以你可以用计时器(setTimeout(..)等等)来强制模拟的Worker是异步的。然后你只需要提供Worker API的填补就行了。这里有一份列表(https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills#web-workers),但坦白地说它们看起来都不怎么样。

我在这里(https://gist.github.com/getify/1b26accb1a09aa53ad25)写了一个填补`Worker`的轮廓。它很基础,但应该满足了简单的`Worker`支持,它的双向信息传递可以正确工作,还有`"onerror"`处理。你可能会扩展它来支持更多特性,比如`terminate()`或模拟共享Worker,只要你觉得合适。

注意: 你不能模拟同步阻塞,所以这个填补不允许使用importScripts(..)。另一个选择可能是转换并传递Worker的代码(一旦Ajax加载后),来重写一个importScripts(..)填补的一些异步形式,也许使用一个promise相关的接口。