关于 event loop 事件循环解题和概念总结(持续补充node相关执行逻辑内容)

463 阅读12分钟

event loop 事件循环

说实话,当我第一次面试的时候我根本不知道什么是事件循环,填鸭式学习后也感觉晦涩难懂,但是遇到的问题必须在最后得到解决。

所以,在阅读过多篇优秀文章之后,在本文中给出我自己的理解和一些题目的解答。(由于不是很懂node,后续会持续跟进补充node的执行机制)

需要的知识储备:

javascript是单线程的非阻塞脚本语言:

单线程:为什么要设计单线程的是因为当你在操作某DOM的时候,假如两块代码分别对该DOM执行不同的修改,那么无疑会带来混乱的结果。

非阻塞:非阻塞是当代码需要进行一项异步任务(顾名思义,该任务通常不会像同步任务一样及时返回执行的结果,很可能需要花费一定时间才能返回结果),主线程会挂起这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。

event loop 作为js执行的机制,需要严格按照设计的模式进行运转。

参照Jake Archibald博客中解释:

宏任务:宏任务需要多次事件循环才能执行完,事件队列中的每一个事件都是一个宏任务。但是在宏任务之间,浏览器为了使js内部宏任务与DOM任务有序的执行可能会呈现更新(UI rendering 下文会解释)。

微任务:微任务通常被安排在当前执行脚本之后应该立即发生的事情,例如对一批操作做出反应,或者在不承担全新任务损失的情况下使某些事情异步。只要没有其他js在执行中,并且在每个宏任务结束时,微任务队列就会在接下来进行清空的操作处理一系列的回调,并且,在微任务期间排队的任何其他微任务都会追加到队列的末尾等待执行。

简单说就是,宏任务在下一轮事件循环执行,微任务在本轮事件循环的所有任务结束之后执行。

宏任务微任务种类的划分

参照这篇文章的表 微任务、宏任务与Event-Loop

宏任务:

#浏览器Node
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame

对于UI rendering 根据html规范一轮事件循环执行结束之后,下轮事件循环执行之前浏览器有可能开始进行UI rendering。

本人阅读了大量的博客文章发现 有相当一部分作者都把UI rendering 归类为宏任务,这应该是错误的(举例证明在后文给出,先搁置不谈)

每次task执行完毕后全部弹出,询问微任务队列,microtask执行完之后再清空microtask,此时本轮循环结束,开始执行UI rendering。UI render完毕之后再接着下一轮循环。

但是注意:

1,浏览器以每秒60帧的速率刷新页面,这个帧率最适合人眼交互,大概16.7ms渲染一帧,所以如果要让用户觉得顺畅,Microtask最好能在16.7ms内完成。否则就会出现所谓的掉帧,让用户觉得卡顿。Task则不影响,因为Task之间浏览器有机会会插入UI rendering

2,不是每轮事件循环都会执行update rendering,浏览器有自己的优化策略,可能把几次的视图更新累积到一起重绘。重绘之前会通知requestAnimationFrame执行回调函数,即requestAnimationFrame的执行时机是在一次都或者多次事件循环的UI render阶段参考1参考2

微任务

#浏览器Node
MutationObserver
Promise.then里的内容 await后面的内容

node中的process.nextTick()个人认为也还是不要划分到微任务队列中,事实上查阅文档资料发现nextTick其实应该是一个独立的队列(文档原话是说技术上不是事件循环的一部分)。它的执行优先级高于所有微任务,每当当前循环轮次的所有task执行完毕时会优先执行nexTick。然后才会执行微任务队列。

浏览器eventloop流程粗略图(以chrome为准)面试题: 深入理解事件循环机制

eventloop流程图.png

对于一个事件循环的执行步骤:

调用栈采用的是后进先出的规则,也就是后进入的函数会叠加在栈的顶部,再从顶部函数最先弹出栈逐个弹出。 队列采用的是先进先出的规则,也就是通常情况下先进入队列的任务会先执行。

