关于javascript:JavaScript的运行机制之Event-Loop

5次阅读

共计 4738 个字符,预计需要花费 12 分钟才能阅读完成。

在了解 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 解决,等到放进异步工作的队列中期待被执行。当执行栈中的工作为空时,就去异步工作的队列中取出工作,放进执行栈中执行,这个过程是一直反复的。整个过程能够如下图所示:

这便是浏览器最简略的 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 渲染,则间接进入下一个循环。

这个过程咱们用更具体的一张图示意如下:

到这里,可能人会有问,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 运行如下图:

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 个阶段的运行如下图所示:

小结

  • 每一个阶段都会有一个 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 Loop》https://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
正文完
 0