JavaScript的运行机制之Event Loop

在理解JavaScript的Event Loop之前,我们先来了解一下几个知识点:

JavaScript单线程

JavaScript是一门单线程语言。

JavaScript的主要用途是与用户交互,而交互过程中时常要操作DOM。我们假设它是一门多线程的语言,想象一下这样的场景:有两个线程A、B同时操作同一个DOM节点,假设一个线程A某个DOM节点上添加内容,而线程B删除了这个节点,这时浏览器应该以哪个线程的操作为准呢?

因此,JavaScript语言的作者在设计之初基于它的用途避免复杂性的考虑,将它设为一门单线程语言。

浏览器多线程

我们说JavaScript是单线程的,是指我们编写的所有JavaScript代码将形成一个个的“任务”,所有的“任务”都需要在主线程上执行,而且是一个任务执行完,才可以执行下一个任务。那么问题来来,当遇到setTimeout、浏览器事件处理、AJAX请求等代码(任务)时,它是怎么执行等呢?如果要一直等到这些任务执行完了再运行后面的代码,那么当这些任务需要很长的时间才执行完时,将很可能为用户带来糟糕的体验,导致用户不耐烦地离开你的网站页面甚至一去不复返,这对网站来说将是灾难性的打击。那么有没有什么方法可以不因这些任务而产生等待呢?

原来,浏览器是多线程的,我们打开一个tab的时候,就开启了一个独立的进程,这个进程包含了多个线程:

  • JS引擎线程(主线程),负责js代码的解释和执行
  • GUI渲染线程,负责页面的绘制
  • 定时触发器线程,如我们编写的setTimeout/setInterval由该线程处理
  • HTTP异步请求线程,负责处理网络请求和响应
  • 浏览器事件触发线程,处理click、input、scroll等浏览器事件

这样,主线程在遇到setTimeout,AJAX请求的时候,就将它们定义的任务交给对应的线程处理,然后继续运行后面的代码。等到这些任务有了结果或某一事件到达触发条件(如setTimeout设定的任务到了设定的延时、AJAX发出的请求状态变更,用户点击了页面元素等)后再将它们的回调函数添加到相应的任务队列,等待主线程空闲时将它们取出执行,从而实现了异步。

需要注意的是,JavaScript引擎线程和GUI渲染线程是互斥的,它们其中一个线程的执行会阻塞另一个线程的执行。这也是我们最好把一些script标签放到body元素末尾的原因:当浏览器在加载HTML文件过程中遇到script标签时会停下来(除非你在script中声明了async或defer属性),因为JavaScript代码可以会改变HTML结构(线程互斥的原因);而JavaScript引擎线程执行JavaScript代码时,GUI渲染线程是不工作的,也就是页面渲染被阻塞了,这当然不是我们想要的。

Event Loop

进入正题,既然浏览器是多线程的,那么这些线程之间是怎么进行协作的呢?答案是Event Loop。

Event Loop是计算机系统的一种运行机制,浏览器引入Event Loop解决单线程的JavaScript的异步问题

  • 浏览器的Event Loop是在html5的规范中明确定义。
  • NodeJS的Event Loop是基于libuv实现的。可以参考Node的官方文档以及libuv的官方文档
  • libuv已经对Event Loop做出了实现,而HTML5规范中只是定义了浏览器中Event Loop的模型,具体的实现留给了浏览器厂商。

我们说JavaScript是单线程的,就是说任何时候都只有一个线程在运行JavaScript代码,这个线程我们称之为“主线程”。JavaScript引擎在解析JavaScript代码都时候,把要运行的代码划分成一个个的“任务”,这些代码在主线程上一个一个地执行,形成一个“执行栈”。而这些任务又可以分为两大类:同步任务和异步任务。

同步任务(如JavaScript整体代码)直接放进主线程的执行栈中执行,而异步任务则交由对应的API处理,等到放进异步任务的队列中等待被执行。当执行栈中的任务为空时,就去异步任务的队列中取出任务,放进执行栈中执行,这个过程是不断重复的。整个过程可以如下图所示:

JavaScript的运行机制之Event Loop_第1张图片

这便是浏览器最简单的Event Loop模型。

宏任务和微任务

而在ES6出来之后,伴随着原生Promise的加入,异步任务又可以细分为“宏任务”(macrotask)和“微任务”(microtask),在最新规范中又将它们分别称为tasks和jobs。

异步任务中,属于“宏任务”的有:

  • setTimeout,setInterval(它们同属于一个任务源)
  • 浏览器事件
  • 异步HTTP请求
  • requestAnimationFrame
  • setImmediate (node.js独有)
  • 其他I/O操作如使用FileReader的异步接口等

属于“微任务”的有:

  • Promise.prototype.then catch finally
  • MutationObserver
  • process.nextTick (node.js独有)

根据规范,宏任务的队列可以有多个,而微任务队列只能有一个。并且:

  • 在一次循环中,主线程先从宏任务的队列中取出一个(位于队首的)任务放入执行栈执行。
  • 执行完一个宏任务(执行栈为空)后,再将微任务队列中的任务依次取出执行,直到微任务队列为空。
  • 这个过程中,如果执行微任务时又产生了新的微任务,会将新的微任务加入微任务队列的末尾。
  • 最后在进入下一个循环的间隙,由浏览器决定要不要进行UI的渲染,如果需要则进行由GUI渲染线程进行UI渲染操作,渲染完成后通知主线程进入下一个事件循环;如果不需要UI渲染,则直接进入下一个循环。

这个过程我们用更详细的一张图表示如下:

JavaScript的运行机制之Event Loop_第2张图片

