返回介绍

第一部分 类型和语法

第二部分 异步和性能

5.1 Web Worker

发布于 2023-05-24 16:38:21 字数 7884 浏览 0 评论 0 收藏 0

如果你有一些处理密集型的任务要执行,但不希望它们都在主线程运行(这可能会减慢浏览器 /UI),可能你就会希望 JavaScript 能够以多线程的方式运行。

在第 1 章里,我们已经详细介绍了 JavaScript 是如何单线程运作的。但是,单线程并不是组织程序执行的唯一方式。

设想一下,把你的程序分为两个部分:一部分运行在主 UI 线程下,另外一部分运行在另一个完全独立的线程中。

这样的架构可能会引出哪些方面的问题呢?

一个就是,你会想要知道在独立的线程运行是否意味着它可以并行运行(在多 CPU/ 核心的系统上),这 样第二个线程的长时间运行就不会阻塞程序主线程。 否则, 相比于 JavaScript 中已有的异步并发,“虚拟多线程”并不会带来多少好处。

你还会想知道程序的这两个部分能否访问共享的作用域和资源。如果可以的话,那你就将遇到多线程语言(Java、C++ 等)要面对的所有问题,比如需要合作式或抢占式的锁机制(mutex 等)。这是相当多的额外工作,不要小看。

还有,如果这两个部分能够共享作用域和资源的话,你会想要知道它们将如何通信。

在我们对 Web 平台 HTML5 的一个叫作 Web Worker 的新增特性的探索过程中,这些都是很好的问题。这是浏览器(即宿主环境)的功能,实际上和 JavaScript 语言本身几乎没什么关系。也就是说,JavaScript 当前并没有任何支持多线程执行的功能。

但是,像你的浏览器这样的环境,很容易提供多个 JavaScript 引擎实例,各自运行在自己的线程上,这样你可以在每个线程上运行不同的程序。程序中每一个这样的独立的多线程部分被称为一个(Web)Worker。这种类型的并行化被称为任务并行 ,因为其重点在于把程序划分为多个块来并发运行。

从 JavaScript 主程序(或另一个 Worker)中,可以这样实例化一个 Worker:

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

这个 URL 应该指向一个 JavaScript 文件的位置(而不是一个 HTML 页面!),这个文件将被加载到一个 Worker 中。然后浏览器启动一个独立的线程,让这个文件在这个线程中作为独立的程序运行。

这种通过这样的 URL 创建的 Worker 称为专用 Worker(Dedicated Worker)。除了提供一个指向外部文件的 URL,你还可以通过提供一个 Blob URL(另外一个 HTML5 特性)创建一个在线 Worker(Inline Worker),本质上就是一个存储在单个(二进制)值中的在线文件。不过,Blob 已经超出了我们这里的讨论范围。

Worker 之间以及它们和主程序之间,不会共享任何作用域或资源,那会把所有多线程编程的噩梦带到前端领域,而是通过一个基本的事件消息机制相互联系。

Worker w1 对象是一个事件侦听者和触发者,可以通过订阅它来获得这个 Worker 发出的事件以及发送事件给这个 Worker。

以下是如何侦听事件(其实就是固定的 "message" 事件):

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

也可以发送 "message" 事件给这个 Worker:

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

在这个 Worker 内部,收发消息是完全对称的:

// "mycoolworker.js"

addEventListener( "message", function(evt){
  // evt.data
} );

postMessage( "a really cool reply" );

注意,专用 Worker 和创建它的程序之间是一对一的关系。也就是说,"message" 事件没有任何歧义需要消除,因为我们确定它只能来自这个一对一的关系:它要么来自这个 Worker,要么来自主页面。

通常由主页面应用程序创建 Worker,但若是需要的话,Worker 也可以实例化它自己的子 Worker,称为 subworker。有时候,把这样的细节委托给一个“主”Worker,由它来创建其他 Worker 处理部分任务,这样很有用。不幸的是,到写作本书时为止,Chrome 还不支持 subworker,不过 Firefox 支持。

要在创建 Worker 的程序中终止 Worker,可以调用 Worker 对象(就像前面代码中的 w1 )上的 terminate() 。突然终止 Worker 线程不会给它任何机会完成它的工作或者清理任何资源。这就类似于通过关闭浏览器标签页来关闭页面。

如果浏览器中有两个或多个页面(或同一页上的多个 tab !)试图从同一个文件 URL 创建 Worker,那么最终得到的实际上是完全独立的 Worker。后面我们会简单介绍如何共享 Worker。

看起来似乎恶意或无知的 JavaScript 程序只要在一个系统中生成上百个 Worker,让每个 Worker 运行在低级独立的线程上,就能够以此制造拒绝服务攻击。尽管这确实从某种程度上保证了每个 Worker 将运行在自己的独立线程上,但是这个保证并不是毫无限度的。系统能够决定可以创建多少个实际的线程 /CPU/ 核心。没有办法预测或保证你能够访问多少个可用线程,尽管很多人假定至少可以达到 CPU/ 核心的数量。我认为最安全的假定就是在主 UI 线程之外至少还有一个线程,就是这样。

5.1.1 Worker 环境

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

但 是,你可以执行网络操 作(Ajax、WebSockets)以及设定定时 器。还 有,Worker 可以访问几个重要的全局变量和功能的本地复 本,包括 navigator 、location 、JSON 和 applicationCache 。

你还可以通过 importScripts(..) 向 Worker 加载额外的 JavaScript 脚本:

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

这些脚本加载是同步的。也就是说,importScripts(..) 调用会阻塞余下 Worker 的执行,直到文件加载和执行完成。