javascript有一个主线程和任务队列,主线程会从上到下逐行执行js代码,形成一个执行栈。同步任务就会按顺序被放到这执行栈中执行,而异步任务会在其有了结果后,将注册的回调函数放入任务队列中等待(这里的任务队列是分宏任务和为微任务的),当主线程空闲的时候(调用栈被清空)主线程会查看微任务队列是否有事件存在,如果不存在就会去宏任务队列中执行排在首位的事件加入当前执行栈;如果存在,则会一次执行队列中事件对应的回调,直到微任务队列清空,然后再去宏任务队列取出最前的一个事件,把对应的回调加入当前执行栈......如此反复进入循环直到所有任务都执行完毕。

对于setTimeout,不要以为在设置的时间消耗之后就会马上执行,严格说应该是取决于该函数执行前,调用栈内函数的执行时间,就比如setTimeout设定的超时时间是3s但是当前调用栈的同步任务花费的时间是4s,那么setTimeout的内容要等到主线程同步任务执行之后才会开始执行。

promise在.then(...)之前的内容全都是同步任务,包括async在await行(含该行)之前的也都是同步任务。

到这里对于浏览器的事件循环想必概念已经比较清晰了来一道简单题解一下,理论必须作用于实践:

题目1:

setTimeout(() => { //st
    console.log('1')
},0);

new Promise((resolve) => { // p
    console.log('2');
    resolve()
}).then(() => { // p-then
    console.log('3');
});

console.log('4')

控制台打印的顺序是 2 4 3 1

1,从上到下解析js,遇到st放入宏任务队列,遇到p-then放入微任务队列,期间的p 和 log按顺序放入主线程执行

2,主线程执行完毕后,控制台输出 2 4

3,此时将微任务队列最老的任务推入执行栈,p-then执行打印 3

4,微任务队列清空后,浏览器会在下一轮事件循环开启前重新渲染

5,开启新一轮事件循环,宏任务队列取出最老的任务开始执行 st 打印 1

加点点难度-题目2:

setTimeout(() => { // s1
    console.log('setTimeout - 1')
    setTimeout(() => { // s1-1
      console.log('setTimeout - 1 - 1')
    })
    new Promise(resolve => { // p1-resolve
      console.log('setTimeout - 1 - resolve')
      resolve() 
    }).then(() => { // p1-then
      console.log('setTimeout - 1 - then')
      new Promise(resolve => resolve()).then(() => { // p1-then-then
          console.log('setTimeout - 1 - then - then')
      })
    })
  })

  setTimeout(() => { // s2
    console.log('setTimeout - 2')
    setTimeout(() => { // s2-1
      console.log('setTimeout - 2 - 1')
    })
    new Promise(resolve => resolve()).then(() => { // p2-then
      console.log('setTimeout - 2 - then')
      new Promise(resolve => resolve()).then(() => { // p2-then-then
          console.log('setTimeout - 2 - then - then')
      })
    })
  })

 控制台打印的顺序是 
 setTimeout - 1 
 setTimeout - 1 - resolve
 setTimeout - 1 - then
 setTimeout - 1 - then - then
 setTimeout - 2 
 setTimeout - 2 - then 
 setTimeout - 2 - then - then
 setTimeout - 1 - 1 
 setTimeout - 2 - 1

粗略模拟一下:

1,宏任务队列 [s1 s2]

2,拿出宏任务s1 ----- setTimeout - 1

3,宏任务队列 [s2 , s1-1]

4,微任务队列 [p1-then], 清空微任务队列 [] -> 追加微任务队列 [p1-then-then], 再清空微任务队列[]

5,拿出宏任务 s2 ---- setTimeout - 2 ,此时宏任务队列 [s1-1, s2-1]

6,微任务队列 [p2-then], 清空微任务队列 [] -> 追加微任务队列[p2-then-then], 再清空微任务队列 []

7,拿出宏任务 s1-1 ---- setTimeout - 1 - 1

8,拿出宏任务 s2-1 ---- setTimeout - 2 - 1

只要明确事件循环执行的先后顺序,就可以有条不紊地写出解答。

async await-题目3:

   
console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end') 
}

async1()

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

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })

console.log('script end')
 控制台打印的顺序是
 'script start'
 'async2 end' 
 'Promise' 
 'script end' 
 'async1 end' 
 'promise1'
 'promise2'
 'setTimeout'

这里要注意函数声明和具体的函数执行是有区别的,应当以函数实际执行的位置排序。

