JavaScript的Event Loop详解

1,167 阅读20分钟

前言

最近在跟团队内的小伙伴们一起学习和研究Vue.js的源码,其中有一块是nextTick函数的实现,这个函数的主要作用就是使用宏任务或微任务来异步执行界面的渲染更新操作等等,所以我本来是打算深入研究一下JavaScript的宏任务和微任务的,但是后来我发现我连JavaScript基本的运行机制都没太搞懂。

  • 什么是调用栈(Call Stack)?
  • 什么是执行上下文(Execution Context)?
  • JavaScript的异步任务(setTimeout?Promise?)又是如何运作的?
  • JavaScript的Event Loop又是个神马东东?
  • ……

带着这些疑问,我开始了长达一个多月的探索之旅。幸运的是,我找到了一些非常棒的学习资料,它们让我受益匪浅,也解答了我的很大一部分问题,这就是我今天要分享给大家的我的学习成果,我会在这一篇博客里把我看过的这些学习资料中所讲到的重点知识全部都包含进来,以帮助大家更快更全面地理解JavaScript基本的运行机制。

话不多说,我们进入正文。

内存的划分

在一个经典的计算机系统架构中,程序在运行时会把分配到的内存划分成四个区块,分别是:Code区块、Static/Global区块、Stack区块以及Heap区块。

程序运行时的内存划分

  • Code区块:用于装载程序运行的指令,其实就是你编写的代码最终编译成的机器指令;

  • Static/Global区块(以下简称:Static区块):用于存放全局变量。定义在函数内的变量只能在该函数内可见,在函数外是无法直接访问到的,但是定义在这里的变量可以在任何函数中都能够访问得到;

  • Stack区块:即Call Stack,用于存放函数运行时的数据信息。包括:函数调用时的参数、函数内定义的变量、函数运行结束后返回的地址等等;

  • Heap区块:函数运行时的基本数据类型的数据会直接保存在Stack中,而对象类型的数据则会在Heap区块中分配内存进行存储,然后返回分配内存的起始地址以保存在Stack中声明的变量中以便后续访问。

Stack(调用栈)

我们目前只需要关注Stack区块即可。Stack是一个典型的栈类型数据结构(FILO:First In Last Out)。当JavaScript中的函数运行时,会往Stack栈中Push一段数据,这段数据我们称之为Stack Frame,当函数运行结束后,会将该函数对应的Stack Frame数据段Pop出栈。所以,函数间的嵌套调用就会在Stack栈中堆叠一摞的Stack Frame数据段。为了让你有一个更清晰直观的认识,接下来我们来看一段代码(示例一):

function foo() {
  console.log('foo');
}

function bar() {
  foo();
  console.log('bar');
}

function baz() {
  bar();
  console.log('baz');
}

baz();

这段代码很简单,它的运行结果就是依次打印出:foo、bar和baz。我们来看一下这段代码在运行过程中Stack区块的变化情况。

第0步:程序准备执行,分配并划分内存空间,将代码指令装载进Code区块并开始执行。假设此时代码块的执行函数名为main,那么JavaScript Runtime会先将Stack Frame(main)压入Stack栈中,然后开始调用baz函数。

第0步:程序准备执行

第1步:调用baz函数,将Stack Frame(baz)压入Stack栈中。

第1步:调用baz函数

第2步:baz调用bar函数。将Stack Frame(bar)压入Stack栈中。

第2步:baz调用bar函数

第3步:bar调用foo函数。将Stack Frame(foo)压入Stack栈中。

第3步:bar调用foo函数

第4步:foo调用console.log函数。将Stack Frame(log)压入Stack栈中。

第4步:foo调用console.log函数

第5步:console.log函数在控制台打印出‘foo’,执行完毕后将Stack Frame(log)推出Stack栈。

console.log函数执行完毕

第6步:foo函数执行完毕,将Stack Frame(foo)推出Stack栈。

第6步:foo函数执行完毕

第7步:bar调用console.log函数。将Stack Frame(log)压入Stack栈中。

第7步:bar调用console.log函数

第8步:console.log函数在控制台打印出‘bar’,执行完毕后将Stack Frame(log)推出Stack栈。

第8步:console.log函数执行完毕

第9步:bar函数执行完毕,将Stack Frame(bar)推出Stack栈。

第9步:bar函数执行完毕

第10步:baz调用console.log函数。将Stack Frame(log)压入Stack栈中。

第10步:baz调用console.log函数

第11步:console.log函数在控制台打印出‘baz’,执行完毕后将Stack Frame(log)推出Stack栈。

