你应该了解一下的 事件循环/event loop 详解

相关系列: 从零开始的前端筑基之旅(面试必备,持续更新~)javascript

javascript是一门单线程语言,在最新的HTML5中提出了Web-Worker,但javascript是单线程这一核心仍未改变,无论谁写的代码,都得一句一句的来执行。html

当咱们打开网站时,网页的渲染过程包括了一大堆任务,好比页面元素的渲染。script脚本的执行,经过网络请求加载图片音乐之类。若是一个一个的顺序执行,赶上任务耗时过长,就会发生卡顿现象。因而,事件循环(Event Loop)应运而生。前端

什么是 Event Loop?

事件循环,能够理解为实现异步的一种方式。event loopHTML Standard中的定义:java

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的event loopnode

JavaScript 有一个主线程 main thread,和调用栈 call-stack 也称之为执行栈。全部的任务都会放到调用栈中等待主线程来执行。待执行的任务就是流水线上的原料,只有前一个加工完,后一个才能进行。event loops就是把原料放上流水线的工人,协调用户交互,脚本,渲染,网络这些不一样的任务。git

将待执行任务分为两类:github

  • 同步任务
  • 异步任务

主线程自上而下执行全部代码web

  • 同步任务直接进入到主线程被执行,而异步任务则进入到 Event Table 并注册相对应的回调函数
  • 知足指定条件(异步任务完成)后,Event Table 会将这个函数移入 Event Queue
  • 主线程任务执行完了之后,会从Event Queue中读取任务,进入到主线程去执行。
  • 不断重复的上述过程就是所谓的Event Loop(事件循环)。

任务队列(task queue)

一个event loop有一个或者多个task队列。当用户代理安排一个任务,必须将该任务增长到相应的event loop的一个tsak队列中。面试

task也被称为macrotask(宏任务),是一个先进先出的队列,由指定的任务源去提供任务。编程

task任务源很是宽泛,总结来讲task任务源包括:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering
  • 总体代码script

因此 Task Queue 就是承载任务的队列。而 JavaScriptEvent Loop 就是会不断地过来找这个 queue,问有没有 task 能够运行运行。

微任务(microtask)

每个event loop都有一个microtask队列,一个microtask会被排进microtask队列而不是task队列。

microtask 队列和task 队列有些类似,都是先进先出的队列,由指定的任务源去提供任务,不一样的是一个 event loop里只有一个microtask 队列。

一般认为是microtask任务源有:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

在Promises/A+规范的Notes 3.1中说起了promise的then方法能够采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。因此不一样浏览器对promise的实现可能存在差别。

浏览器环境下的 Event Loop

事件循环的顺序,决定js代码的执行顺序。进入总体代码(宏任务)后,开始第一次循环。接着执行全部的微任务。而后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行全部的微任务。

执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧之内的屡次dom变更浏览器不会当即响应,而是会积攒变更以最高60HZ的频率更新视图)

setTimeout

以下面代码,setTimeout 就是一个异步任务,

console.log('start')
setTimeout(()=>{
  console.log('setTimeout')
});
console.log('end');
复制代码
  • 主线程执行同步任务:console.log('start');
  • 遇到 setTimeout 发现是一个异步任务,就先注册了一个异步的回调
  • 执行语句console.log('end')
  • 主线程任务执行完毕,看Event Queue是否有待执行的 task,只要主线程的task queue没有任务执行了,主线程就一直在这等着
  • 等异步任务等待的时间到了之后,在执行console.log('setTimeout')

js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。

注意,只有等主线程执行完毕,才会检查Event Queue是否有待执行的 task,所以可能会出现另外一种状况。

console.log('start')

setTimeout(()=>{
  console.log('setTimeout')
}, 3000);

todo(); // 假定这里是一个耗时10秒的操做
复制代码

正常状况下,控制台输出应该是这样的

start
// 等待3秒
setTimeout
复制代码

而实际上,输出大概是这样的:

start
// 等待10秒
setTimeout
复制代码

