- 深入浅出WebAssembly
- 于航
- 14762字
- 2020-08-27 23:28:39
1.1 JavaScript的发展和弊端
一直以来,在Web前端领域我们都主要使用JavaScript语言来编写运行在浏览器上的Web应用。不仅如此,随着 React Native、Electron 及 Vue.js 等用于各种目的的开发框架的出现,JavaScript 语言正变得越来越流行,直至一跃成为 Github 语言排行榜的年度冠军。但反观如今的各类Web应用,其功能逐渐复杂化,对性能的要求逐渐提高。而浏览器作为运行平台,虽然其内部的JavaScript引擎也在不断被优化,但限于JavaScript语言本身的一些特性,根本无法满足日益增长的应用性能需求。
1.1.1 快速发展与基准测试
自1997年 ECMAScript 1.1版本标准作为一个正式草案被提交给欧洲计算机制造商协会(ECMA),使得ECMAScript这种脚本语言规范开始逐渐走向标准化,一直到2017年6月,ECMAScript 2017(ES8)标准的正式发布,ECMAScript标准的不断发展带动着JavaScript这门以其作为标准实现的脚本语言不知不觉地走过了20个年头。
在这20个年头里,不只是ECMAScript标准本身在语法和特性上有了翻天覆地的变化,包括用来解析和执行JavaScript脚本语言的 Web 浏览器、用来快速进行前端构建的各种前端JavaScript框架、基于Chrome V8用来进行服务端应用开发的Node.js运行时引擎,甚至是那些用于辅助前端应用开发的包括各种构建和自动化工具在内的与JavaScript相关的开源软件,都有了十足的发展。
首先值得一提的是JavaScript这门脚本语言在编程语法和功能特性上的发展。自1997年ECMAScript标准诞生一直到2015年,在这十几年时间里,ECMAScript标准本身并没有做过太多的改动,只是在不断调整语言稳定性的过程中,使语义更加严格,同时还增加了少许的新特性。1999年发布的ECMAScript 3版本增加了对“正则表达式”的支持,而在这其后的整整十年内,ECMAScript标准一直处于一个相对的平稳期,其间没有做任何修订和改动。直到十年后的2009年,在ECMAScript 5标准中又增加了“严格模式”,以及对“JSON”这种轻型数据交换格式进行编/解码等操作的支持(除此之外,还有少许其他新特性)。在六年后的2015年,ECMAScript标准迎来了史上最大的一次在语言特性上的改动,那就是ECMAScript 6标准的诞生。ECMAScript 6标准提供了很多丰富的新特性和语法糖,包括从Python借鉴来的迭代器和生成器特性、集合类型、箭头函数、类型数组,以及可以用于面向OOP编程的“类”关键字和用于元编程的“代理”特性等。自ECMAScript 6标准发布开始,在接下来的两年内ECMAScript标准又相继发布了ECMAScript 2017和ECMAScript 2018版本,同时ECMAScript标准也开始以发布年份作为版本号重新命名,从此ECMAScript标准也进入了每年一次版本迭代的快速发展新时代。ECMAScript标准的快速发展也同样促使前端开发领域跟着快速发展,一大批前端JavaScript框架和前端技术架构体系也由此诞生(这里ECMAScript 6对应ECMAScript 2015)。
从十年前擅长直接操作DOM对象的jQuery到十年后以MVVM模式架构见长的React以及Vue.js等框架,前端框架的发展突飞猛进,不断地改变着人们在前端应用领域的日常开发模式。从最初碎片组件化的开发模式到现在已经逐渐成体系化、完整组件化和分层次的开发模式,前端开发的效率也在不断提升。当然,前端框架的日益增多也离不开JavaScript这门编程语言所应用的领域变得愈加广泛。现在JavaScript语言可以被应用在Web前端开发、移动端APP开发、服务端应用开发、桌面端应用开发、深度学习应用开发甚至是硬件开发等多个领域。不仅如此,自基于V8构建的Node.js引擎出现后,JavaScript语言也正逐渐“力挽狂澜”,变得“无所不能”。
通过统计近十年来(从2008年1月1日到2017年11月1日)全球开发者每年在Github上所创建的 JavaScript 开源项目的数量(如图1-1所示),可以发现,每年所创建的 JavaScript开源项目的数量与前一年相比几乎都呈指数型增长。毋庸置疑,2015年是JavaScript最流行的一年,从2014年到2016年的三年时间里,Angular.js、React和Vue.js这三个Web前端开发框架“巨头”相继出现,在Web前端开发领域呈分庭抗礼之势。
图1-1 近十年来Github上基于JavaScript语言构建的开源项目数量
当然,JavaScript 之所以能够深入到如此众多行业和领域的开发实践当中,也离不开 Web浏览器在性能上的日益优化和提升。以Chrome浏览器的核心JavaScript引擎V8为例,如图1-2所示,从官方Github仓库中每日对其修改提交数量统计数据中可以看到,每天都有30~50个提交被合并到 V8项目的主分支。V8的完整版本号由四位数字组成,形式为“MAJOR.MINOR. BUILD.PATCH”。如某一个V8版本的版本号为“6.3.292.33”,其中第三位数字对应的“BUILD”字段在每次V8重新编译和发布后都会增长。而事实上,该字段对应数字在V8每天的小版本发布中都会有10次以上的增长,可见其版本发布之频繁,性能优化和特性迭代速度之快。
图1-2 Github上V8项目的每日修改提交数量统计图
那么现如今的Web浏览器对JavaScript代码的性能优化到底达到了怎样的程度呢?我们以下面所给出的代码为例,来看一下同样一段耗时的业务逻辑,在不同编程语言的对应实现下,其代码的运行效率和性能表现如何。这里我们还是以 Chrome 的最新版本桌面浏览器作为用来测试该JavaScript应用的容器,采用的Chrome版本是62.0.3202.94 (Official Build)(64-bit)。用于测试的应用其业务逻辑是:初始化两个浮点数类型的变量,然后让其中一个变量的值等于该值与另一个变量的累加和,并重复该过程1亿次,程序会打印出这1亿次累加操作所花费的时间,并重复进行10次。最后再将这10次的1亿次累加和打印出来。
首先,我们基于原生C++语言编写应用,该应用对应的具体源代码如下:
在C++源代码编写完毕之后,需要通过编译器来编译这段源代码,将其转换成一个二进制的可执行文件。这里将编译的程序分为两个版本,第一个为不经过任何编译器优化处理的基本版本;第二个为经过编译器对C++源代码进行浮点数优化和代码优化后生成的版本。
# 1.未经过编译器优化的版本,编译、链接与运行
g++ benchmark-cpp.cc-o benchmark-cpp
# 运行所生成的程序
./benchmark-cpp
# 2.编译器对代码和浮点数操作优化后的版本
g++-O3–ffast-math benchmark-cpp.cc–o benchmark-cpp
# 运行所生成的程序
./benchmark-cpp
可以看到,这是一个用来计算浮点数累加值的程序。该程序一共循环10次,每一次都会打印出浮点数累加1亿次后所花费的时间。程序在最后会打印出循环10次后,即累加10亿次后得到的浮点数变量值。这里在进行C++代码编译时分别编译出了两个不同版本的程序,第一个是未经过任何编译器优化直接生成的版本;而在第二个版本中,我们加入了GCC编译器支持的,可以对浮点数运算进行优化的参数“-fast-math”来优化代码中的浮点数运算。同时还指定了编译器需要对C++代码进行优化的等级参数“-O3”。
接下来,我们继续编写与该段程序业务逻辑相同的其他语言版本的程序代码。首先给出JavaScript版本代码,这段使用JavaScript语言编写的代码可以直接在Chrome开发者模式下的Console(控制台)中运行。
然后继续编写Java版本的代码,这段代码仍然基于同样的业务逻辑。
在编译上述代码时请确保本地环境已经安装了 Java 开发工具包(JDK)。可以在命令行下
运行如下命令来编译该Java程序。
# 编译代码
javac benchmark-java.java
# 运行代码
java benchmark
最后给出的是Python语言对应的代码。这里采用的Python解释器版本是2.7.13,将下面的代码直接存储在一个以“.py”为后缀的文件中,然后直接使用“python”命令在命令行下执行该文件即可。
通过Python解释器在命令行下运行Python文件中的代码。
# 解释执行
python benchmark-python.py
我们对上述4种不同编程语言分别对应的5段程序的运行结果进行了统计,并计算出了每段程序在10次“大循环”中消耗的平均时间值。最后我们将统计结果绘制成如图1-3所示的柱状图(前10列为5段程序对应的10次“大循环”过程的单次统计结果,最后一列为10次“大循环”的平均统计结果)。
从图1-3所示的基准测试平均结果中可以看到,运行效率最高的是经过编译器对代码进行优化和浮点数操作优化后的 C++程序,单次循环的平均耗时只有3ms。其次便是基于 Java 和JavaScript编写的程序,两种程序在处理单次“大循环”时的耗时基本相同,平均时间均为100ms左右,不相上下。而在相同的程序业务逻辑下,未经过任何编译器优化处理的C++程序的运行效率并不是很高,基准测试结果显示其运行效率低于在同样业务逻辑下使用 JavaScript 和 Java语言编写的程序,平均耗时为300ms左右。排名最后的是使用Python语言编写的程序,同样的单次循环平均耗时在6000ms以上(测试数据仅供参考)。
图1-3 多语言性能基准测试的统计结果
综合来看,JavaScript 代码在现代 Web 浏览器中的解析和运行效率其实并不低,虽然相比经过编译器优化的 C/C++代码来说还有一些差距,但在日常开发工作中经常接触到的那些编程语言里,JavaScript 代码的解析和运行效率已经处于比较高的水平。现实总是残酷的,虽然JavaScript代码的解析和运行效率正在随着JavaScript引擎的不断优化改进而不断提升,但在日常工作中我们所遇到的前端应用也正变得越来越复杂和多样化。
1.1.2 Web新时代与不断挑战
一般来说,一项新技术是否会随着时代的推进而被快速迭代和发展,要看这项技术所应用的实际业务场景中是否有相应的技术需求,毕竟没有任何技术会被凭空创造出来,那么技术的迭代和发展速度也就取决于这些业务需求的变化速度。技术决定了业务需求的多样性,而业务需求的多样性又反过来推动技术本身不断向前发展,两者相辅相成,最终才能推动行业整体的发展和进步。
自1991年HTTP协议和HTML(超文本标记语言)这两种核心的Web技术诞生以来,Web技术领域便开始不断发生翻天覆地的变化。如图1-4所示为1991—2002年Web技术的总体发展情况,在这十二年里,Web技术的总体发展过程还是比较缓和和稳定的。首先是NetScape、Opera和Internet Explorer(IE)三大浏览器开始逐渐走入人们的视野。一些用于构建更丰富Web应用的基础性技术开始逐渐涌现,比如Flash技术从1996年开始可以被应用在浏览器端,这使得我们可以在传统的Web应用中嵌入包含丰富多媒体信息的Flash应用,这一发展也使得Web应用的交互性和动态性大大增强。Flash技术的出现催生了一批以提供视频播放、视频发布和视频分享服务为主的视频服务平台,同时也推动了基于Flash的Web页游行业的发展。
图1-4 1991—2002年Web技术的总体发展情况
从2002年开始,Web技术的发展便到了其整个发展历程的“下半场”。如图1-5所示为2003—2012年这十年间Web技术的总体发展情况,在这十年的时间里,新型Web技术的出现逐渐呈现出爆炸式的增长。首先是Chrome、Firefox和Safari这三种为推动Web技术的爆炸式发展做出了巨大贡献的浏览器开始出现,各大浏览器厂商对其产品的版本更新迭代速度也开始加快。Web技术从2008年开始便进入了一个爆炸式的快速发展阶段,各种各样的新型Web浏览器特性,以及新的 Web 标准和 ECMAScript 语言标准都如雨后春笋般开始涌现出来。XMLHTTPRequest2技术为Web应用的数据传输提供了更加方便和高效的方式;WebRTC技术为Web应用的实时在线视频/语音直播提供了底层的基础技术解决方案;WebGL技术为Web应用提供了一种可以通过JavaScript来使用Web浏览器版OpenGL的特性,基于WebGL暴露出的JavaScript接口,我们可以在Web网页上高效地绘制3D动画和模型,而这为基于Web网页运行大型3D网络游戏提供了可能;IndexedDB技术为前端应用的结构化数据存储和高性能检索提供了支持。除此之外,还有很多的Web相关技术正在或已经被实现和标准化,这些技术无疑都大大地拓宽了Web应用所能够覆盖到的应用领域和场景。也正是自2008年HTML 5标准和2009年CSS 3标准出现后,Flash多媒体应用技术在Web开发领域逐渐走下坡路,直至最后被其他技术取代。由此可见Web领域的技术迭代与更替速度之快。
图1-5 2003—2012年Web技术的总体发展情况
JavaScript作为一门可用于开发Web前端应用的编程语言,从1995年发展至今,其所能够应用的领域已经不再局限于最原始的、基于浏览器的Web应用开发。包括Node.js在内的一系列新出现的JavaScript运行时环境已经把JavaScript语言的应用场景从前端应用开发带到了服务器端的应用开发。
基于Chrome V8引擎构建的Node.js和Fib.js等JavaScript运行时环境,为后端服务器应用的开发提供了非阻塞的异步IO和基于事件模型等新特性,这些新特性可以让我们以开发传统前端Web应用的思路来开发服务器端应用。不仅如此,基于Node.js开发出来的各种服务器端应用框架更是极大地提高了我们开发后端应用的效率。这些框架在一些必要的业务流程上已经为我们做了足够多的封装和优化,这使得我们可以更多地去关注业务逻辑代码的实现,而不是一些底层的架构细节。但事情并没有这么完美,以Node.js为例,由于其本身是基于V8实现的,而V8最重要的一个功能就是对JavaScript代码进行解析和优化,然后将优化好的中间代码编译成机器码或其他格式后再进行处理。因此,无论Node.js对V8上层的JavaScript代码进行了何种底层系统调用流程上的优化,如果最后V8在解析和优化JavaScript代码的过程中消耗了大量时间,那么整个应用的运行效率必然会大打折扣。总的来说,Chrome V8、JavaScriptCore 和SpiderMonkey等JS引擎对JavaScript代码的解析和优化效率,直接决定了基于JavaScript开发的前端和服务器端应用的运行效率,进而也影响了产品的用户体验。
除此之外,变得日益复杂和庞大的Web前端应用也带来了更多对JavaScript语言性能上的挑战。比如基于Web浏览器的视频处理应用、大型3D游戏以及在线的机器学习(深度学习)实时训练平台等,无一例外都需要消耗大量的浏览器计算资源,因此JS引擎对JavaScript代码的解析执行效率高低也直接决定了这些应用能否被流畅地运行。不仅如此,我们都知道通过JavaScript来移动或修改网页上的DOM节点所付出的成本是巨大的,随着传统Web页面的交互设计变得越来越复杂,这种成本损耗所带来的性能问题可能会被逐渐放大,这也是我们在未来将要面对的问题。
1.1.3 无法跨越的“阻碍”
前面我们提到,Chrome V8和JavaScriptCore等JS引擎对JavaScript代码的解析和执行效率高低,会直接影响到那些基于JavaScript开发的前后端应用的运行效率,那么JS引擎在解析和执行JavaScript代码时究竟会在哪些地方消耗较多的时间和性能呢?下面让我们走进这些JS引擎,来看一下它们内部的“世界”。
function add (a, b){
return a + b;
}
上面给出了一段简单的JavaScript代码,在这段代码中我们定义了一个非常简单的JavaScript 函数。调用该函数时一共需要传入两个参数,函数会直接返回这两个参数经过“+”运算符运算后的结果。那么当我们在代码中调用该函数时,JS引擎会对这个函数的调用过程进行怎样的处理呢?如图1-6所示,这是在ECMA-262最新的8.0版本标准中规定的当JavaScript引擎遇到“+”运算符时需要进行的语法分析规则。
图1-6 在ECMA-262标准中规定的对“+”运算符的语法分析规则
在这段标准中说明了JavaScript引擎需要对“+”运算符两边的操作数进行怎样的处理和转换。我们将这段比较抽象的标准“翻译”成较为容易理解的流程描述,说明如下。
首先,这段标准说明了“+”运算符两边可以使用的操作数类型。这个操作数可以来源于AdditiveExpression或MultiplicativeExpression表达式所对应的值。粗粗一看就可以发现,MultiplicativeExpression表达式是指由“*(乘法)”、“/(除法)”、“%(取余)”及“**(求幂)”等运算符组成的一系列表达式;而AdditiveExpression表达式则是指由“+(加法)”和“−(减法)”运算符组成的表达式。但实际上,每一种类型的表达式都是从下到上由一连串的表达式“继承”链组成的。比如对于一个AdditiveExpression表达式,我们自上而下来推导它的继承链结构,可以发现一个独立的MultiplicativeExpression表达式本身也是一个AdditiveExpression表达式,而一个独立的 ExponentiationExpression 表达式同时也是一个 MultiplicativeExpression 表达式,依此类推,直到整个继承链的最底层。链路底层直接对应的是数字的实体类型,这些类型又会被分为NonZeroDigit及DecimalDigit等各种类型。
整个继承链路如图1-7所示。这些出现在ECMAScript标准中的各类型表达式之间复杂的继承关系,也决定了JavaScript引擎在解析JavaScript代码时应该如何确定各个运算符之间的运算优先级关系。
图1-7 在ECMA-262标准中部分表达式的继承链关系
通过上述说明我们可以了解到,“+”运算符与其两边的子表达式共同组成了一个新的AdditiveExpression 表达式。接下来,我们可以按照标准给出的详细流程来进一步了解当 JS 引擎在解析 JavaScript 代码时,如果遇到“+”运算符应该以怎样的规则来解析这个新生成的AdditiveExpression表达式,并最终求得这个表达式的值。
JavaScript引擎在进行语法/语义分析时,首先需要判断该“+”运算符两边子表达式的值,这个步骤对应于上述ECMAScript 8.0标准中的第1步和第3步,这里在解析AdditiveExpression子表达式值时进行的是一个递归的过程。然后通过标准给出的抽象方法 GetValue 来对“+”运算符两边已经解析好的操作数进行处理,这里的lval和rval分别代表两个处理好的结果值。接下来,继续通过抽象方法ToPrimitive对上一步中得到的lval和rval两个值进行处理,返回的lprim和rprim分别代表经过这一步处理后得到的两个中间结果值。在第7步中,我们需要判断上一步的两个结果值lprim和rprim中是否至少有一个值的类型为String(字符串)。如果是,则分别对lprim和rprim两个值调用ToString抽象方法进行处理,然后将这两个处理后的结果值(分别对应 lstr 和 rstr 代表的字符串)拼接成一个完整的字符串,并将该字符串作为最终结果返回。如果不是,则会对lprim和rprim代表的中间结果值调用ToNumber抽象方法进行处理。这个抽象方法会按照相应的规则将两个中间结果值分别转换成对应的数字值,最后再将这两个数字值通过数学加法进行计算,并将计算结果返回。
这里需要注意的是,为了能够更加严谨地将“ECMA—262”标准直观地表达出来,我们还需要对上述分析过程中的几个地方有更深刻的认识。
标准中的“变量”名
需要注意的是,我们在上文中提到的“lval”和“rval”等标记名称,实际上并不代表任何JavaScript引擎在其源码中实际使用到的变量名、寄存器名或组件名等,只是标准文档为了方便在描述解析流程时将各个阶段的“中间结果”表示出来而取的标记名称。而且这些标记名称也十分有规律,比如lval可以理解为Left Value,即运算符左操作数所代表的值;lstr代表Left String,即左操作数对应的字符串值,其他可以依此类推。用这些标记名称来表示标准在各个处理阶段所生成的值类型显得十分贴切和形象。
抽象方法
我们在上文中提到的抽象方法,其实在 ECMAScript 8.0标准中对应的名词是“Abstract Operations”。Abstract Operations本身并不是ECMAScript语言的一部分,只是用来帮助标准本身来更加清晰地描述语法和语义。
这里称之为“抽象方法”,是由于 Abstract Operations 在标准文档中的书面引用形式与JavaScript语言中的函数调用过程十分类似。所谓的Abstract Operations其实本身也是一系列对给定值的特定处理流程。如图1-8所示为抽象方法 ToNumber 的处理流程规范。当 ToNumber抽象方法遇到不同类型的操作数时会根据标准分别进行不同的处理,这里以比较复杂的 Object类型操作数为例。对于一个Object类型的操作数,抽象方法ToNumber首先需要对该操作数调用抽象方法ToPrimitive进行处理,然后将其返回的结果再通过调用抽象方法ToNumber来进行处理并得到最终的处理结果。同样的,抽象方法ToPrimitive所对应的Abstract Operation,也有其自己的一套规范化、标准化的处理流程。
图1-8 在ECMA-262标准中对抽象方法ToNumber的处理规范定义
非终结符、终结符与产生式
为了能够更加深入地理解ECMAScript规范中表达式的作用,以及表达式与JavaScript引擎之间的关系,我们可以将大多数编程语言所对应编译器的语法分析过程总结成如下通用的简化版流程。这里假设使用某种编程语言编写了如下一行代码。
thisIsAVariable = 1+2
这行代码表示一个最简单的赋值语句,将等号“=”右边的表达式结果值赋值给了一个名为“thisIsAVariable”的变量。我们使用字母“S”来表示整个赋值语句表达式,那么应该怎样通过符号组合的形式来描述这条赋值语句呢?假设使用字母“v”来表示赋值语句最左边的变量,字母“e”表示“=”运算符,字母“p”表示“+”加号运算符,字母“d”表示一个整数。经过整理,我们得到了如下所示的字符表达式,最右侧的五个字母从左至右依次对应上述语句中出现的每一个有效的语法元素。
S->vedpd
但实际上,由于“=”运算符右边可以放置任意类型的表达式,因此我们继续对上述字符表达式进行修改,用字母“E”来表示一个任意类型的表达式。经过整理后的字符表达式如下。
S->veE
在这里,我们将上述字符表达式称为赋值语句“S”的产生式。其中字母“v”和“e”所对应的 Token 类型已经确定。Token 是词法分析器在进行词法分析时产生的最小的且具有明确语义的有效关键字。比如对于上述赋值语句,在词法分析阶段,词法分析器会将这段代码按照最基本的关键字进行分割,分割出的“thisIsAVariable”、“=”、“1”、“+”和“2”五个字符串片段中每一个都独立地称作一个 Token,并且其各自分别对应着一种具体的 Token 元素类型。而这些能够直接与某类型Token相对应的符号,我们称它们为“终结符”。相反的,符号“E”可以表示一个具有任意组成元素的表达式类型,而其本身并没有被明确地指定与哪些Token相对应,因此我们称它为“非终结符”。
为了能够清楚地描述符号“E”所对应表达式的具体结构,我们需要对“E”进行名为“非终结符展开”的操作。为了简化该流程,假定在该编程语言中只存在“加法”这一种数学运算,同时也只有“整数”这一种数据类型。那么符号“E”所代表的表达式便可能具有两种组成方式,其中一种是符号“E”可以仅由一个整数字面量组成,该整数独立地作为表达式完成整个“S”表达式的计算过程;另一种是符号“E”可以表示任意经由“+”加法运算符组合而形成的子表达式的值。因此,我们可以进一步对赋值语句“S”的产生式进行如下整理。
E->d|Epd
S->ve(d|Epd)
可以看到,符号“E”的产生式以递归形式表示出来。这种递归形式可以使该表达式的展开式能够覆盖到具有任意长度子表达式的所有具体表达式上。比如对于如下这种包含有连加运算的数学表达式,借助上述表达式“S”的递归形式展开式,我们可以通过以下步骤对其进行展开。
thisIsAVariable = 1 + 2 + 3;
# 展开过程
1.S-> veE
2.S-> veEpd (E->Epd)
3.S-> veEpdpd (E->Epd)
4.E-> vedpdpd (E->d)
通常来说,在编程语言所对应的整个编译器链路中,词法分析器(Lexer)负责将源代码中的各类短语进行过滤并解析成具有特定语义的Token字符串,而这些字符串将会在接下来的语法分析阶段,被语法分析器(Parser)通过相应的算法进行“表达式非终结符”展开的处理。比如在 JavaScript 语言中,我们可以通过“+”运算符来定位一个 AdditiveExpression 类型的表达式。刚才我们也提到过,每一个表达式其实都是一种“非终结符”类型,这些非终结符都需要在被编译成机器码之前展开成特定的终结符形式。因此相对而言,如果语法分析器无法将一段代码内的某个表达式展开成标准中提到的任意一种终结符展开式形式,那么在该表达式中便一定存在语法格式错误。
同样的,如果代码中不存在语法错误,也就代表着该段代码中所有的非终结符结构(包括表达式、条件控制结构及函数定义在内的各类非终结符形式)都被成功地展开成了标准中某种特定的终结符形式。语法分析器在处理完代码后会向编译器链路的下一个阶段输出一种名为“抽象语法树(AST)”的数据结构,它以结构化的表示形式表达了整段代码的语法结构。至此,也表明语法分析器真正“理解”了源代码中各个代码段所表达的具体语义。
通过上面的分析,我们大致了解了JavaScript引擎在处理“+”运算符时所需要经过的一系列解析流程。实际上,JS引擎在实际实现规范中所描述的各类流程时,需要处理的细节问题远比我们所描述的要复杂得多。在规范中出现的每一个流程内的每一个抽象方法都有着其各自不同的处理流程,而在这些流程内部同样又有多个更加底层的抽象方法。通过将这些抽象方法一步一步组合形成各种各样的上层流程,而流程与流程之间又相互调用形成网状结构,这些网状结构的调用流程最后便组成了整个ECMAScript语法和语义层的标准。
回过头来看,JavaScript引擎之所以要在处理“+”运算符时经过多道“工序”,其主要原因是 JavaScript本身是一种弱类型(Weak Typed)的编程语言。所谓弱类型,在语法形式上最直观的体现便是在使用该编程语言初始化变量时,无须显式地指出变量的具体类型,整个变量的类型完全由代码解释器在代码的运行过程中进行推断。而相对于弱类型编程语言的则是强类型(Strongly Typed)编程语言。同样的,所谓强类型,最直观的体现便是在使用该编程语言声明变量时,必须要显式地指明变量需要存储的数据类型。这样做的好处是,我们无须花费额外的精力在代码运行时去推断变量的数据类型,而这从某种程度上便可以大大提高代码的运行效率。由于代码中的所有变量类型都不再需要通过运行时环境去推断,因此便可以提前将程序的源代码进行静态编译(AOT)和优化,最后直接生成相应的经过优化的二进制机器码供CPU执行。C 语言便是这样一种常用的强类型编程语言。强类型编程语言所共有的一个优势就是无须进行变量的运行时类型推断,进而使得代码的运行效率更高。
1.1.4 Chrome V8引擎链路
从上文中我们已经了解到,由于JavaScript引擎无法在代码运行前便得知变量所存储的具体数据类型,因此对于很多运算符操作,JS引擎需要在运行时环境下通过一系列的判断“决策”才能推断出变量所存储的具体数据类型。而实施这些“决策”的过程则需要消耗一定的系统资源。接下来让我们以老版本(Chrome 58以下)的Chrome V8引擎为例,来进一步剖析V8引擎在处理JavaScript源代码时的整个流程以及所对应的编译器链路,如图1-9所示。
图1-9 老版本Chrome V8引擎的编译器链路
V8引擎的飞跃式进化也是从这里开始的。整个代码的解析、编译和执行流程按照JavaScript代码所经过的不同编译器顺序及类型,可以被分为“Full-codegen”基线JIT编译器对应的Baseline编译阶段,以及“TurboFan”和“Crankshaft”两个优化JIT编译器所对应的Optimized编译阶段共两个部分。
首先,每一组编译器都会有一个前置的语法分析器,这个语法分析器会对 JavaScript 源代码进行词法和语法分析,然后生成对应的抽象语法树结构。一般来说,一个完整的编译器链路在处理源代码时通常会分为以下几个步骤。
词法分析
词法分析,顾名思义,该阶段主要是识别和提取源代码中的关键字,同时判断出每一种关键字的类型。这些关键字可能是一个用来声明变量的“let”,也可能只是一个简单的用于结束语句的分号“;”。所有这些组成源代码的关键字都会在这一阶段被识别和提取出来,最后结合这些关键字的具体类型和一些基本的语素信息,我们就得到了词法分析阶段的最小单位“Token”,而每一个Token就代表了一种不同的关键字类型。
语法分析
语法分析,该阶段会通过结合编程语言的具体语法规则和从词法分析阶段得到的Token信息,将JavaScript源代码转换成抽象语法树(AST)的形式。抽象语法树以树状的形式表示源代码的语法结构。
// 声明一个函数
function add(a, b){
return a + b;
}
// 声明一个变量并调用函数,最后将函数值赋值给该变量
let num = add(1, 2);
比如我们在上面的JavaScript源代码中声明了一个函数,这个函数接收两个参数,然后返回这两个参数经过“+”运算符运算后的结果。接下来调用该函数,并将数字1和2作为参数传入该函数,最后再将函数返回的结果赋值给一个新的变量“num”。我们将这段JavaScript源代码经过语法分析处理后生成的AST以JavaScript对象的形式表示出来,如下所示。这里我们采用了Esprima来分析JavaScript源代码并生成对应的AST结构。
从上面的AST结构中可以看到,抽象语法树上的每一个节点都有其各自的类型,这些类型可能是ECMAScript标准中的一个终结符Token,比如标识符(Identifier)类型;或者是一个非终结符,比如一个需要进一步展开的表达式类型。除此之外,还有函数定义、变量定义等各种类型的节点。在语法分析阶段,语法分析器会根据文法来分析那些在词法分析阶段产生的Token,并且按照ECMAScript的语法规则将这些Token进行整理和组合,通过识别关键字Token来提取出语法中的函数定义、变量定义和表达式等语法结构。最后再将这些包含有各种元素的层级结构整理成一个树状的语法表示结构。AST从最内层(树的子节点)开始统一由终结符类型的Token组成,逐层向外扩展。比如对于CallExpression表达式类型,其展开过程可以有多种形式,每种形式又由不同的 Token 组成,而具体的展开形式只有根据实际的源代码才能确定。同时CallExpression也可以被放到VariableDeclaration这种语法结构中。AST便是这样逐层地将各种类型的语法元素按照标准中规定的语法结构表示出来的,树形结构的表示方式也正好对应了源代码在语法上的逻辑嵌套和组成关系。
语义分析
在语法分析阶段,编译器只是对源代码进行静态分析,以判断源代码在语法表达上是否存在错误。在语义分析阶段,编译器会进一步分析在语法分析阶段产生的AST结构,进而判断源代码是否存在运行时错误。在这一阶段中,编译器会分析源代码中的函数调用过程,以及传入函数的参数个数是否正确,优化那些已经声明并被初始化,但却在程序中没有被明确调用到的变量等。对于强类型的编程语言,编译器还会检查变量类型的声明与使用是否保持一致。
生成目标代码
经过上面几个步骤之后,我们便可以将最终经过分析和优化后的代码直接“翻译”成对应的目标代码并在对应的目标平台上执行。比如 JavaScript 代码对应的目标执行平台就是各类浏览器。在这一阶段中,编译器会将从上一步语义分析阶段得到的中间代码直接编译成对应平台的机器码,并在浏览器中解析和执行。
我们再把目光移回到V8引擎上。为了提高对JavaScript源代码的解析和执行效率,V8引擎会对当前即将执行的JavaScript代码段进行分析。如前面的图1-9所示,V8引擎会首先将所有的JavaScript源代码通过一个前置的语法分析器(Parser)来进行词法和语法的分析,并同时生成对应的AST数据结构。在这一阶段中,Parser会检查整段JavaScript代码并将它们分成如下两种不同的类型。
Top-Level代码
这一类型的代码主要是指那些当JavaScript源代码初次加载时需要被首先运行到的“顶层”代码。这部分代码主要包括变量声明、函数定义以及函数调用等类型的代码。而那些用于定义函数的函数体内部的代码,并不属于Top-Level代码。
非Top-Level代码
这一类型的代码则与 Top-Level 代码正好相反,主要是指那些用于定义函数的函数体内部的JavaScript代码。
在如下的一段代码中,我们将其中的Top-Level代码和非Top-Level代码通过注释做出了区分。其中注释为TL的代码为Top-Level代码,而注释为NTL的代码为非Top-Level代码。
在V8引擎中,位于各个编译器的前置Parser被分为Pre-Parser与Full-Parser两种类型。首先,Pre-Parser主要负责对整个JavaScript源代码段进行必要的前期检查。Pre-Parser并不区分具体的代码类型,即无论这些代码是否属于Top-Level类型,Pre-Parser都会对它们进行检查。通过检查,V8会判断JavaScript代码中是否存在语法错误,如果存在,则V8需要在对代码进行下一步处理前及时抛出语法错误信息(Early Syntax Error)并提示用户,同时中断代码的后续解析和执行。在Pre-Parser对代码进行分析和处理的阶段,Parser并不会生成对应于源代码语法的AST结构,同时也不会生成变量可用的上下文作用域。
接下来,Full-Parser会开始分析那些属于Top-Level类型的JavaScript源代码,并生成这部分JavaScript代码所对应的AST信息。同时在该阶段中,Full-Parser还会对代码中的变量进行作用域分析,以便追踪那些具有特殊作用域的变量(例如闭包中的变量),并为它们在外层作用域分配相应的资源,同时生成该变量可用的上下文作用域。当 Full-Parser 将所有属于顶层Top-Level类型的JavaScript源代码都转换成对应的AST信息后,这些AST随后便会被送往V8引擎的第一个支持运行时编译(JIT)的编译器——“Full-codegen”基线编译器来进行处理。在这里,Full-codegen会快速地根据输入的AST信息来编译并生成对应未经优化的机器码,这些机器码最后便可以被浏览器快速地解析和执行。
浏览器在解析和执行这些 Top-Level 代码的过程中,会遇到一些诸如函数调用的操作。由于在初次的Full-Parsing过程中,Parser只对Top-Level代码进行了处理,而在这部分Top-Level代码中并不包含这些被调用函数的函数体定义。因此在这种情况下,V8引擎会根据 Top-Level代码在执行过程中遇到的函数调用,来对 JavaScript 源代码中对应函数的函数体再进行一次Full-Parsing 的处理,并生成这个函数体所对应的 AST 信息。这些 AST 信息随后也同样会被Full-codegen处理并生成对应的机器码,最后再由浏览器解析和执行。V8引擎的这种“不一次性完全生成和处理所有JavaScript源代码对应的AST信息,而只在用到时才进行AST生成和编译”的特性,我们称之为“Lzay Parsing”。总的来说,在V8引擎中,Pre-Parsing阶段主要用来检查整个JavaScript源代码中是否含有需要提前抛出的语法错误,而在随后的Full-Parsing阶段才会真正生成AST信息并交由编译器来处理。
随着从Full-codegen基线编译器输出的未经优化的机器码被浏览器解析和执行,V8引擎会发现在当前这些正在运行的代码逻辑中,有一些比较耗时的代码流程可以被进一步优化。比如在JavaScript代码中出现的“大次数循环代码块”或ECMAScript 6标准中的某些新特性。这时,V8引擎会选择把这部分JavaScript代码直接转交给另外的优化编译器进行优化处理。V8引擎有两个JavaScript优化编译器,分别为Crankshaft和TurboFan。其中Crankshaft主要用于对JavaScript源代码进行一些比较基础的优化;而TurboFan主要用于对那些使用了ECMAScript 6及以上标准的新特性代码进行优化,同时它也负责对ASM.js代码进行处理。
首先,Full-codegen基线编译器在根据AST来生成未经优化机器码的过程中,会假设某些情况是成立的。也只有当在确保这些假设是真实成立的情况下,优化编译器所进行的深度优化才会有实际的意义。比如在优化一段包含有大量循环逻辑的JavaScript代码时,如果Full-codegen基线编译器发现在循环的前几次中都执行了相同逻辑的代码(相同的作用域环境与变量结构),便会假设在后面的所有循环中,迭代的代码形式都是保持不变的,而对于这样的循环结构,基线编译器会把编译流程交给优化编译器来进行优化处理。但实际上,由于 JavaScript 语言本身所具有的高度动态性,所以并不能完全保证循环逻辑中每次迭代所执行的代码结构都一定是完全相同的。因此,这些经过优化编译器生成的机器码在被浏览器解析和执行前,V8引擎会再次检验之前基线编译器所做出的假设是否成立。如果假设确实成立,浏览器便会直接解析和执行这些经过优化生成的机器码;如果假设不成立,优化编译器便会开始执行一个名为“去优化(Deoptimize)”的过程。
“去优化”过程是指V8引擎发现之前基线编译器所做出的假设并不成立,而此时则需要把代码的编译流程从优化编译器重新“交回”到基线编译器的手上。基线编译器会再次重新编译这些 JavaScript 代码,同时生成未优化的机器码,最后让浏览器来解析和执行。而之前优化编译器生成的那部分错误的优化机器码便会被直接丢弃。
以上便是老版本Chrome V8引擎在处理JavaScript代码时,从编译器角度来看所经过的一系列流程。随着Web应用的规模越来越大,V8引擎现有的这种用于处理JavaScript代码的编译器架构模式所存在的问题便逐渐凸显出来。Full-codegen基线编译器在处理Top-Level代码时所生成的机器码会大量占用 V8的堆内存。不仅如此,V8的编译器链路在解析和执行 JavaScript代码的整个时间线(Startup Time)上,有近三分之一的时间被Parsing和Compiling占据。其中,对同一段代码的多次 Parsing 更是大大降低了 V8引擎对 JavaScript 源代码的处理效率(Pre-Parsing、基线编译器的Full-Parsing和优化编译器的Full-Parsing)。比如对于如下所示的这段JavaScript源代码,我们尝试通过Node.js来追踪V8引擎在处理该段代码时的Pre-Parsing和Full-Parsing过程。(注:Node.js基于V8构建,因此也使用V8来解析JavaScript代码。)
在终端命令行中执行如下Node.js命令。
node-trace_parse app.js
通过为node命令指定“-trace_parse”参数,我们可以让Node.js输出V8引擎在对JavaScript源代码进行Pre-Parsing和Full-Parsing两个过程时的相关信息,结果如图1-10所示。可以看到,在解析这段JavaScript源代码时,V8引擎会首先对这段源代码中的所有函数定义及调用过程进行Pre-Parsing操作,在Pre-Parsing过程中V8引擎会检查源代码中是否存在需要提前抛出的语法错误信息。在对所有源代码完成 Pre-Parsing 阶段的处理后,V8引擎开始处理在第一次运行时将会被调用到的函数代码,即属于Top-Level类型的代码。
图1-10 通过Node.js来追踪上述JavaScript代码在V8下的Parsing过程
随着代码的运行,一些在Top-Level代码中被调用的函数其函数体会被V8引擎继续处理。这里由于我们在Top-Level代码中调用了sayHi函数,因此V8引擎会对sayHi函数的函数体再进行一次Full-Parsing处理,Full-Parsing操作会分析函数体代码并生成所对应的AST数据结构,同时初始化函数内的变量和上下文环境。
但从整体上看,V8引擎对sayHi函数的Pre-Parsing处理过程其实是完全没有必要的。由于所有位于Top-Level层级的函数调用都会在JavaScript被加载时就执行,对这些函数的函数体的语法检查完全可以放在紧接着 Pre-Parsing 之后的 Full-Parsing 阶段来进行,如果可以将这些重复的操作完全省略掉,那么V8引擎在处理JavaScript代码时的效率又会得到进一步的提升。
为此,V8引擎也提供了一些比较“Hack”的方式来避免多余的 Pre-Parsing 过程。我们可以通过一种常见的名为“IIFE(立即执行函数表达式)”形式的 JavaScript 代码,来强制让 V8引擎省略掉对IIFE内部代码的Pre-Parsing过程。在这里,我们通过IIFE来优化之前的那段代码,优化后的JavaScript源代码如下。
在终端命令行中运行如下Node.js命令。
node-trace_parse app.js
接下来,我们还是使用Node.js来追踪V8引擎对上述经过IIFE处理后的JavaScript代码进行Pre-Parsing和Full-Parsing的处理过程,命令执行结果如图1-11所示。可以看到,V8引擎在处理这些包含在IIFE结构内的代码时,完全省略了对这部分代码的Pre-Parsing处理过程,这在某种程度上提升了V8引擎解析和执行JavaScript代码的效率。
图1-11 经过IIFE修改后的JavaScript代码执行结果
鉴于在老版本V8引擎中存在各种问题,Google自Chrome 58版本开始,开始对V8引擎的编译器链路进行了改进与优化。V8团队希望能够通过如图1-12所示的全新编译器链路架构模式来优化现阶段的V8引擎。
图1-12 全新的Chrome V8编译器链路
在这个全新的 V8引擎编译器链路中,新加入了一个名为“Ignition”的解释器,同时去掉了Full-codegen基线编译器和Crankshaft优化编译器。Ignition解释器会根据从Parser传递过来的基于JavaScript源代码生成的AST信息直接生成对应的“比特码(Bytecode)”数据结构。比特码本身是一种对机器码形式的抽象,它的信息密度更高,因此相比于基线编译器生成的未经优化的机器码,Ignition解释器生成比特码的速度更快,同时这些比特码的体积更小,占用的堆内存也更少。这些比特码一部分会被 Ignition 自身直接高效地解释和执行,另一部分则会被送往TurboFan的“图”生成器中等待进一步的优化。
与之前类似,如果优化编译器TurboFan生成的优化机器码不可用,即V8引擎所做出的优化假设是不成立的,那么这些机器码便会被直接丢弃,同时整个编译流程会直接再次返回到Ignition解释器,由Ignition来处理和执行这些JavaScript源代码。这种新的编译器链路使得V8引擎的整体架构复杂度大幅下降,仅有一次的Parsing过程也使得JavaScript代码可以被更高效地运行。同时也解决了在老版本链路中基线编译器会耗费大量堆内存的问题。
但实际上,从老版本V8链路到全新链路架构的升级并不是一蹴而就的。因此,在Chrome 53版本中采用的V8编译器链路仍旧如图1-13所示。由于TurboFan优化编译器本身的处理性能并不足以独立支撑整个V8链路对JavaScript代码的优化,因此我们不得不把Crankshaft优化编译器重新加回到链路中。而另一方面,由于Crankshaft本身没有可用于处理比特码的编译器前端,因此从 Crankshaft 进行去优化过程时仍需要把这部分代码重新交回到 Full-codegen 基线编译器的手上,所以Full-codegen也被重新加回到了V8引擎链路中。
图1-13 Chrome 53版本中实际使用的Chrome V8编译器链路
可以看到,从老版本V8链路到全新链路架构的升级过程并不是能够快速完成的。随着Web应用的规模不断增大,V8引擎需要不断地进行升级来提升自己处理JavaScript代码的性能。但是,每一次升级所花费的时间和Web应用逐渐复杂化的时间周期并不成正比。并且V8引擎所存在的这些问题并不是其独有的,包括 SpiderMonkey和JavaScriptCore等在内的这些常见的JavaScript引擎均存在类似的问题。