From URL to Interactive(四)---从var到及时编译(Var to JIT)

这是《From URL to Interactive》系列文章的第一篇《Server to Client》。《From URL to Interactive》是个引子就不译了,文章主要基于windows自带的浏览器Eage为基础介绍现代浏览器对HTML从请求、链接、加载、解析、渲染、交互的过程。分阶段介绍:

From URL to Interactive(一)---从服务器到客户端(Server to Client)

From URL to Interactive(二)---从标签到DOM(Tags to DOM

From URL to Interactive(三)---从大括号到像素(Braces to Pixels)

From URL to Interactive(四)---从var到及时编译(Var to JIT)

 

篇原文地址:https://alistapart.com/article/var-to-jit

-----------------------------------------------------------------------------------------------------------------------------

前面我们的文章介绍了浏览器如何将CSS渲染成漂亮的像素到用户的屏幕上。虽然现在浏览器可以通过CSS提供高交互体验,但对于交互的最后一公里,我们需要动态修改HTML文档,因此我们需要使用JavaScript

Bundle to bytecode

对于现代的web应用,浏览器最先看到的js并不是开发者写的,相反很可能是类似webpack这样的工具生成的捆绑包(bundle)。它可能是一个相当大的包,包含一个UI框架,如React,各种polyfill(在旧版浏览器中模拟新平台功能的库),以及在npm上找到的各种其他包。浏览器JavaScript引擎的第一个挑战是将大量文本转换为可在虚拟机上执行的指令。它需要解析代码,并且因为用户正在等待所有交互性的JavaScript,所以它需要快速完成。

在高层次上,JavaScript引擎就像任何其他编程语言编译器一样解析代码。首先,输入字符流被分解为tokens。每个token代表句法结构中的一个有意义的单元,类似于自然语言中的单词和标点符号。然后将这些token送入自上而下的解析器,该解析器产生表示程序的树型结构。语言设计者和编译器工程师喜欢将这个树结构称为AST(抽象语法树)。然后通过分析AST生成字节码虚拟机指令。

生成AST的过程是JavaScript引擎更直接的工作之一。不幸的是,它可能很慢。还记得我们开始使用的大量代码吗?在用户开始与站点交互之前,JavaScript引擎必须为整个包解析和构建语法树。对于初始页面加载的大部分代码可能是不必要的,有些甚至可能根本不执行!

 

幸运的是,我们的编译工程师已经发明了各种技巧来加快速度。首先,一些引擎用后台线程来解析代码,释放主UI线程以进行其他计算。其次,现代引擎将通过使用延迟解析(deferred parsing)或延迟编译(lazy compilation)的技术尽可能长地延迟内存中语法树的创建。它的工作方式如下:如果引擎看到可能在一段时间内不会被执行的函数,它将执行函数体的快速“一次性”解析。这种一次性解析会发现代码中可能存在的任何语法错误,但它不会生成AST。稍后,当第一次调用该函数时,将再次解析代码。这次,引擎将生成执行所需的完整AST和字节码。在JavaScript的世界中,做两次有时比做一次更快!

但是,最好的优化是允许我们完全绕过任何工作的优化。在JavaScript编译中,这意味着完全跳过解析步骤。一些JavaScript引擎将尝试缓存生成的字节码,以便以后重用,防止用户再次访问该站点。这并不像听起来那么简单。随着网站的更新,JavaScript捆绑包可能会经常更改,浏览器必须仔细权衡序列化字节码的成本与缓存带来的性能改进。

字节码到运行时(Bytecode to runtime)

现在我们有了字节码,我们就可以开始执行了。在现今的JavaScript引擎中,我们在解析过程中生成的字节码首先被送入解释器(interpreter)。解释器有点像用软件实现的CPU。它一次查看一个字节码指令,并确定要执行的实际机器指令和下一步操作。

JavaScript编程语言的结构和行为在ECMA-262的文档中定义。语言设计者喜欢将结构部分称为“语法”,行为部分称为“语义”。语言的几乎每个方面的语义都是由使用类似散文的伪代码编写的算法定义的。例如,让我们假装我们是编译工程师实现有符号的右移运算符(>>)。以下是规范告诉我们的内容:

ShiftExpression:ShiftExpression >> AdditiveExpression

  1. lref成为评估ShiftExpression的结果。
  2. lval 成为GetValue(lref
  3. rref成为评估AdditiveExpression的结果。
  4. rval成为GetValue(rref)。
  5. lnum成为ToInt32lval)。
  6. rnum 成为Uint32rval)。
  7. shiftCount是屏去除rnum的最低有效5位(bit)之外的所有位的结果,即计算rnum & 0x1F的值。
  8. 返回通过shiftCount位(bit)执行lnum的符号扩展右移的结果。传递最重要的位。结果则是带符号的32位整数。

在前六个步骤中,我们将操作数(>>的任一侧的值)转换为32位整数,然后我们执行实际的移位操作。如果你稍微斜眼看,它看起来有点像食谱。如果你真的斜着眼睛,你可能会看到一个语法导向的解释器的开头。

不幸的是,如果我们完全按照规范中的描述实现算法,那么我们最终会得到一个非常慢的解释器。考虑从JavaScript对象获取属性值的简单操作。

JavaScript中的对象在概念上类似于字典。每个属性都由字符名串类型的key。对象也可以有一个原型对象。

