第五章: 程序性能
这本书至此一直是关于如何更有效地利用异步模式. 但是我们还没有直接解释为什么异步对于 JS 如此重要. 最明显明确的理由就是 性能.
举个例子, 如果你要发起两个 Ajax 请求, 而且他们是相互独立的, 但你在进行下一个任务之前需要等到他们全部完成, 你就有两种选择来对这种互动建立模型: 顺序和并发.
你可以发起第一个请求并等到它完成再发起第二个请求. 或者, 就像我们在 promise 和 generator 中看到的那样, 你可以 "并列地" 发起两个请求, 并在继续下一步之前让一个 "门" 等待它们全部完成.
显然, 后者要比前者性能更好. 而更好的性能一般都会带来更好的用户体验.
异步 (并发穿插) 甚至可能仅仅增强高性能的印象, 即便整个程序依然要用相同的时间才成完成. 用户对性能的印象意味着一切 -- 如果不能再多的话!-- 和实际可测量的性能一样重要.
现在, 我们想超越局部的异步模式, 转而在程序级别的水平上讨论一些宏观的性能细节.
注意: 你可能会想知道关于微性能问题, 比如 a++ 与 ++a 哪个更快. 我们会在下一章 "基准分析与调优" 中讨论这类性能细节.
1. 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 不会相互, 或者与主程序共享任何作用域或资源 -- 那会将所有的多线程编程的噩梦带到我们面前 -- 取而代之的是一种连接它们的基本事件消息机制.
w1Worker 对象是一个事件监听器和触发器, 它允许你监听 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 对象的浏览器简单地降级到结构化克隆, 这意味着性能上的降低, 而不是彻底的特性失灵.
2. 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 功能.
3. 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 位整数.
复习
本书的前四章基于这样的前提: 异步编码模式给了你编写更高效代码的能力, 这通常是一个非常重要的改进. 但是异步行为也就能帮你这么多, 因为它在基础上仍然使用一个单独的事件轮询线程.
所以在这一章我们涵盖了几种程序级别的机制来进一步提升性能.
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 中发生更多程序级别优化的未来是可以确定的.
来源: https://juejin.im/post/5b03ec26f265da0b7452800c