第五章:程序性能
第五章: 程序性能
这本书至此一直是关于如何更有效地利用异步模式。但是我们还没有直接解释为什么异步对于 JS 如此重要。最明显明确的理由就是 性能。
举个例子,如果你要发起两个 Ajax 请求,而且他们是相互独立的,但你在进行下一个任务之前需要等到他们全部完成,你就有两种选择来对这种互动建立模型:顺序和并发。
你可以发起第一个请求并等到它完成再发起第二个请求。或者,就像我们在 promise 和 generator 中看到的那样,你可以“并列地”发起两个请求,并在继续下一步之前让一个“门”等待它们全部完成。
显然,后者要比前者性能更好。而更好的性能一般都会带来更好的用户体验。
异步(并发穿插)甚至可能仅仅增强高性能的印象,即便整个程序依然要用相同的时间才成完成。用户对性能的印象意味着一切——如果不能再多的话!——和实际可测量的性能一样重要。
现在,我们想超越局部的异步模式,转而在程序级别的水平上讨论一些宏观的性能细节。
注意: 你可能会想知道关于微性能问题,比如a++
与++a
哪个更快。我们会在下一章“基准分析与调优”中讨论这类性能细节。
Web Workers
如果你有一些处理密集型的任务,但你不想让它们在主线程上运行(那样会使浏览器/UI 变慢),你可能会希望 JavaScript 可以以多线程的方式操作。
在第一章中,我们详细地谈到了关于 JavaScript 如何是单线程的。那仍然是成立的。但是单线程不是组织你程序运行的唯一方法。
想象将你的程序分割成两块儿,在 UI 主线程上运行其中的一块儿,而在一个完全分离的线程上运行另一块儿。
这样的结构会引发什么我们需要关心的问题?
其一,你会想知道运行在一个分离的线程上是否意味着它在并行运行(在多 CPU/内核的系统上),如此在第二个线程上长时间运行的处理将 不会 阻塞主程序线程。否则,“虚拟线程”所带来的好处,不会比我们已经在异步并发的 JS 中得到的更多。
而且你会想知道这两块儿程序是否访问共享的作用域/资源。如果是,那么你就要对付多线程语言(Java,C++等等)的所有问题,比如协作式或抢占式锁定(互斥,等)。这是很多额外的工作,而且不应当轻易着手。
换一个角度,如果这两块儿程序不能共享作用域/资源,你会想知道它们将如何“通信”。
所有这些我们需要考虑的问题,指引我们探索一个在近 HTML5 时代被加入 web 平台的特性,称为“Web Worker”。这是一个浏览器(也就是宿主环境)特性,而且几乎和 JS 语言本身没有任何关系。也就是说,JavaScript 当前 并没有任何特性可以支持多线程运行。
但是一个像你的浏览器那样的环境可以很容易地提供多个 JavaScript 引擎实例,每个都在自己的线程上,并允许你在每个线程上运行不同的程序。你的程序中分离的线程块儿中的每一个都称为一个“(Web)Worker”。这种并行机制叫做“任务并行机制”,它强调将你的程序分割成块儿来并行运行。
在你的主 JS 程序(或另一个 Worker)中,你可以这样初始化一个 Worker:
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 不会相互,或者与主程序共享任何作用域或资源——那会将所有的多线程编程的噩梦带到我们面前——取而代之的是一种连接它们的基本事件消息机制。
w1
Worker 对象是一个事件监听器和触发器,它允许你监听 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"
事件不需要消除任何歧义,因为我们可以确定它只可能来自于这种一对一关系——不是从 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 可以访问它自己的几个重要全局变量/特性的拷贝,包括navigator
,location
,JSON
,和applicationCache
。
你还可以使用importScripts(..)
加载额外的 JS 脚本到你的 Worker 中:
// 在Worker内部
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 对象:
// `foo` 是一个 `Uint8Array`
postMessage(foo.buffer, [foo.buffer]);
第一个参数是未经加工的缓冲,而第二个参数是要传送的内容的列表。
不支持 Transferable 对象的浏览器简单地降级到结构化克隆,这意味着性能上的降低,而不是彻底的特性失灵。
共享的 Workers
如果你的网站或应用允许多个标签页加载同一个网页(一个常见的特性),你也许非常想通过防止复制专用 Worker 来降低系统资源的使用量;这方面最常见的资源限制是网络套接字链接,因为浏览器限制同时连接到一个服务器的连接数量。当然,限制从客户端来的链接数也缓和了你的服务器资源需求。
在这种情况下,创建一个单独的中心化 Worker,让你的网站或应用的所有网页实例可以 共享 它是十分有用的。
这称为SharedWorker
,你会这样创建它(仅有 Firefox 与 Chrome 支持此特性):
var w1 = new SharedWorker("http://some.url.1/mycoolworker.js");
因为一个共享 Worker 可以连接或被连接到你的网站上的多个程序实例或网页,Worker 需要一个方法来知道消息来自哪个程序。这种唯一的标识称为“端口(port)”——联想网络套接字端口。所以调用端程序必须使用 Worker 的port
对象来通信:
w1.port.addEventListener("message", handleMessages);
// ..
w1.port.postMessage("something cool");
另外,端口连接必须被初始化,就像这样:
w1.port.start();
在共享 Worker 内部,一个额外的事件必须被处理:"connect"
。这个事件为这个特定的连接提供端口object
。保持多个分离的连接最简单的方法是在port
上使用闭包,就像下面展示的那样,同时在"connect"
事件的处理器内部定义这个连接的事件监听与传送:
// 在共享Worker的内部
addEventListener( "connect", function(evt){
// 为这个连接分配的端口
var port = evt.ports[0];
port.addEventListener( "message", function(evt){
// ..
port.postMessage( .. );
// ..
} );
// 初始化端口连接
port.start();
} );
除了这点不同,共享与专用 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 相关的接口。
SIMD
一个指令,多个数据(SIMD)是一种“数据并行机制”形式,与 Web Worker 的“任务并行机制”相对应,因为他强调的不是程序逻辑的块儿被并行化,而是多个字节的数据被并行地处理。
使用 SIMD,线程不提供并行机制。相反,现代 CPU 用数字的“向量”提供 SIMD 能力——想想:指定类型的数组——还有可以在所有这些数字上并行操作的指令;这些是利用底层操作的指令级别的并行机制。
使 SIMD 能力包含在 JavaScript 中的努力主要是由 Intel 带头的(https://01.org/node/1495 ),名义上是 Mohammad Haghighat(在本书写作的时候),与 Firefox 和 Chrome 团队合作。SIMD 处于早期标准化阶段,而且很有可能被加入未来版本的 JavaScript 中,很可能在 ES7 的时间框架内。
SIMD JavaScript 提议向 JS 代码暴露短向量类型与 API,它们在 SIMD 可用的系统中将操作直接映射为 CPU 指令的等价物,同时在非 SIMD 系统中退回到非并行化操作的“shim”。
对于数据密集型的应用程序(信号分析,对图形的矩阵操作等等)来说,这种并行数学处理在性能上的优势是十分明显的!
在本书写作时,SIMD API 的早期提案形式看起来像这样:
var v1 = SIMD.float32x4(3.14159, 21.0, 32.3, 55.55);
var v2 = SIMD.float32x4(2.1, 3.2, 4.3, 5.4);
var v3 = SIMD.int32x4(10, 101, 1001, 10001);
var v4 = SIMD.int32x4(10, 20, 30, 40);
SIMD.float32x4.mul(v1, v2); // [ 6.597339, 67.2, 138.89, 299.97 ]
SIMD.int32x4.add(v3, v4); // [ 20, 121, 1031, 10041 ]
这里展示了两种不同的向量数据类型,32 位浮点数和 32 位整数。你可以看到这些向量正好被设置为 4 个 32 位元素,这与大多数 CPU 中可用的 SIMD 向量的大小(128 位)相匹配。在未来我们看到一个x8
(或更大!)版本的这些 API 也是可能的。
除了mul()
和add()
,许多其他操作也很可能被加入,比如sub()
,div()
,abs()
,neg()
,sqrt()
,reciprocal()
,reciprocalSqrt()
(算数运算),shuffle()
(重拍向量元素),and()
,or()
,xor()
,not()
(逻辑运算),equal()
,greaterThan()
,lessThan()
(比较运算),shiftLeft()
,shiftRightLogical()
,shiftRightArithmetic()
(轮换),fromFloat32x4()
,和fromInt32x4()
(变换)。
注意: 这里有一个 SIMD 功能的官方“填补”(很有希望,预期的,着眼未来的填补)(https://github.com/johnmccutchan/ecmascript_simd ),它描述了许多比我们在这一节中没有讲到的许多计划中的 SIMD 功能。
asm.js
“asm.js”(http://asmjs.org/ )是可以被高度优化的 JavaScript 语言子集的标志。通过小心地回避那些特定的很难优化的(垃圾回收,强制转换,等等)机制和模式,asm.js 风格的代码可以被 JS 引擎识别,而且用主动地底层优化进行特殊的处理。
与本章中讨论的其他性能优化机制不同的是,asm.js 没必须要是必须被 JS 语言规范所采纳的东西。确实有一个 asm.js 规范(http://asmjs.org/spec/latest/ ),但它主要是追踪一组关于优化的候选对象的推论,而不是 JS 引擎的需求。
目前还没有新的语法被提案。取而代之的是,ams.js 建议了一些方法,用来识别那些符合 ams.js 规则的既存标准 JS 语法,并且让引擎相应地实现它们自己的优化功能。
关于 ams.js 应当如何在程序中活动的问题,在浏览器生产商之间存在一些争议。早期版本的 asm.js 实验中,要求一个"use asm";
编译附注(与 strict 模式的"use strict";
类似)来帮助 JS 引擎来寻找 asm.js 优化的机会和提示。另一些人则断言 asm.js 应当只是一组启发式算法,让引擎自动地识别而不用作者做任何额外的事情,这意味着理论上既存的程序可以在不用做任何特殊的事情的情况下从 asm.js 优化中获益。
如何使用 asm.js 进行优化
关于 asm.js 需要理解的第一件事情是类型和强制转换。如果 JS 引擎不得不在变量的操作期间一直追踪一个变量内的值的类型,以便于在必要时它可以处理强制转换,那么就会有许多额外的工作使程序处于次优化状态。
注意: 为了说明的目的,我们将在这里使用 ams.js 风格的代码,但要意识到的是你手写这些代码的情况不是很常见。asm.js 的本意更多的是作为其他工具的编译目标,比如 Emscripten(https://github.com/kripken/emscripten/wiki )。当然你写自己的 asm.js 代码也是可能的,但是这通常不是一个好主意,因为那样的代码非常底层,而这意味着它会非常耗时而且易错。尽管如此,也会有情况使你想要为了 ams.js 优化的目的手动调整代码。
这里有一些“技巧”,你可以使用它们来提示支持 asm.js 的 JS 引擎变量/操作预期的类型是什么,以便于它可以跳过那些强制转换追踪的步骤。
举个例子:
var a = 42;
// ..
var b = a;
在这个程序中,赋值b = a
在变量中留下了类型分歧的问题。然而,它可以写成这样:
var a = 42;
// ..
var b = a | 0;
这里,我们与值0
一起使用了|
(“二进制或”),虽然它对值没有任何影响,但它确保这个值是一个 32 位整数。这段代码在普通的 JS 引擎中可以工作,但是当它运行在支持 asm.js 的 JS 引擎上时,它 可以 表示b
应当总是被作为 32 位整数来对待,所以强制转换追踪可以被跳过。
类似地,两个变量之间的加法操作可以被限定为性能更好的整数加法(而不是浮点数):
(a + b) | 0;
再一次,支持 asm.js 的 JS 引擎可以看到这个提示,并推断+
操作应当是一个 32 位整数加法,因为不论怎样整个表达式的最终结果都将自动是 32 位整数。
asm.js 模块
在 JS 中最托性能后腿的东西之一是关于内存分配,垃圾回收,与作用域访问。asm.js 对于这些问题建一个的一个方法是,声明一个更加正式的 asm.js“模块”——不要和 ES6 模块搞混;参见本系列的 ES6 与未来。
对于一个 asm.js 模块,你需要明确传入一个被严格遵循的名称空间——在规范中以stdlib
引用,因为它应当代表需要的标准库——来引入需要的符号,而不是通过词法作用域来使用全局对象。在最基本的情况下,window
对象就是一个可接受的用于 asm.js 模块的stdlib
对象,但是你可能应该构建一个更加被严格限制的对象。
你还必须定义一个“堆(heap)”——这只是一个别致的词汇,它表示在内存中被保留的位置,变量不必要求内存分配或释放已使用内存就可以使用——并将它传入,这样 asm.js 模块就不必做任何导致内存流失的的事情;它可以使用提前保留的空间。
一个“堆”就像一个有类型的ArrayBuffer
,比如:
var heap = new ArrayBuffer(0x10000); // 64k 的堆
使用这个提前保留的 64k 的二进制空间,一个 asm.js 模块可以在这个缓冲区中存储或读取值,而不受任何内存分配与垃圾回收的性能损耗。比如,heap
缓冲区可以在模块内部用于备份一个 64 位浮点数值的数组,像这样:
var arr = new Float64Array(heap);
好了,让我制作一个 asm.js 风格模块的快速,愚蠢的例子来描述这些东西是如何联系在一起的。我们将定义一个foo(..)
,它为一个范围接收一个开始位置(x
)和一个终止位置(y
),并且计算这个范围内所有相邻的数字的积,然后最终计算这些值的平均值:
function fooASM(stdlib, foreign, heap) {
"use asm";
var arr = new stdlib.Int32Array(heap);
function foo(x, y) {
x = x | 0;
y = y | 0;
var i = 0;
var p = 0;
var sum = 0;
var count = ((y | 0) - (x | 0)) | 0;
// 计算范围内所有相邻的数字的积
for (i = x | 0; (i | 0) < (y | 0); p = (p + 8) | 0, i = (i + 1) | 0) {
// 存储结果
arr[p >> 3] = (i * (i + 1)) | 0;
}
// 计算所有中间值的平均值
for (
i = 0, p = 0;
(i | 0) < (count | 0);
p = (p + 8) | 0, i = (i + 1) | 0
) {
sum = (sum + arr[p >> 3]) | 0;
}
return +(sum / count);
}
return {
foo: foo,
};
}
var heap = new ArrayBuffer(0x1000);
var foo = fooASM(window, null, heap).foo;
foo(10, 20); // 233
注意: 这个 asm.js 例子是为了演示的目的手动编写的,所以它与那些支持 asm.js 的编译工具生产的代码的表现不同。但是它展示了 asm.js 代码的典型性质,特别是类型提示与为了临时变量存储而使用heap
缓冲。
第一个fooASM(..)
调用用它的heap
分配区建立了我们的 asm.js 模块。结果是一个我们可以调用任意多次的foo(..)
函数。这些调用应当会被支持 asm.js 的 JS 引擎特别优化。重要的是,前面的代码完全是标准 JS,而且会在非 asm.js 引擎中工作的很好(但没有特别优化)。
很明显,使 asm.js 代码可优化的各种限制降低了广泛使用这种代码的可能性。对于任意给出的 JS 程序,asm.js 没有必要为成为一个一般化的优化集合。相反,它的本意是提供针对一种处理特定任务——如密集数学操作(那些用于游戏中图形处理的)——的优化方法。
复习
本书的前四章基于这样的前提:异步编码模式给了你编写更高效代码的能力,这通常是一个非常重要的改进。但是异步行为也就能帮你这么多,因为它在基础上仍然使用一个单独的事件轮询线程。
所以在这一章我们涵盖了几种程序级别的机制来进一步提升性能。
Web Worker 让你在一个分离的线程上运行一个 JS 文件(也就是程序),使用异步事件在线程之间传递消息。对于将长时间运行或资源密集型任务挂载到一个不同线程,从而让主 UI 线程保持相应来说,它们非常棒。
SIMD 提议将 CPU 级别的并行数学操作映射到 JavaScript API 上来提供高性能数据并行操作,比如在大数据集合上进行数字处理。
最后,asm.js 描述了一个 JavaScript 的小的子集,它回避了 JS 中不易优化的部分(比如垃圾回收与强制转换)并让 JS 引擎通过主动优化识别并运行这样的代码。asm.js 可以手动编写,但是极其麻烦且易错,就像手动编写汇编语言。相反,asm.js 的主要意图是作为一个从其他高度优化的程序语言交叉编译来的目标——例如,Emscripten(https://github.com/kripken/emscripten/wiki )可以将 C/C++转译为 JavaScript。
虽然在本章没有明确地提及,在很早以前的有关 JavaScript 的讨论中存在着更激进的想法,包括近似地直接多线程功能(不仅仅是隐藏在数据结构 API 后面)。无论这是否会明确地发生,还是我们将看到更多并行机制偷偷潜入 JS,但是在 JS 中发生更多程序级别优化的未来是可以确定的。