第11步:console.log函数执行完毕

第12步:baz函数执行完毕,将Stack Frame(baz)推出Stack栈。

第12步:baz函数执行完毕

第13步:程序运行结束,将Stack Frame(main)推出Stack栈,Code区块和Stack区块均使用完毕等待被GC回收。

第13步:程序运行结束

看到这里,你应该已经对JavaScript的Call Stack有了一个更清晰直观的认识了。

接下来,我们来聊一聊JavaScript中的“报错”。相信大家在浏览器中开发时都碰到过报错的情况,这时候浏览器终端会输出一段报错信息,里面包含了错误发生时的Stack栈中的函数调用链路情况。例如,我把上面的代码改成这样(示例二):

function foo() {
  throw new Error('error from foo');
}

function bar() {
  foo();
}

function baz() {
  bar();
}

baz();

代码执行后,会在浏览器终端打印出下面这样的报错信息。

示例二:error from foo

基于Stack这样的设计,编译器就能够很轻松地定位发生错误时的函数调用链路情况,我们也就能够很方便地排查发生错误的原因了。

Stack Overflow(栈溢出)

很多人也碰到过栈溢出(Stack Overflow)的问题。那么为什么会有栈溢出的情况发生呢?因为Stack栈的大小是在程序运行开始前就已经确定下来不可变更的,所以当你往栈中存放的数据超出栈的最大容量时,就会发生栈溢出的情况。通常的原因都是因为代码的Bug导致函数无限循环嵌套调用,如同下面这个示例(示例三)所示:

示例三:栈溢出的报错

单线程的JavaScript

我们都知道,JavaScript是一门单线程(single-threaded)的语言,单线程就意味着“JavaScript Runtime只有一个Call Stack”,也意味着“JavaScript Runtime同一时间只能做一件事情”,来看看下面这段代码(示例四):

let arr = [0, 1, 2, 3, 4, 5];

/* 平方值 */
function square(arr) {
  return arr.map((item) => item * item);
}

let res1 = square(arr);
console.log(res1); // [0, 1, 4, 9, 16, 25]

/* 立方值 */
function cube(arr) {
  return arr.map((item) => item * item * item);
}

let res2 = cube(arr);
console.log(res2); // [0, 1, 8, 27, 64, 125]

这段代码很简单,给定一个arr数组,分别计算输出数组中每一个数值求平方和求立方之后的结果数组。这段代码在JavaScript中必然是顺序执行的,先求平方再求立方,但是我们不妨设想一下,因为square和cube函数做的事情互不相干,那么我们能不能让它们并行执行以提高运行效率呢?在这里因为arr数组很短,两个函数的计算逻辑也很简单,所以这段代码运行起来非常地快,但是如果arr数组非常地大,square和cube方法又进行了一些非常耗时的复杂计算的话,那么我们的设想就变得非常地有意义了。但是,可行吗?答案是:No。之前我说过,JavaScript Runtime是单线程的,它同一时间只能做一件事情。所以我们写的JavaScript代码只能单向串行执行,无法并行执行(这里暂不考虑Web Workers等技术)。

但是,如果是这样的话,那么我们在代码中使用setTimeout函数时,就必须等待setTimeout指定的延迟时长过后执行回调函数,然后才能继续执行后面的代码,使用ajax发送请求也是同样的情况,我们必须等到请求结果返回后执行回调函数,代码才能继续往后走。但是我们知道这些都不是真实的情况,那么为什么会存在这样的矛盾点呢?

先不着急揭晓答案,我们先来研究一下setTimeout函数。

setTimeout

setTimeout函数基本的功能,就是接收一个回调函数和一个delay延迟时长(默认为0),然后在delay时长过后执行回调函数。来看一下下面的这段代码和它的运行结果(示例五):

function foo () {
  console.log('one');

  setTimeout(function inner() {
    console.log('two')
  }, 0);

  console.log('three');
}

foo();

示例五

也许有些同学会对运行结果感到很意外,'three'竟然在'two'之前被打印出来,我们都知道setTimeout可以延迟执行一段函数,但是为什么延迟时长设置为0都不能让inner函数立即被执行呢?为了探究这个问题,我们来看一下这段代码在运行过程中Stack区块的变化情况:

第1步:调用foo函数

第2步:foo调用console.log函数

第3步:console.log函数执行完毕

第4步:foo调用setTimeout函数

第5步:setTimeout函数执行完毕

第6步:foo调用console.log函数

第7步:console.log函数执行完毕