从新分析一下执行流程:

  • 主线程执行同步任务:console.log('start');
  • 遇到 setTimeout 发现是一个异步任务,就先注册了一个异步的回调
  • 执行语句 todo()
  • 3秒到了,计时事件timeout完成,打印任务进入Event Queue
  • 主线程任务执行完毕,看Event Queue是否有待执行的 task,
  • 执行console.log('setTimeout')

setTimeout这个函数,是通过指定时间后,把要执行的任务加入到Event Queue中,与上一个栗子不一样,当计时事件完成后,主线程任务并无执行完毕。只有等主线程执行完本轮代码后,才会查询Event Queue。因此,等待大约10秒后控制台才有第二次输出。

setTimeout(fn,0)

setTimeout(fn,0)的含义是,指定某个任务在主线程最先可得的空闲时间执行,意思就是只要主线程执行栈内的同步任务所有执行完成,栈为空就立刻执行。

即使主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒。

setInterval

setInterval会每隔指定的时间将注册的函数置入Event Queue,若是前面的任务耗时过久,那么一样须要等待。

setTimeout类似,对于setInterval(fn,ms)来讲,不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦**setInterval的回调函数fn执行时间因为主线程繁忙超过了延迟时间ms,那么就彻底看不出来有时间间隔,而是会连续执行。**

Promise与process.nextTick(callback)

process.nextTick(callback)相似node.js版的"setTimeout",在事件循环的下一次循环中调用 callback 回调函数。

以一段代码为例:

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

new Promise(function(resolve) {
    console.log('promise');
}).then(function() {
    console.log('then');
})

console.log('console');
复制代码

主线程自上而下执行全部代码

  • 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。
  • 接下来遇到了Promisenew Promise当即执行,then函数分发到微任务Event Queue。
  • 遇到console.log(),当即执行。
  • 总体代码script做为第一个宏任务执行结束,看看有哪些微任务?咱们发现了then在微任务Event Queue里面,执行。
  • ok,第一轮事件循环结束了,咱们开始第二轮循环,固然要从宏任务Event Queue开始。咱们发现了宏任务Event Queue中setTimeout对应的回调函数,当即执行。
  • 结束。

宏任务和微任务嵌套

执行完一个宏任务后,会执行全部的微任务,而后再执行一个宏任务

console.log('start');
Promise.resolve()
  .then(function promise1() {  // then1
    console.log('promise1');
  })
  .then(function () {          // then2
    console.log('promise2')
  })
  
setTimeout(function setTimeout1() {  // setTimeout1
  console.log('setTimeout1')
  Promise.resolve().then(function promise2() {  // then3
    console.log('promise3');
  })
}, 0)

setTimeout(function setTimeout2() {  // setTimeout1
  console.log('setTimeout2')
}, 0)
console.log('end')
复制代码

分析下执行流程:

  • 遇到console.log(),当即执行, 输出 start。
  • 遇到Promisethen被分发到微任务Event Queue中。咱们记为then1
  • 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。咱们暂且记为setTimeout1
  • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,咱们记为setTimeout2
  • 遇到console.log(),当即执行, 输出 end。

第一轮执行结束,控制台输出 stert,end,此时,任务队列以下:

宏任务Event Queue 微任务Event Queue
setTimeout1 then1
setTimeout2

执行微任务:

  • 执行then1,输出 promise1,then被分发到微任务,记为 then2
  • 此时为微任务循环, 执行 hten2, 输出 promise2

微任务执行完毕,第二轮循环开始,转入宏任务 setTimeout1:

  • 遇到console.log(),当即执行, 输出 setTimeout1。
  • 遇到Promisethen被分发到微任务Event Queue中。咱们记为then3

执行微任务 then3:

  • 遇到console.log(),当即执行, 输出 promise3。

第三轮循环开始,执行宏任务setTimeout2

  • 遇到console.log(),当即执行, 输出 setTimeout2

最后,控制台输出结果为

stert
end
promise1
promise2
setTimeout1
promise3
setTimeout2
复制代码

Node 环境下的 Event Loop

Node中的Event Loop是基于libuv实现的,而libuv是 Node 的新跨平台抽象层,libuv使用异步,事件驱动的编程方式,核心是提供i/o的事件循环和异步回调。libuvAPI包含有时间,非阻塞的网络,异步文件操做,子进程等等。