并且await下一行的console.log('async1 end')才是要加入微任务队列的回调。

接下来再谈node中事件循环的执行

参考一次弄懂Event Loop(彻底解决此类面试问题)node官方文档

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

node事件循环.png

官网事件循环操作顺序的简化概述(每个框将被称为事件循环的“阶段”): node流程.png

API

node中新增了setImmediate()和Process.nextTick()两个浏览器中没有的API,需要注意这二者的特殊行为

阶段概述

每一轮事件循环都会经过六个阶段,在每个阶段之后都会执行microtask

当事件循环进入poll阶段并在timers中没有可以执行定时器时候,将发生以下两种情况之一:

如果poll队列不为空

  • 事件循环将遍历其同步执行它们的callback队列,直到队列为空,或者达到system-dependent(系统相关限制)。

如果poll队列为空,则会发生以下两种情况之一:

  • 如果有setImmediate()回调需要执行 则会立即停止执行poll阶段并进入执行check阶段以执行回调。

  • 如果没有setImmediate()回调需要执行 poll阶段将等待callback被添加到队列中,然后立即执行。

tip:当设定了timer的话且poll队列为空,则会判断是否有timer超时,如果有的话会回到timer阶段执行回调

  • timers: 此阶段执行setTimeout()和setInterval()到期的回调。
  • pending callback: 上一轮循环中少数的callback会在这一阶段执行。
  • idle, prepare: 仅在内部使用(可以忽略)。
  • poll:检索新的 I/O 事件;执行执行I/O回调;节点会在适当的时候阻塞在这里。
  • check:setImmediate回调在这里被调用。
  • close callbacks:执行close事件的回调例如socket.on('close', ...)

在事件循环的每次运行之间,node检查它是否在等待任何的异步I/O或计时器,如果没有,则干净地关闭。

由于版本迭代,文章往往只会研究当前最新版本node行为,历经查阅很多资料的云里雾里之后,我猛然发现! 原来以上的行为是node11以前的行为,简单的说就是:node11是按照任务类别执行的,浏览器是按照一个宏任务->清空微任务队列->一个宏任务执行......这样的方式执行的,在nodev11之后事件循环的行为已经基本和浏览器趋同。

Snipaste_2021-11-14_14-34-40.png

  • 再举个小例子,相信大家可以一目了然:
setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
在浏览器中执行:
 timer1
 promise1
 timer2
 promise2
在node11以前版本中执行:
 timer1
 timer2
 promise1
 promise2
 很明显,node每轮循环有6个阶段,timer阶段要把属于同一类的setTimeout()都执行掉,然后再去清空微任务队列

需要注意的问题:

1,setTimeout()和setImmediate():

1, setImmediate 设计在 poll 阶段完成时执行,即 check 阶段; 2, setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行

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

setImmediate(() => {
  console.log('immediate');
});

对于如上代码,打印先后是随机的,取决于进程的性能:

  • 首先根据源码的逻辑setTimeout(...,0) === setTimeout(...,1),但是循环还要有准备时间。
  • 如果准备时间小于1ms,那么timer阶段的timeout会到下一轮执行,在check阶段就会执行immediate,等到下一轮执行timeout。
  • 如果准备时间大于1ms,那么准备完毕之后,timer阶段的timeout会执行。

当然,如果你把上述代码写入I/O callbacks中那么打印顺序始终会是 immediate -> timeout

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

很简单,因为这说明我们处于poll阶段,poll阶段之后就是check阶段,无论setTimeout有多快,都会优先执行setImmediate,因为check是setImmediate的'主场'。

2,process.nextTick():

这个API其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

new Promise(resolve => {
    console.log('resolve');
    resolve()
}).then(() => {
    console.log('promise-then');
})

process.nextTick(() => {
    console.log('nextTick1');
    process.nextTick(() => {
        console.log('nextTick2');
    })
})
打印结果(node v14.18.1)
resolve
nextTick1
nextTick2
promise-then

尾巴

花了一周时间重新理解和整理资料总结,不光是为了弄懂事件循环,也可以为了后续手写promise,async-await等学习做铺垫,并前后串联。

总之,现阶段的是写完了,后续在学习中有错误和补充会对本文及时修改。

戒骄戒躁,砥砺前行。

参考文档和资料