【你不懂JS: 异步与性能】第五章: 程序性能

2018年5月25日64

这本书至此一直是关于如何更有效地利用异步模式。但是我们还没有直接解释为什么异步对于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:

  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)会用于拷贝/复制这个对象。这个算法相当精巧,甚至可以处理带有循环引用的对象复制。to-string/from-string的性能劣化没有了,但用这种方式我们依然面对着内存用量的翻倍。IE10以上版本,和其他主流浏览器都对此有支持。

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

选择使用Transferable对象不需要你做太多;任何实现了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的填补就行了。这里有一份列表,但坦白地说它们看起来都不怎么样。

我在这里写了一个填补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的早期提案形式看起来像这样:

  1. var v1 = SIMD.float32x4( 3.14159, 21.0, 32.3, 55.55 );
  2. var v2 = SIMD.float32x4( 2.1, 3.2, 4.3, 5.4 );
  3. var v3 = SIMD.int32x4( 10, 101, 1001, 10001 );
  4. var v4 = SIMD.int32x4( 10, 20, 30, 40 );
  5. SIMD.float32x4.mul( v1, v2 ); // [ 6.597339, 67.2, 138.89, 299.97 ]
  6. 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功能的官方“填补”(很有希望,预期的,着眼未来的填补),它描述了许多比我们在这一节中没有讲到的许多计划中的SIMD功能。

asm.js

“asm.js”是可以被高度优化的JavaScript语言子集的标志。通过小心地回避那些特定的很难优化的(垃圾回收,强制转换,等等)机制和模式,asm.js风格的代码可以被JS引擎识别,而且用主动地底层优化进行特殊的处理。

与本章中讨论的其他性能优化机制不同的是,asm.js没必须要是必须被JS语言规范所采纳的东西。确实有一个asm.js规范,但它主要是追踪一组关于优化的候选对象的推论,而不是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。当然你写自己的asm.js代码也是可能的,但是这通常不是一个好主意,因为那样的代码非常底层,而这意味着它会非常耗时而且易错。尽管如此,也会有情况使你想要为了ams.js优化的目的手动调整代码。

这里有一些“技巧”,你可以使用它们来提示支持asm.js的JS引擎变量/操作预期的类型是什么,以便于它可以跳过那些强制转换追踪的步骤。

举个例子:

  1. var a = 42;
  2. // ..
  3. var b = a;

在这个程序中,赋值b = a在变量中留下了类型分歧的问题。然而,它可以写成这样:

  1. var a = 42;
  2. // ..
  3. var b = a | 0;

这里,我们与值0一起使用了|(“二进制或”),虽然它对值没有任何影响,但它确保这个值是一个32位整数。这段代码在普通的JS引擎中可以工作,但是当它运行在支持asm.js的JS引擎上时,它 可以 表示b应当总是被作为32位整数来对待,所以强制转换追踪可以被跳过。

类似地,两个变量之间的加法操作可以被限定为性能更好的整数加法(而不是浮点数):

  1. (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,比如:

  1. var heap = new ArrayBuffer( 0x10000 ); // 64k 的堆

使用这个提前保留的64k的二进制空间,一个asm.js模块可以在这个缓冲区中存储或读取值,而不受任何内存分配与垃圾回收的性能损耗。比如,heap缓冲区可以在模块内部用于备份一个64位浮点数值的数组,像这样:

  1. var arr = new Float64Array( heap );

好了,让我制作一个asm.js风格模块的快速,愚蠢的例子来描述这些东西是如何联系在一起的。我们将定义一个foo(..),它为一个范围接收一个开始位置(x)和一个终止位置(y),并且计算这个范围内所有相邻的数字的积,然后最终计算这些值的平均值:

  1. function fooASM(stdlib,foreign,heap) {
  2. "use asm";
  3. var arr = new stdlib.Int32Array( heap );
  4. function foo(x,y) {
  5. x = x | 0;
  6. y = y | 0;
  7. var i = 0;
  8. var p = 0;
  9. var sum = 0;
  10. var count = ((y|0) - (x|0)) | 0;
  11. // 计算范围内所有相邻的数字的积
  12. for (i = x | 0;
  13. (i | 0) < (y | 0);
  14. p = (p + 8) | 0, i = (i + 1) | 0
  15. ) {
  16. // 存储结果
  17. arr[ p >> 3 ] = (i * (i + 1)) | 0;
  18. }
  19. // 计算所有中间值的平均值
  20. for (i = 0, p = 0;
  21. (i | 0) < (count | 0);
  22. p = (p + 8) | 0, i = (i + 1) | 0
  23. ) {
  24. sum = (sum + arr[ p >> 3 ]) | 0;
  25. }
  26. return +(sum / count);
  27. }
  28. return {
  29. foo: foo
  30. };
  31. }
  32. var heap = new ArrayBuffer( 0x1000 );
  33. var foo = fooASM( window, null, heap ).foo;
  34. 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可以将C/C++转译为JavaScript。

虽然在本章没有明确地提及,在很早以前的有关JavaScript的讨论中存在着更激进的想法,包括近似地直接多线程功能(不仅仅是隐藏在数据结构API后面)。无论这是否会明确地发生,还是我们将看到更多并行机制偷偷潜入JS,但是在JS中发生更多程序级别优化的未来是可以确定的。


你不懂JS(系列丛书)-网页版目录
上一篇 ●【你不懂JS: 异步与性能】第四章: Generator
下一篇 ● 【你不懂JS: 异步与性能】第六章: 基准分析与调优

如非特别注明,文章皆为原创。

转载请注明出处: https://www.liayal.com/article/5b07ed224b90272bebb17a68

你不想说点啥么?
😀😃😄😁😆😅😂🤣☺️😊😇🙂🙃😉😌😍😘😗😙😚😋😜😝😛🤑🤗🤓😎🤡🤠😏😒😞😔😟😕🙁☹️😣😖😫😩😤😠😡😶😐😑😯😦😧😮😲😵😳😱😨😰😢😥🤤😭😓😪😴🙄🤔🤥😬🤐🤢🤧😷🤒🤕😈👿👹👺💩👻💀☠️👽👾🤖🎃😺😸😹😻😼😽🙀😿😾👐👐🏻👐🏼👐🏽👐🏾👐🏿🙌🙌🏻🙌🏼🙌🏽🙌🏾🙌🏿👏👏🏻👏🏼👏🏽👏🏾👏🏿🙏🙏🏻🙏🏼🙏🏽🙏🏾🙏🏿🤝👍👍🏻👍🏼👍🏽👍🏾👍🏿👎👎🏻👎🏼👎🏽👎🏾👎🏿👊👊🏻👊🏼👊🏽👊🏾👊🏿✊🏻✊🏼✊🏽✊🏾✊🏿

评论

~ 评论还没有,沙发可以有 O(∩_∩)O~