浏览器的事件循环机制(event loop)

655 阅读9分钟

要理解浏览器的event loop,要从javascript是一门单线程语言讲起。

1、单线程语言和多线程运行环境

​ 之所以说js 是一门单线程语言,指的是负责解释并执行JS代码的线程只有一个,常称之为主线程

​ 虽然js是单线程语言,但是浏览器却是多线程并行的

​ 在js语言的运行时环境中,除了需要js引擎,还需要浏览器提供的 Web API(比如settimeout,ajax请求,鼠标点击事件的处理等等)、回调队列,事件循环。

​ 也就是说,在浏览器中,有js引擎线程,GUI渲染线程,JS引擎线程,事件触发线程,定时触发器线程,异步http请求线程等等。

那么这些线程是怎么协同合作的呢?

​ 前面说过,负责解释并执行 JS 代码的线程只有一个。在这些线程里面,js 引擎线程负责解析和执行 js 代码,其他线程的处理结果,都要等待js引擎线程的响应,才能执行相关的 js 代码。js 引擎线像一个总工程师一样,调配与控制着其他线程。

如下图所示,是使用V8引擎的 js 运行时环境的示意图:

希望读完本文,大家能够对完全看懂这张图哦~

2、执行栈和内存堆

​ 如上图所示,js 引擎在运行过程中,只有一个执行栈(call stack)和一个内存堆(memory heap)。

  • 堆:存放变量的地方,比如当chorme的V8引擎遇到变量声明和函数声明的时候,就把它们存储在堆里面。

  • 执行栈:

    • 执行栈可以理解成一个函数调用栈,里面保存着函数的执行上下文
    • 当脚本要调用一个函数时,解析器把该函数push到栈顶并且执行这个函数。
    • 任何被这个函数调用的函数会进一步push到执行栈顶。
    • 执行栈总是执行最顶部的函数,当函数运行结束后,解释器将它从执行栈中pop出去。
    • 当执行栈为空时,返回 js 脚本中继续执行代码。

    我们来看看下面这段代码的执行栈:

    1 function thirdFunc() {
    2  console.log("i am the third one");
    3 }
    
    4 function secondFunc() {
    5  thirdFunc();
    6  console.log("i am the second one");
    7 }
    
    8 function firstFunc() {
    9  secondFunc();
    10 console.log("i am the first one");
    11 }
    
    12 firstFunc();
    
    // i am the third one
    // i am the second one
    // i am the first one
    

    ​ js自上而下的执行这段代码,1到11行皆为函数声明,第12行遇到了函数调用,调用了firstFunc();js 引擎将firstfunc函数压入执行栈;执行firstFunc()时,发现调用了secondFunc,将secondFunc压入执行栈,同理,将thirdFunc压入执行栈。所以这段代码的执行栈为:

    ​ 当thirdFunc函数运行完毕后,执行栈将它pop出去,继续执行secondFunc函数;同理,最后firstFunc也被pop出去,执行栈为空,返回 js 脚本继续执行其他代码。

3、同步任务和异步任务

​ js 引擎按顺序执行代码时,会区分同步任务和异步任务。

(1)同步任务

  • 同步任务可以立马得到结果(比如 console.log())。
    • 并且执行机制遵循要等上一个任务执行完毕,才能执行下一个任务;
    • 同步任务会放入主线程的执行栈中执行。

(2)异步任务

  • 异步任务不能立马得到调用的结果,一般会有一个回调函数;
    • 当 js 引擎遇到异步任务时,如上图所示的 web api's,会交给浏览器相应的线程去执行(比如 ajax 请求交给 http request 线程去处理);
    • 当处理完毕(比如等到了ajax的结果),会将回调函数放入上图所示的**回调队列(callback queue)**排队,等待主线程的调用,将回调函数推入执行栈中按同步任务规则执行。
    • 比如当发生鼠标点击事件时,会将相应的回调函数推入回调队列排队;settimeout 的计时到事件后,也会将回调函数推入回调队列排队。

4、执行栈、宏任务(task)与微任务(microtask)队列的配合

如何区分宏任务和微任务,何时将回调队列中的函数推入执行栈中执行,就是我们常常听说的事件循环机制(Event loop)来控制的啦。

1> 宏任务与微任务

​ 上面说的回调队列还可细分为宏任务队列和微任务队列。

​ 注意:由上图可以看出,事件循环机制是在运行时环境中的,即与浏览器有关,不同浏览器的事件循环可能不一样哦。

​ 下面本文以 chrome 的事件循环来作为讲解。

(1)通常认为宏任务的任务源有: setTimeout, setInterval, requestAnimationFrame,I/O, UI rendering

(2)通常认为是微任务源有: Promise.then, Object.observe, MutationObserver, MutaionObserver

注意:Promise是个同步,但是promise.then/catch是微任务。

2> 三者之间的执行关系

​ 我们用一张图来说明:

也就是说:

(1)当所有同步任务执行完毕,执行栈中为空时,查看微任务队列。

(2)若当前微任务队列不为空,我们将此刻的微任务队列记为队列A(之后微任务队列中再添加微任务都不属于队列A),接下来对A队列的操作是:

​ <1>将队列A的队首的第一个任务推入执行栈中。

​ <2>执行栈执行所有同步任务,执行栈中为空时,查看队列A是否为空;若为空,返回<1>。

​ <3>队列A为空后,进行浏览器渲染操作。注意:UI渲染线程与js引擎线程是互斥的,即渲染界面的时候,是不能解析和执行 js 脚本的。

​ <4>一轮事件循环结束。

(3)若此时微任务队列不为空,返回第二步;若为空,则继续;

(4)取出宏任务队列中的一个宏任务,放入执行栈中,返回第一步。

​ 由此可见,每次从宏任务队列中拿任务之前,都要查看微任务是否为空,否则优先执行微任务。

​ 注意:但并不是每轮event loop都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。

5、不可缺少的练习

1>试金石

我们来看下面这段代码的运行结果:

console.log('script start');
setTimeout(function() {
  console.log('second event loop')
  Promise.resolve().then(function(){
    console.log('promise3')
    setTimeout(() => {
      console.log('setTimeout2')
    }, 0);
    Promise.resolve().then(function () {
      console.log('promise4')
    })
  })
},0);
Promise.resolve()
.then(function() {console.log('promise1');})
.then(function() {  console.log('promise2');});
console.log('script end');

控制台的打印结果为:

不知是否与大家的预期一致呀?

下面我们来看看发生了什么?

(1)js 引擎运行到第一行代码,在控制台打印出 script start

(2)运行到第二行代码,注册setTimeout 的回调函数,在0s以后,推入宏任务队列;

(3)运行到14行,注册 Promise.then 的回调函数(Promise.resolve是个同步操作哦,then才是异步), 将该回调函数推入微任务队列;

(4)继续注册下一个then的回调函数, 将该回调函数推入微任务队列;

(5)运行到第17行,在控制台打印出 script end

(6)按序取出微任务队列中的第一个then的回调函数,放入执行栈中执行,在控制台打印出 promise1

(7)取出微任务队列中的第二个then的回调函数,放入执行栈中执行,在控制台打印出 promise2

(8)微任务队列此时已经清空啦,控制台打印出 undefined,表示第一轮事件循环结束;

(9)取出宏任务队列的setTimeout的回调函数,放入执行栈中执行,运行第3行的代码,在控制台打印出 second event loop

(10)运行第4行,注册then的回调函数,推入微任务队列;

(11)由于此时执行栈为空,从微任务队首取出then的回调函数,放入执行栈中执行;

(12)运行第5行代码,在控制台打印出 promise3

(13)运行第6行代码,遇到setTimeout异步任务,注册回调函数,经过0s后,推入宏任务队列;

(14)运行第9行代码,注册then的回调函数,推入微任务队列;

(15)执行栈中的第(10)步说的回调函数执行完毕,pop出去,执行栈中此时为空;

(16)由于此时执行栈为空,从微任务队首取出第(14)步then的回调函数,放入执行栈中执行,运行第10行代码,在控制台打印出 promise4

(17)由于微任务队列已空,从宏任务队列取出第(13)步的setTimeout的回调函数,放入执行中执行,运行第7行代码,在控制台打印出 setTimeout2

(18)Over。

2>栈溢出问题

看下面这段代码及其运行结果:

function callItself() {
    callItself();
};
callItself();

evernotecid://FD11820A-5CF6-468E-BC5B-9E2744875FE7/appyinxiangcom/8940975/ENResource/p843

上面这段代码报的错是栈溢出,因为执行栈中不停的推入callItself函数。

那么怎么能让他不报错呢?

巧用setTimeout:

function callItself() {
  setTimeout(() => {
    callItself();
  }, 0);
};
callItself();

这样就不会报错啦,但是这只是个异步与同步的演示用例,实际业务中没有这么用的哈。

3> vue.$nextTick

​ 关于 nextTick 的具体实现方式,这里就不细说了,有兴趣的童鞋可以查看这篇文章:Vue源码详解之nextTick

​ 总的来说,就是 vue.$nextTick 将它的回调函数都包在一个微任务里,这就保证了,在执行vue.$nextTick 回调之前,所有的同步操作都执行完了。比如 setAttribute 这些操作 dom 的同步操作已经完成,才会执行走到 nextTick 的回调函数。最后 nextTick 回调执行完毕,UI渲染进程开始渲染。

彩蛋

除了文中已经附上的点击跳转链接,本文还借鉴了以下文章~

参考文献:

1、How JavaScript works

2、从浏览器多进程到JS单线程

3、关于JavaScript单线程的一些事

4、从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理

5、js执行上下文

6、Tasks, microtasks, queues and schedules