到这里,可能人会有问,Event Loop事件循环机制是怎么循环起来的呢?或者更具体的,当Event Loop尝试进入下一个循环,去宏任务队列取任务发现队列为空时,循环不就进行不下去了吗?

笔者认为,如何循环不是本文的重点,加之不同浏览器可能有不同的实现,故不作更多阐述。试想,如果是你自己实现,独立一个线程去监听执行栈是否为空也未尝不可,或在没有任务要执行的时候将主线程挂起,等到有新的任务被添加进来再继续执行也不是不行。

小结

  • JS是单线程运行的,而浏览器是多线程运行的,浏览器使用Event Loop模型实现异步操作
  • 一开始时,JS引擎把同步代码放入执行栈中执行,而异步代码交给相应的线程或API处理,等到有结果或到达触发条件了,就把注册的回调函数放入异步任务队列中等待主线程取出执行
  • 随着ES6 Promise的原生实现和HTML5 MutationObserver 的加入,将异步任务细分为宏任务和微任务
  • 宏任务的回调放入宏任务队列(可以有多个),微任务的回调放入微任务队列,并且每一次Event Loop循环中,都是先取出一个宏任务执行,然后取出所有微任务执行
  • 一次Event Loop循环过程:整体代码(第一个宏任务)=>所有微任务=>UI渲染,接着进入下一轮循环。

Node.js中的Event Loop

前面我们已经讲到,node.js的Event Loop是基于libuv实现的,而node.js中的Event Loop和浏览器中的Event Loop运行机制有很大的不同,nodejs中 Event Loop 运行如下图:

JavaScript的运行机制之Event Loop_第3张图片

node.js与浏览器中的Event Loop不同主要表现在:

  • node.js中的Event Loop的单次循环是分阶段进行的。每个阶段运行完所有该阶段的回调函数或回调次数达到了次数限制,才会进入下一个阶段或指定的阶段,直到运行完最后一个阶段,进入下一个循环。
  • 除了Poll阶段,node会在每个阶段,将该阶段对应的所有宏任务都依次执行完,然后执行微任务队列中的所有任务。(注意:node.js 11.0及以后的版本中,Goole为了向浏览器靠齐,将这一行为改成与浏览器一致,即每个 Macrotask 执行完后,就去执行 Microtask 了)

node.js中,将Event Loop分为以下几个阶段:

Timers阶段

这个阶段执行setTimeout/setInterval的已到期的回调函数。执行顺序:

  1. 所有setTimeout/setInterval的回调函数
  2. 所有process.nextTick的回调函数
  3. 所有微任务的回调函数

Pending callbacks阶段

即IO callbacks阶段。

根据libuv的文档,一些应该在上轮循环poll阶段执行的callback,因为某些原因不能执行,就会被延迟到这一轮的循环的I/O callbacks阶段执行。换句话说这个阶段执行的callbacks是上轮残留的。

Idle/Prepare阶段

仅供内部使用(略)

Poll阶段

注:Node很多API都是基于事件订阅完成的,这些API的回调应该都在poll阶段完成。

这个阶段的运行机制和其他阶段有所不同,也是最复杂的阶段。表现如下:

  • 当回调队列不为空时,会执行回调。与其他阶段不同的是,该阶段产生的微任务,不会等到所有宏任务的回调执行完之后再执行,而是执行完一个宏任务就执行所有微任务,即与浏览器的行为一致。
  • 当回调队列为空的时候,这里又分两种情况:

1.如果有待执行的setImmediate回调,那么事件循环直接结束poll阶段进入check阶段

2.如果没有待执行的setImmediate设定回调,会检查有没有已到期的timers:

  • 如果有,那么事件循环将回到timers阶段
  • 如果没有,这个时候事件循环会阻塞在poll阶段等待回调被加入poll队列

Check阶段

这个阶段执行setImmediate的回调。执行顺序:

所有setImmediate的回调函数

所有process.nextTick的回调函数

所有微任务的回调函数

Close callbacks阶段

执行一些关闭回调,如 socket.on('close', ...)

这6个阶段的运行如下图所示:

JavaScript的运行机制之Event Loop_第4张图片

小结

  • 每一个阶段都会有一个FIFO回调队列,都会尽可能的执行完当前阶段中所有的回调或到达了系统相关限制,才会进入下一个阶段
  • 除了Poll的各个阶段,执行顺序都是:所有宏任务队列任务回调=>所有nextTick队列任务回调=>所有其他微任务回调
  • Poll 阶段执行的微任务的时机和 Timers 阶段 & Check 阶段的时机不一样,前者是在每一个回调执行就会执行相应微任务,而后者是会在所有回调执行完之后,才统一执行相应微任务。
  • node.js新版本中(v11.0及以后版本中),宏任务和微任务的执行顺序和过程保持一致。

参考资料:

  1. 阮一峰《JavaScript 运行机制详解:再谈Event Loop》https://blog.csdn.net/qianyu6200430/article/details/108989045
  2. youth7《不要混淆nodejs和浏览器中的event loop》https://cnodejs.org/topic/5a9108d78d6e16e56bb80882
  3. libuv文档:http://docs.libuv.org/en/v1.x/design.html#the-i-o-loop
  4. liuxuan带你彻底弄懂Event Loophttps://segmentfault.com/a/1190000016278115?utm_source=tag-newest
  5. 《[[译] 深入理解 JavaScript 事件循环(二)— task and microtask](https://www.cnblogs.com/dong-...https://www.cnblogs.com/dong-xu/p/7000139.html

你可能感兴趣的:(javascript事件循环)