第8步:foo函数执行完毕

第9步:inner函数开始执行

第10步:inner调用console.log函数

第11步:console.log函数执行完毕

第12步:inner函数执行完毕,程序运行结束

我们重点关注上面的第4步、第5步和第9步。可以看到,当第4 ~ 5步调用setTimeout函数后,Stack Frame(setTimeout)莫名消失了,它接收的回调函数inner在此时并没有被执行,程序继续往后走从而打印出'three'。当第8步foo函数执行完毕,也就是看似整段代码执行结束后,第9步inner函数又莫名出现在了Stack栈中并开始执行,inner函数运行完毕后整段代码才真正地运行结束。

我们再来看看另外一个例子(示例六):

function foo() {
  let start = Date.now();

  console.log('start');

  setTimeout(function inner() {
    console.log('inner: ' + (Date.now() - start));
  }, 2000);

  while((Date.now() - start) < 1500);
  
  console.log('end: ' + (Date.now() - start));
}

foo();

示例六

这段代码的运行结果有两点值得我们关注。第一点,foo函数因为包含了一行空while语句而执行了1500ms,但是setTimeout中的inner函数似乎并没有受到任何影响,仍然在2秒钟之后开始执行,说明foo函数的执行和setTimeout的计时操作是在并行执行的。第二,inner函数打印的时间差并不是刚刚好等于2000ms,而是2002ms,而且如果你运行这段代码的话你就会发现,你打印的结果很可能跟我不一样,但是一定是大于等于2000ms的一个值。

庐山真面目

著名的v8引擎是Chrome和NodeJS背后使用的JavaScript Runtime引擎,而你在它的源码里是搜不到setTimeout、DOM、Ajax等字样的,因为它本身只包含了heap和stack,其他的setTimeout、DOM、Ajax等相关的功能都是由浏览器基于v8引擎之上所构建和提供的WebAPIs功能。这些WebAPIs和v8引擎一样都是用C++编写的,它们会以独立的线程的方式提供服务,所以我们的JavaScript Runtime是单线程的没错,但是当我们调用这些WebAPIs时,它们就会另起一个独立的线程来完成各自的工作,这样我们的JavaScript代码才有了并发的效果。

v8引擎的结构图如下所示:

v8引擎的结构图

而浏览器的全貌图是这样子的:

浏览器的全貌图

Event Table(事件映射表)

首先介绍一下WebAPIs部分,浏览器会维护一个事件映射表(Event Table),它记录着事件与回调函数之间的映射关系。如果你想监听某个DOM的click事件的话,那你就必须先在该DOM上注册click事件,然后当该DOM接收到click事件时才会有回调函数被执行,如果某个事件没有被绑定回调函数的话,那么该事件发生时就如同石沉大海一样什么也不会发生。Ajax也是一样,如果不添加返回响应时的回调函数的话,那么就会变成单纯的发送一个HTTP请求,也不会有后续的回调函数处理响应内容了。setTimeout自不必说,它必须要设置一个回调函数才有意义。总而言之,这些事件与回调函数之间的映射关系都会被浏览器记录在Event Table表里,以便当对应事件发生时能执行对应的回调函数。

Message Queue(消息队列)

接下来是消息队列Message Queue(简称MQ),有些文章称之为Event Queue或者Callback Queue,说的都是同一个东西。MQ是一个典型的FIFO(First In First Out)的消息队列,新消息会被加入到队列的尾部,消息的执行顺序与加入队列的顺序相同。每一条消息都有与之绑定的一个函数,当队首的消息被处理时,消息对应的函数就会把消息当做输入参数并开始执行。刚刚Event Table中记录的事件发生时,就会往MQ队列中加入一条消息,然后等待被执行

Event Loop

接下来我们就要触及到整篇文章的重点和核心了,那就是Event Loop。刚刚我们说到,消息已经被加入到MQ队列中,那么消息什么时候会被处理呢?这时候就该Event Loop登场了。

Event Loop实际做的事情非常地简单:它会持续不断地检查Call Stack是否为空,如果为空的话就检查MQ队列是否有待处理的消息,如果有的话就从MQ队列的队首取出一条消息并执行消息绑定的函数,如果没有的话就同步监控MQ队列是否有新的消息加入,一旦发现就立即取出并执行消息绑定的函数。整个过程不断重复

知道了Event Loop的运行机制之后,之前的几个疑问就迎刃而解了。

首先看下示例五的setTimeout神秘消失和离奇闪现事件。我现在把第4步、第5步、第8步和第9步的完整截图发出来给大家看看:

第4步:foo调用setTimeout函数

第5步:setTimeout函数执行完毕

第8步:foo函数执行完毕

第9步:inner函数开始执行

第5步中,我们调用了浏览器提供的setTimeout方法,随即启动一个单独的线程做计时操作,然后往Event Table中加入一条记录。这里由于delay参数设置为0,所以事件会被立即触发,然后往MQ队列中加入一条消息,由于此时Call Stack还不为空,所以消息会在MQ队列中等待。第8步中,foo函数执行完毕,Call Stack被清空,Event Loop发现Call Stack为空之后立即检查MQ队列,发现有一条待处理的消息,于是从队列中取出消息并开始执行消息绑定的函数,也就是inner函数,最后inner函数执行完毕,至此整个程序运行结束。大家可以在这儿看到完整的过程

再来看下示例六的两个问题点。第一点答案已经揭晓了,我们的JavaScript Runtime和setTimeout是在两个独立的线程上并行执行的。关于第二点,我相信有些同学已经知道答案了,因为添加在setTimeout中的回调函数在倒计时结束之后并不会被立即执行(即便delay参数被设置为0),而是需要先将消息添加到MQ队列的队尾,然后等待排在前面的消息全部被处理完毕后才能开始执行,这个过程总归要花点时间,所以通常setTimeout回调函数执行时的实际delay时长都要大于指定的delay时长。同样给出示例六的完整运行过程

顺便提一下,浏览器的每一个tab(iframe标签和Web Workers同样如此)都拥有自己独立的Event Loop以及一整套的Runtime运行环境,包括Call Stack、Heap、Message Queue、Render Queue(后面会提到)等等,这样就保证了即便某一个tab因为执行了某种耗时的操作被阻塞,其他的tab也能够正常运作,而不会说直接导致整个浏览器被卡死。不同Runtime之间的通讯方式可以看这里

Blocking(阻塞)

JavaScript号称是一门“single-threaded(单线程)、non-blocking(非阻塞)、asynchronous(异步的)、concurrent(并发的)”编程语言。这确实是事实但也不尽然。说它是事实是因为浏览器将网络请求、文件操作(NodeJS)等几乎所有耗时的操作都以独立线程(concurrent)和异步回调(asynchronous)的形式提供给我们使用,所以我们的JavaScript Runtime主线程可以持续高效不间断地执行我们的JS代码,这就是非阻塞(non-blocking)的含义。单线程(single-threaded)的JavaScript Runtime是优势也是劣势。优势在于它简化了我们编写代码的方式,使得我们可以不用考虑复杂的并发问题。劣势在于一旦有耗时的操作占据了JavaScript Runtime主线程的话,就会导致MQ队列中的消息无法得到及时的处理,还会阻塞UI渲染线程的执行,进而影响到页面的流畅性。

我们将上面的示例六稍作改动,这次我们把setTimeout的delay参数设置为500ms,来看看会发生些什么(示例七):

function foo() {
  let start = Date.now();

  console.log('start');

  setTimeout(function inner() {
    console.log('inner: ' + (Date.now() - start));
  }, 500);

  while((Date.now() - start) < 1500);
  
  console.log('end: ' + (Date.now() - start));
}

foo();

示例七

可以看到,整体代码的运行耗时依然是1500ms不变,但是我们发现inner函数执行时时间也过去了1500ms,而并没有像我们期望的那样在500ms后就执行,原因就是因为while((Date.now() - start) < 1500);是一句同步的操作,它的执行会占据JavaScript Runtime主线程和Call Stack调用栈,进而导致即便inner函数对应的消息在500ms之后就已经在MQ队列中等待,但是由于此时Call Stack并不为空,所以inner函数就无法被Event Loop及时Pick进入Call Stack执行,它不得不等到1500ms过后Call Stack被清空,然后才能被执行。实际的运行效果请大家自行查看。

Rendering(渲染)

刚刚我们有提到,如果JavaScript Runtime主线程被阻塞的话,同样会影响到UI渲染线程的执行,而一旦UI渲染线程被阻塞,用户就无法在页面上执行点击、滑动等操作了。这究竟是为什么呢?