如果一个对象没有给定字符串键的条目,那么我们需要在原型中查找该键。我们重复此操作,直到找到我们正在寻找的key或者到达原型链的末尾。

每当我们想要从对象中获取属性值时,都可能需要执行很多工作!

JavaScript引擎中用于加速动态属性查找的策略称为内联缓存(inline caching)。内联缓存最初是为20世纪80年代的Smalltalk语言开发的。基本思想是先前属性查找的结果可以直接存储在生成的字节码指令中。

为了了解这是如何工作的,让我们想象一下JavaScript引擎是一座高耸的哥特式大教堂。当我们走进去的时候,我们注意到引擎充满了物体。每个对象都有一个可识别的形状,用于确定其属性的存储位置。

现在,假设我们正在遵循卷轴上写的一系列字节码指令。下一条指令告诉我们从某个对象获取名为“x”的属性的值。你抓住那个物体,用手将它翻过几次,找出存储“x”的位置,然后发现它存储在物体的第二个数据槽中。

你会发现任何具有相同形状的对象在其第二个数据槽中都有一个“x”属性。你拉出你的羽毛笔并在你的字节码卷轴上做一个注释,指出对象的形状和“x”属性的位置。下次看到此指令时,您只需检查对象的形状。如果形状与您在字节码注释中记录的形状相匹配,则无需检查对象即可准确知道数据的位置。您刚刚实现了所谓的单态内联缓存!

但是如果对象的形状与我们的字节码注释不匹配会发生什么?我们可以通过为我们看到的每个形状绘制一个带有一行的小表来解决这个问题。当我们看到一个新形状时,我们使用我们的羽毛笔向表中添加一个新行。我们现在有一个多态内联缓存。它不像单形缓存那么快,并且它在卷轴上占用了更多的空间,但是如果没有太多的行,它可以很好地工作。

如果我们最终得到一个太大的表,我们将要删除该表,并做一个注释以提醒自己不要担心该指令的内联缓存。在编译器术语中,我们有一个变形的调用点(megamorphic callsite)。

一般来说,单态代码非常快,多态代码几乎一样快,并且变形代码往往相当慢。或者,以三行俳句诗(haiku)来表达:

 

One shape, flowing wind
Several shapes, jumping fox
Many shapes, turtle

 

Interpreter to just-in-time(JIT)

解释器的好处在于它可以快速开始执行代码,而对于只运行一次或两次的代码,这个“软件CPU”的执行速度可以接受。但对于“热代码”(运行数百,数千或数百万次的函数),我们真正想要的是直接在实际硬件上执行机器指令。我们想要即时(JIT)编译。

由于JavaScript函数由解释器执行,因此会收集有关调用函数的频率以及调用函数的类型的各种统计信息。如果使用相同类型的参数频繁运行该函数,则引擎可能决定将函数的字节码转换为机器代码。

让我们再次踏入我们假想的JavaScript引擎 - 哥特式大教堂。在程序执行时,您尽职地从精心标记的货架上拉出字节码卷轴。对于每个函数,大致有一个卷轴。按照每个卷轴上的说明操作时,可以记录执行卷轴的次数。您还会记下执行指令时遇到的对象的形状。实际上,您是一名剖析译员(profiling interpreter)。

当你打开下一个字节码卷轴时,你会注意到这个字节码很“热”。你已经执行了几十次,而且你认为它在机器代码中的运行速度要快得多。幸运的是,有两个房间里满是抄写员(scribes )准备好为你翻译。第一个房间的抄写员,一个灯火通明的开放式办公室,可以很快地将字节码转换成机器代码。他们生产的代码质量很好,简洁,但效率却不高。第二个房间的抄写员,黑暗而朦胧的香,工作更仔细,需要更长的时间才能完成。但是,它们生成的代码是高度优化的,并且尽可能快。

以编译器的说讲,我们将这些不同的房间称为JIT编译层。不同的引擎具有不同的层数,这取决于它们的权衡。

您决定将字节码发送到第一个文字室。稍作处理后,使用您精心记录的笔记,他们会生成一个包含机器指令的新卷轴,并将其放在原始字节码版本旁边的正确架子上。下次需要执行该功能时,可以使用此更快的指令集。

唯一的问题是,当他们翻译我们的卷轴时,抄写员(scribe)做了相当多的假设。也许他们假设变量总是保持整数。如果其中一个假设无效,会发生什么?

在这种情况下,我们必须执行所谓的救助(bailout)。我们从架子上拉出原始的字节码卷轴,并找出应该从哪个指令开始执行。机器代码卷轴消失在一阵烟雾中,并且该过程重新开始。

超越无限

今天的高性能JavaScript引擎已经远远超出了20世纪90年代Netscape Navigator和Internet Explorer附带的相对简单的解释器。而这种演变仍在继续。新功能逐渐添加到语言中。通用编码模式得到优化。WebAssembly正在成熟。更丰富的标准模块库被开发出来。作为开发人员,只要控制捆绑包的大小并尽量确保涉及性能的关键代码不过于动态,我们可以期待现代JavaScript引擎提供快速高效的执行。

关于作者

Kevin Smith

Kevin致力于Microsoft Edge的JavaScript引擎,专注于编程语言设计和标准化。在编写了Web应用程序和玩具编译器十年之后,他现在担任TC39的微软代表,TC39是负责JavaScript演变和规范的委员会。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值