Node 的 Event Loop 分为 6 个阶段:

  • timers:执行setTimeout()setInterval()中到期的callback。
  • pending callback: 上一轮循环中有少数的I/O callback会被延迟到这一轮的这一阶段执行
  • idle, prepare:仅内部使用
  • poll: 最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  • check: 执行setImmediate的callback
  • close callbacks: 执行close事件的callback,例如socket.on('close'[,fn])http.server.on('close, fn)

上面六个阶段都不包括 process.nextTick()

timers 阶段

timers 阶段会执行 setTimeoutsetInterval 回调,而且是由 poll 阶段控制的。

在 timers 阶段其实使用一个最小堆而不是队列来保存全部的元素,由于timeout的callback是按照超时时间的顺序来调用的,并非先进先出的队列逻辑)。而为何 timer 阶段在第一个执行阶梯上其实也不难理解。在 Node 中定时器指定的时间也是不许确的,而这样,就能尽量的准确了,让其回调函数尽快执行。

pending callbacks 阶段

pending callbacks 阶段实际上是 I/O 的 callbacks 阶段。好比一些 TCP 的 error 回调等。

poll 阶段

poll 阶段主要有两个功能:

  • 执行 I/O 回调
  • 处理 poll 队列(poll queue)中的事件

当时Event Loop 进入到 poll 阶段而且 timers 阶段没有任何可执行的 task 的时候(也就是没有定时器回调),将会有如下两种状况

  • 若是 poll queue 非空,则 Event Loop就会执行他们,直到为空或者达到system-dependent(系统相关限制)
  • 若是 poll queue 为空,则会发生如下一种状况
    • 若是setImmediate()有回调须要执行,则会当即进入到 check 阶段
    • 检查timer 阶段的任务。若是有的话,则会回到 timer 阶段执行回调。
    • 若是没有setImmediate()须要执行,则 poll 阶段将等待 callback 被添加到队列中再当即执行,这也是为何咱们说 poll 阶段可能会阻塞的缘由。

check 阶段

check 阶段在 poll 阶段以后,setImmediate()的回调会被加入check队列中,他是一个使用libuv API 的特殊的计数器。

一般在代码执行的时候,Event Loop 最终会到达 poll 阶段,而后等待传入的连接或者请求等,可是若是已经指定了setImmediate()而且这时候 poll 阶段已经空闲的时候,则 poll 阶段将会被停止而后开始 check 阶段的执行。

close callbacks 阶段

若是一个 socket 或者事件处理函数忽然关闭/中断(好比:socket.destroy()),则这个阶段就会发生 close 的回调执行。

setImmediate() vs setTimeout()

  • setImmediate在 poll 阶段后执行,即check 阶段
  • setTimeout 在 poll 空闲时且设定时间到达的时候执行,在 timer 阶段

计时器的执行顺序将根据调用它们的上下文而有所不一样。 若是二者都是从主模块中调用的,则时序将受到进程性能的限制。

若是不在I / O周期(即主模块)内,则两个计时器的执行顺序是不肯定的,由于它受进程性能的约束:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
复制代码
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
复制代码

若是在一个I/O 周期内移动这两个调用,则始终首先执行当即回调:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
复制代码
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout
复制代码

setTimeout()相比,使用setImmediate()的主要优势是,若是在I / O周期内安排了任何计时器,则setImmediate()将始终在任何计时器以前执行,而与存在多少计时器无关。

nextTick queue

process.nextTick()从技术上讲不是Event Loop的一部分。 相反,不管当前事件循环的当前阶段如何,若是存在 nextTickQueue,都将在当前操做完成以后处理nextTickQueue,优先于其余 microtask

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
复制代码

Node与浏览器的 Event Loop 差别

浏览器环境下,microtask的任务队列是每一个macrotask执行完以后执行。而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

若是你收获了新知识,请给做者点个赞吧,左侧边栏第一个按钮,用力的点一下~

参考文章:

  1. 这一次,完全弄懂 JavaScript 执行机制
  2. 从event loop规范探究javaScript异步及浏览器更新渲染时机
  3. 完全吃透 JavaScript 执行机制
相关文章
相关标签/搜索