原来,在浏览器的实现中,UI渲染操作(或者说是DOM更新操作)同样是以队列的形式处理的。类似于Message Queue,浏览器会维护一个Render Queue(简称RQ)来专门存放UI渲染消息,而且它跟MQ一样,必须等到Call Stack为空时才能被处理,不同的是,它的处理优先级是要高于MQ的。界面刷新的频次一般是每秒钟60次,也就是每16.67ms会执行一次,所以Event Loop每隔16.67ms就查看一下RQ队列是否有待处理的消息,如果有的话就检查Call Stack是否为空,为空就从RQ队列取出消息并处理,否则就继续等待直至Call Stack被清空,然后再处理RQ队列中的UI渲染消息。

我相信大家都碰到过页面卡顿的情况,原因就在这里了。我之前发的链接工具叫做loupe,是一个专门用来观察JavaScript Runtime的工具网站,打开它并点击左上角的图标就可以展开设置面板,里面可以设置代码运行时停顿的时长,还可以模拟UI渲染操作,勾中之后就可以查看当主线程代码运行时,UI渲染消息被阻塞的过程了。我们还是以示例六为例,来看看实际的运行效果:

RQ队列被阻塞

再谈Blocking

我们已经知道,JavaScript Runtime主线程的阻塞会导致RQ队列和MQ队列中的消息无法被及时处理,所以我们要尽量避免执行一些同步耗时的操作,要给到这些队列中的消息被处理的机会。

同样,会阻碍队列消息被及时处理的还有队列本身被阻塞的情况。比较典型的场景是在document的onscroll事件上绑定了回调函数,由于onscroll事件触发的频次同样是每秒60次,所以当用户滚动页面时,很容易就会把MQ队列塞满,如果回调函数里还执行了一些UI渲染等耗时的操作的话,那简直就是灾难性的,毕竟UI渲染线程和JavaScript Runtime主线程是无法并行执行的(运行效果传送门)。

尾声

至此我的分享就结束了,感谢Philip Roberts在2014年欧洲JSConf上精彩的演讲,是他让我真正搞明白了JavaScript的Event Loop究竟是如何工作的,之前提到的loupe也是他的杰作,附上油管链接优酷链接以供各位看官享用:)

参考资料

  1. What the heck is the event loop anyway? | Philip Roberts | JSConf EU
  2. Concurrency model and Event Loop
  3. The JavaScript Event Loop
  4. Understanding JS: The Event Loop
  5. JavaScript Event Loop Explained

***更新@2019年07月07日***

闭包

最近又看了一篇博客(The JavaScript Event Loop: Explained),引发了我对于闭包的思考,考虑如下代码:

function foo() {
  let a = {
    name: 'Chris, Z',
    gender: 'Man',
  };
  
  let b = 'Baby';
  
  let c = 1024;
  
  let d = true;
  
  setTimeout(function inner() {
    console.log(a);
    console.log(b);
    console.log(c);
    console.log(d);
  });
}

foo();

按照我们之前所说的,当foo执行完毕后它对应的stack frame(foo)就被移出Call Stack栈而不复存在了,但是我们也知道inner函数执行时是能够访问到foo函数内定义的abcd变量的,这不是矛盾了吗? 我其实也没找到具体的资料解释这一块Runtime引擎是怎么处理的,所以我大胆地设想了几种可能的做法:

做法一

stack frame(foo)出栈时确实被内存回收了,但是Runtime引擎在这里做了优化,inner函数会将abcd变量的值拷贝下来保存到某个地方,由于a变量指向了堆中的一个对象,b变量指向了堆中的一个字符串常量,它们都是引用值,所以当inner函数将ab变量的引用地址值保存下来时,stack frame(foo)中声明的ab变量本身就可以被放心地回收了,ab变量所指向的堆地址由于仍然被inner函数所引用而不会被GC回收,进而可以在inner函数执行时被引用到。而cd变量就更简单了,它们只是原始类型而已,直接被inner函数拷贝保存下来就可以了,既不会影响stack frame(foo)的内存回收,也不会影响inner函数执行时引用到cd变量的值。

做法二

stack frame(foo)并不会真正出栈(逻辑上已经出栈,但物理上仍然占据栈内存),inner函数也无需在执行前保存它引用的变量值。那么此时Call Stack在内存空间上就会形成“空洞”,只不过Runtime引擎会很好地处理这种情况,不会让后续的stack frame入栈和出栈感受到“空洞”的存在而已。

做法三

前面的跟做法二一样,只不过Call Stack会直接用跳过stack frame(foo)的一个新地址作为起始地址开始构建,这样就不会形成“空洞”了。

当然,上面的这些都只是我个人的猜想而已,如果谁有确切的答案还望不吝赐教。

参考资料

  1. JavaScript main thread. Dissected.
  2. The JavaScript Event Loop: Explained