另外,已经有一些讨论涉及把 <canvas> API 暴露给 Worker,以及把 canvas 变为 Transferable(参见 5.1.2 节),这将使 Worker 可以执行更高级的 off-thread 图形处理,这对于高性能游戏(WebGL)和其他类似的应用是很有用的。尽管目前的浏览器中还不存在这种支持,但很可能不远的将来就会有。

Web Worker 通常应用于哪些方面呢?

· 处理密集型数学计算

· 大数据集排序

· 数据处理(压缩、音频分析、图像处理等)

· 高流量网络通信

5.1.2 数据传递

你可能已经注意到这些应用中的大多数有一个共性,就是需要在线程之间通过事件机制传递大量的信息,可能是双向的。

在早期的 Worker 中,唯一的选择就是把所有数据序列化到一个字符串值中。除了双向序列化导致的速度损失之外,另一个主要的负面因素是数据需要被复制,这意味着两倍的内存使用(及其引起的垃圾收集方面的波动)。

谢天谢地,现在已经有了一些更好的选择。

如果要传递一个对象,可以使用结构化克隆算法 (structured clone 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 接口(http://developer.mozilla.org/en-US/docs/Web/API/Transferable )的数据结构就自动按照这种方式传输(Firefox 和 Chrome 都支持)。

举例来说,像 Uint8Array 这样的带类型的数组(参见本系列的《你不知道的 JavaScript(下卷)》的“ES6 & Beyond”部分)就是 Transferable。下面是如何使用 postMessage(..) 发送一个 Transferable 对象:

// 比如foo是一个Uint8Array

postMessage( foo.buffer, [ foo.buffer ] );

第一个参数是一个原始缓冲区,第二个是一个要传输的内容的列表。

不支持 Transferable 对象的浏览器就降级到结构化克隆,这会带来性能下降而不是彻底的功能失效。

5.1.3 共享 Worker

如果你的站点或 app 允许加载同一个页面的多个 tab(一个常见的功能),那你可能非常希望通过防止重复专用 Worker 来降低系统的资源使用。在这一方面最常见的有限资源就是 socket 网络连接,因为浏览器限制了到同一个主机的同时连接数目。当然,限制来自于同一客户端的连接数也减轻了你的资源压力。

在这种情况下,创建一个整个站点或 app 的所有页面实例都可以共享的中心 Worker 就非常有用了。

这称为 SharedWorker ,可通过下面的方式创建(只有 Firefox 和 Chrome 支持这一功能):

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

因为共享 Worker 可以与站点的多个程序实例或多个页面连接,所以这个 Worker 需要通过某种方式来得知消息来自于哪个程序。这个唯一标识符称为端口 (port),可以类比网络 socket 的端口。因此,调用程序必须使用 Worker 的 port 对象用于通信:

w1.port.addEventListener( "message", handleMessages );

// ..

w1.port.postMessage( "something cool" );

还有,端口连接必须要初始化,形式如下:

w1.port.start();

在共享 Worker 内部,必须要处理额外的一个事件:"connect" 。这个事件为这个特定的连接提供了端口对象。保持多个连接独立的最简单办法就是使用 port 上的闭包(参见本系列《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分),就像下面的代码一样,把这个链接上的事件侦听和传递定义在 "connect" 事件的处理函数内部:

// 在共享Worker内部
addEventListener( "connect", function(evt){
  // 这个连接分配的端口
  var port = evt.ports[0];

  port.addEventListener( "message", function(evt){
    // ..

    port.postMessage( .. );

    // ..
  } );

  // 初始化端口连接
  port.start();
} );

除了这个区别之外,共享和专用 Worker 在功能和语义方面都是一样的。

如果有某个端口连接终止而其他端口连接仍然活跃,那么共享 Worker 不会终止。而对专用 Worker 来说,只要到实例化它的程序的连接终止,它就会终止。

5.1.4 模拟 Web Worker

从性能的角度来说,将 Web Worker 用于并行运行 JavaScript 程序是非常有吸引力的方案。但是,由于环境所限。你可能需要在缺乏对此支持的更老的浏览器中运行你的代码。因为 Worker 是一种 API 而不是语法,所以我们可以作为扩展来模拟它。

如果浏览器不支持 Worker,那么从性能的角度来说是没法模拟多线程的。通常认为 Iframe 提供了并行环境,但是在所有的现代浏览器中,它们实际上都是和主页面运行在同一个线程中的,所以并不足以模拟并发。

就像我们在第 1 章中详细讨论的,JavaScript 的异步(不是并行)来自于事件循环队列,所以可使用定时器(setTimeout(..) 等)强制模拟实现异步的伪 Worker。然后你只需要提供一个 Worker API 的封装。Modernizr GitHub 页面(http://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills#web-workers )上列出了一些实现,但坦白地说,它们看起来都不太好。

在这一点上,我也编写了一个模拟 Worker 的概要实现(https://gist.github.com/getify/1b26accb1a09aa53ad25 )。它是很基本的,但如果双向消息机制正确工作,并且“onerror ”处理函数也正确工作,那么它应该可以提供简单的 Worker 支持。如果需要的话,你也可以扩展它,实现更多的功能,比如 terminate() 或伪共享 Worker。

因为无法模拟同步阻塞,所以这个封装不支持使用 importScripts(..) 。对此,一个可能的选择是,解析并转换 Worker 的代码(一旦 Ajax 加载之后)来处理重写为某种异步形式的 importScripts(..) 模拟,可能通过支持 promise 的接口。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文