从JS引擎谈到事件环

1,443 阅读6分钟

JS Runtime和引擎傻傻分不清

Runtime和引擎是两个容易混淆的概念。单单对于Javascript来说,他们是不一样的。

JS引擎的工作内容

JS引擎的工作内容通常包含JS代码的解析(对JS代码进行词法、语法等分析)和JIT编译(将JS代码编译为Intel, ARM以及MIPS等不同CPU对应的汇编代码),同时它还要负责执行代码、分配内存以及垃圾回收等功能。 流程如下图:

V8运行机制.png
简单地说,Parser将JS源码转换为AST,然后Ignition将AST转换为Bytecode,最后TurboFan将Bytecode转换为经过优化的Machine Code(实际上是汇编代码)。

常见的JS引擎

引擎 描述
Rhino 由Mozilla基金会管理,开放源代码,完全以Java编写
SpiderMonkey 第一款JavaScript引擎,由BrendanEich在NetscapeCommunications时编写,用于Mozilla 1.0~3.0版本
TraceMonkey 基于实时编译的引擎,其中部份代码取自Tamarin引擎,用于 Mozilla Firefox 3.5~3.6版本
JägerMonkey / JagerMonkey 德文Jäger原意为猎人,结合追踪和组合码技术大幅提高效能,部分技术借鉴了V8、JavaScriptCore、WebKit,用于Mozilla Firefox 4.0以上版本
V8 开放源代码,由Google丹麦开发,是Google Chrome的一部分
JavaScriptCore 用于Apple的Safari浏览器
Chakra(JScript) 用于微软IE系列
Chakra(Javascript) 用于微软Edge浏览器
Hermes 来自Facebook,生而与React Native紧密结合
其他 还有适用于物联网和嵌入式的轻量级引擎,如JerryScript、duktape、QuickJS等。

JS Runtime和JS引擎的关系

简单的说,JS引擎在JS Runtime中运行,JS runtime为引擎提供了一些内建的库,可以在程序运⾏时使⽤。

所以Window对象和DOM API,都存在于浏览器的Runtime而不存在于Node Runtime;相反,Cluster 和FileSystem API存在于Node Runtime确不存在与浏览器Runtime。当然,两个Runtime都包含内置的数据类型和常⽤的⼯具,⽐如Console对象,定时器等。

因此Chrome和Node.js共享相同的引擎(V8),但是它们具有不同的Runtime。

单线程和异步

单线程

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。 为什么JavaScript不能有多个线程呢?

JavaScript的单线程,与它的用途有关。 它是作为浏览器脚本语言而生的。其主要用途是与用户交互和操作DOM,这决定了它只能是单线程。 试想一下,如果JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器该怎么办? 为了避免这种问题,JavaScript语言的设计者最开始就采用了单线程的设计。

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务,如此顺序执行。 JS的Runtime中,只有一个Call Stack(中文翻译为调用栈或者执行栈),用于执行JS代码,它遵循FILO(First-In-Last-Out)规则。 我们用下面动图来演示一个任务在Call Stack中的执行过程。

02.gif

我们可以在浏览器中证明这一点,下图中f()方法抛出错误,我们可以在错误信息中依次看到调用关系,即:f被fn调用,fn被foo调用,foo被一个匿名函数调用。值得注意的是,最后的这个匿名函数在Call Stack中的位置是1,这是JS Runtime执行JS代码的主函数,也就是上图中的main()

errors.png

因为Call Stack的空间有限,所以当你的代码中出现无限循环的情况时,就会爆出一个Ranger Error。

03.gif

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

异步

也是因为同一个时间只能运行一个任务,如果前一个任务耗时很长,后一个任务就不得不一直等着。

04.gif
如图中readFileSync方法,会同步读取文件,在读取的过程中,不能做任何其他事情,造成了阻塞。 令人尴尬的是,这个时候CPU是几乎闲置的,瓶颈在于低速的IO(比如网络请求,或者文件系统的读写)。 这不是我们想要的。于是JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再通知主线程,把挂起的任务继续执行下去。
05.gif
如图中改用异步的readFile方法,主线程在执行readFile之后并没有等待结果返回,而是继续执行console.log('end')。 readFile的回调函数被挂起,在系统完成读取之后才被触发,然后进入Call Stack执行。

再谈Eventloop

Eventloop

值得注意的是,JS Runtime却不是单线程的,这句话怎么理解呢? 以浏览器为例,当网络请求发出而未返回数据的时候,鼠标的点击事件还是可以被响应的,这归功于JS Rumtime的多线程。

在浏览器提供给JS Runtime,DOM操作、XHR、定时器等方法,我们称之为Web API。

异步方法调用了Web API,并将Callback(回调函数)挂起,待Web API返回结果之后,又会将Callback推入一个任务队列中,我们称为Callback Queue。 这个Callback Queue是FIFO(First-In-First-Out)的。当主线程的代码执行完成之后,就会去Callback Queue中轮询是否有Callback可以执行,如果有,就取出放入Call Stack中执行。

06.gif
这种运行机制被称作Eventloop(事件环)

微任务和宏任务

异步任务又细分为Micro Task(微任务)和Macro Task(宏任务):

任务类型 举例
Micro Task process.nextTick、Promise和 MutationObserver 等。
Macro Task setTimeout、setInterval、setImmediate、 script中整体的代码、 I/O操作、 UI渲染等。

这里我们重点说一下process.nextTick和Promise。

process.nextTick方法可以在当前Call Stack的尾部, 下一次主线程读取Callback Queue之前触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。 Promise的回调函数会组成Promise Job Queue,在每个事件环的周期中,Runtime会先查看Promise Job Queue中有无任务。如果有,就先执行。如果没有再去执行Callback Queue中的任务。 流程如图:

流程

下面给出一道题,大家可以练一练

process.nextTick(function(){
    console.log(7);
});
setImmediate(function(){
    console.log(9);
})
new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){
    console.log(5);
});
process.nextTick(function(){
    console.log(8);
});
// 3 4 7 8 5 9

<本文完>

以上为笔者对JS引擎和事件环的一些理解,如有任何疑问或者勘误,欢迎在评论区留言~

谢谢~

参考文档:

www.ruanyifeng.com/blog/2014/1…

javascript.info/microtask-q…

vimeo.com/96425312

zhuanlan.zhihu.com/p/24460769

jakearchibald.com/2015/tasks-…

zhuanlan.zhihu.com/p/104333176

zhuanlan.zhihu.com/p/69616627

blog.fundebug.com/2019/07/16/…

tc39.es/ecma262/#se…