对于中卷的部分,之前已经分了两篇文章进行了介绍。
分别是中卷一和中卷二
但是真的由于内容很多,很精华,每一章节单拿出来都可以写成几篇文章。
就算仅仅记录表面意思,仍然足以撑满篇幅。
今天我们开始记录性能部分内容,真的是中卷的最后一篇文章啦。
程序性能
Web Worker
如果你有一些处理密集型的任务要执行,但不希望它们都在主线程运行(这可能会减慢浏览器/UI),可能你就会希望JavaScript能够以多线程的方式运行。
像浏览器这样的环境,很容易提供多个JavaScript引擎实例,各自运行在自己的线程上,这样你可以在每个线程上运行不同的程序。
程序中每一个这样的独立的多线程部分被称为一个(Web)Worker。
这种类型的并行化被称为任务并行,因为其重点在于把程序划分为多个块来并发运行。
从JavaScript主程序(或另一个Worker)中,可以这样实例化一个Worker:
1 | var w1 = new Worker('http://some.url.1/mycoolworker.js') |
这个URL应该指向一个JavaScript文件的位置(而不是一个HTML页面!),这个文件将被加载到一个Worker中。
然后浏览器启动一个独立的线程,让这个文件在这个线程中作为独立的程序运行。
Worker之间以及它们和主程序之间,不会共享任何作用域或资源,那会把所有多线程编程的噩梦带到前端领域,而是通过一个基本的事件消息机制相互联系。
Worker w1对象是一个事件侦听者和触发者,可以通过订阅它来获得这个Worker发出的事件以及发送事件给这个Worker。
1 | // 'mycoolworker.js' |
注意,专用Worker和创建它的程序之间是一对一的关系。
也就是说,”message”事件没有任何歧义需要消除,因为我们确定它只能来自这个一对一的关系:它要么来自这个Worker,要么来自主页面。
Worker环境
在Worker内部是无法访问主程序的任何资源的。
这意味着你不能访问它的任何全局变量,也不能访问页面的DOM或者其他资源。
记住,这是一个完全独立的线程。
但是,你可以执行网络操作(Ajax、WebSockets)以及设定定时器。
还有,Worker可以访问几个重要的全局变量和功能的本地复本,包括navigator、location、JSON和applicationCache。
你还可以通过importScripts(..)向Worker加载额外的JavaScript脚本:
1 | // worker 内部 |
这些脚本加载是同步的。
也就是说,importScripts(..)
调用会阻塞余下Worker的执行,直到文件加载和执行完成。
数据传递
如果要传递一个对象,可以使用结构化克隆算法(structured clone algorithm)把这个对象复制到另一边。
这个算法非常高级,甚至可以处理要复制的对象有循环引用的情况。
这样就不用付出to-string和from-string的性能损失了,但是这种方案还是要使用双倍的内存。IE10及更高版本以及所有其他主流浏览器都支持这种方案。
这里有一篇文章关于结构化克隆算法的应用。
还有一个更好的选择,特别是对于大数据集而言,就是使用Transferable对象。
这时发生的是对象所有权的转移,数据本身并没有移动。
一旦你把对象传递到一个Worker中,在原来的位置上,它就变为空的或者是不可访问的,这样就消除了多线程编程作用域共享带来的混乱。
当然,所有权传递是可以双向进行的。
如果选择Transferable对象的话,其实不需要做什么。
任何实现了Transferable接口的数据结构就自动按照这种方式传输(Firefox和Chrome都支持)
下面是如何使用postMessage(..)发送一个Transferable对象:
1 | // 比如 Unit8Array |
第一个参数是一个原始缓冲区,第二个是一个要传输的内容的列表。
不支持Transferable对象的浏览器就降级到结构化克隆,这会带来性能下降而不是彻底的功能失效。```
共享Worker
共享Worker可以与站点的多个程序实例或多个页面连接,所以这个Worker需要通过某种方式来得知消息来自于哪个程序。
这个唯一标识符称为端口(port),可以类比网络socket的端口。因此,调用程序必须使用Worker的port对象用于通信:
1 | w1.port.addEventListener('message',handleMessage) |
还有,端口连接必须要初始化,形式如下:
1 | w1.port.start() |
在共享Worker内部,必须要处理额外的一个事件:”connect”。
这个事件为这个特定的连接提供了端口对象。
保持多个连接独立的最简单办法就是使用port上的闭包,就像下面的代码一样,把这个链接上的事件侦听和传递定义在”connect”事件的处理函数内部:
1 | // worker 内部 |
SMID
单指令多数据(SIMD)是一种数据并行(data parallelism)方式,与WebWorker的任务并行(task parallelism)相对,因为这里的重点实际上不再是把程序逻辑分成并行的块,而是并行处理数据的多个位。
通过SIMD,线程不再提供并行。
取而代之的是,现代CPU通过数字“向量”(特定类型的数组),以及可以在所有这些数字上并行操作的指令,来提供SIMD功能。
这是利用低级指令级并行的底层运算。
1 | var v1 = SMID.float32x4(3.14159, 21.0, 32.3, 55.55) |
asm.js
asm.js这个标签是指JavaScript语言中可以高度优化的一个子集。
通过小心避免某些难以优化的机制和模式(垃圾收集、类型强制转换,等等), asm.js风格的代码可以被JavaScript引擎识别并进行特别激进的底层优化。
性能测试与调优
性能测试
1 | var start = (new Date()).getTime() |
使用这个方法测试某个运算的速度(执行时间)有很多错误。
重复
如果想要用重复来测试,要确保把异常因素排除,你需要大量的样本来平均化。
你还会想要知道最差样本有多慢,最好的样本有多快,以及最好和最差情况之间的偏离度有多大,等等。
司仪重复也不是正确的方法。
Benchmark.js
任何有意义且可靠的性能测试都应该基于统计学上合理的实践。
1 | function foo() { |
环境为王
对特定的性能测试来说,不要忘了检查测试环境,特别是比较任务Ⅹ和Y这样的比对测试。
仅仅因为你的测试显示Ⅹ比Y快,并不能说明结论Ⅹ比Y快就有实际的意义。
引擎优化
我们设想的所有优化可能性在受限的测试中都有可能发生,而且在更复杂的程序中(出于各种各样的原因),引擎可能不会进行这样的优化。
也可能恰恰相反,引擎可能不会优化这样无关紧要的代码,但是在系统已经在运行更复杂的程序时可能会倾向于激进的优化。
这是不是意味着无法真正进行任何有用的测试呢?绝对不是!
测试不真实的代码只能得出不真实的结论。
如果有实际可能的话,你应该测试实际的而非无关紧要的代码,测试条件与你期望的真实情况越接近越好。
只有这样得出的结果才有可能接近事实。
像++x对比x++这样的微观性能测试结果为虚假的可能性相当高,可能我们最好就假定它们是假的。
jsPerf.com
如果想要在不止一个环境下得出像“Ⅹ比Y快”这样的有意义的结论成立,那你需要在尽可能多的真实环境下进行实际测试。仅仅因为在Chrome上某个Ⅹ运算比Y快并不意味着这在所有的浏览器中都成立。
当然你可能还想要交叉引用多个浏览器上的测试运行结果,并有用户的图形展示。
有一个很棒的网站正是因这样的需求而诞生的,名为jsPerf
写好测试
要写好测试,需要认真分析和思考两个测试用例之间有什么区别,以及这些区别是有意还是无意的。
编写更好更清晰的测试。
但还有,花一些时间来编写文档(使用jsPerf.com上的Description字段和/或代码注释)精确表达你的测试目的,甚至对于那些微小的细节也要如此。
找出那些有意的区别,这会帮助别人和未来的你更好地识别出那些可能扭曲测试结果的无意区别。
不要试图窄化到真实代码的微小片段,以及脱离上下文而只测量这一小部分的性能,因为包含更大(仍然有意义的)上下文时功能测试和性能测试才会更好。
这些测试可能也会运行得慢一点,这意味着环境中发现的任何差异都更有意义。
微性能
在考虑对代码进行性能测试时,你应该习惯的第一件事情就是你所写的代码并不总是引擎真正运行的代码。
来考虑下面这段代码:
1 | var foo = 41; |
可能你会认为最内层函数中的引用foo需要进行三层作用域查找。
事实上,编译器通常会缓存这样的查找结果,使得从不同的作用域引用foo实际上并没有任何额外的花费。
但是,还有一些更深入的问题需要思考。
如果编译器意识到这个foo只在一个位置被引用而别处没有任何引用,并且注意到这个值只是41而从来不会变成其他值呢?
JavaScript可能决定完全去掉foo变量,将其值在线化,这不是很可能发生也可以接受的吗?就像下面这样:
1 | (function() { |
当你把JavaScript代码看作对引擎要做什么的提示和建议,而不是逐字逐句的要求时,你就会意识到,对于具体语法细节的很多执着迷恋已经烟消云散了。
这里是另一个常见的愚蠢的执迷于微观性能的例子:
1 | var x = [...] |
理论上说,这里应该在变量len中缓存x数组的长度,因为表面上它不会改变,来避免在每个循环迭代中计算x.length的代价。
如果运行性能测试来比较使用x.length和将其缓存到len变量中的方案,你会发现尽管理论听起来没错,但实际的可测差别在统计上是完全无关紧要的。
实际上,在某些像v8这样的引擎中,可以看到,预先缓存长度而不是让引擎为你做这件事情,会使性能稍微下降一点。
不要试图和JavaScript引擎比谁聪明。对性能优化来说,你很可能会输。
不是所有的引擎都类似
引擎可以自由决定一个运算是否需要优化,可能进行权衡,替换掉运算次要性能。
对一个运算来说,很难找到一种方法使其在所有浏览器中都运行得较快
在一些JavaScript开发社区有一场运动,特别是在那些使用Node.js工作的开发者中间。
这场运动是要分析v8 JavaScript引擎的特定内部实现细节,决定编写裁剪过的JavaScript代码来最大程度地利用v8的工作模式。
通过这样的努力,你可能会获得令人吃惊的高度性能优化。因此,这种努力的回报可能会很高。
大局
怎么知道什么是大局呢?
首先要了解你的代码是否运行在关键路径上。
如果不在关键路径上,你的优化就很可能得不到很大的收益。
有没有听过“这是过早优化”这样的警告?
这来自于高德纳著名的一句话:“过早优化是万恶之源。”
很多开发者都会引用这句话来说明多数优化都是“过早的”,因此是白费力气。和通常情况一样,事实要更加微妙一些。
尽管程序关键路径上的性能非常重要,但这并不是唯一要考虑的因素。
在性能方面大体相似的几个选择中,可读性应该是另外一个重要的考量因素。
尾调用优化
尾调用优化(Tail Call Optimization, TCO)
尾调用就是一个出现在另一个函数“结尾”处的函数调用。
这个调用结束后就没有其余事情要做了(除了可能要返回结果值)
1 | function foo(x) { |
调用一个新的函数需要额外的一块预留内存来管理调用栈,称为栈帧。
所以前面的代码一般会同时需要为每个baz()、bar(..)和foo(..)保留一个栈帧。
然而,如果支持TCO的引擎能够意识到foo(y+1)调用位于尾部,这意味着bar(..)基本上已经完成了,那么在调用foo(..)时,它就不需要创建一个新的栈帧,而是可以重用已有的bar(..)的栈帧。
这样不仅速度更快,也更节省内存。
在简单的代码片段中,这类优化算不了什么,但是在处理递归时,这就解决了大问题,特别是如果递归可能会导致成百上千个栈帧的时候。
有了TCO,引擎可以用同一个栈帧执行所有这类调用!
递归是JavaScript中一个纷繁复杂的主题。
因为如果没有TCO的话,引擎需要实现一个随意(还彼此不同!)的限制来界定递归栈的深度,达到了就得停止,以防止内存耗尽。
有了TCO,尾调用的递归函数本质上就可以任意运行,因为再也不需要使用额外的内存!
ES6之所以要求引擎实现TCO而不是将其留给引擎自由决定,一个原因是缺乏TCO会导致一些JavaScript算法因为害怕调用栈限制而降低了通过递归实现的概率。
最后今天冬至了,愿大家都能吃上一碗热乎的饺子🥟!
- 本文作者: Jambo
- 版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!