事件循环到底怎么说才能让面试官满意

158 阅读9分钟

Js是单线程语言,其依靠异步+事件循环的机制达成了非阻塞的效果。异步+非阻塞的特点使其在web应用中流行起来、也使得Node具有处理并发的能力。

一、 谈谈事件循环

1.1 事件循环在Js中的意义 — 异步+事件循环达成非阻塞

  1. js是单线程的语言 单线程就会涉及到如果长任务它一直在等待,就会造成浏览器的卡顿

  2. 依靠异步+事件循环机制让js实现非阻塞 (事件循环从宏任务开始,script其实就是一个宏任务)

    1. js中有执行栈,顺序执行,遇到异步的会挂起到task队列,继续执行执行栈的,异步的返回后会加上另一个队列,这个队列就是事件队列。

    这里要注意: 把回调加入到微/宏队列中也是顺序执行

    比如下面这个例子,2会比1先打印,原因是因为按照代码顺序,()=>{ console.log(2)}会被更早地加入到微任务队列。new Promise().then()是在Promise.resolve().then(()=>{ console.log(2)})之后才被调用,代码按顺序执行,打印1的回调被调用的时机更晚,更晚被加入回调队列。

    new Promise((resolve)=>{
     resolve(1);
     Promise.resolve().then(()=>{ console.log(2)});
     console.log(3)
     }).then((res)=>{
         console.log(res)
     })
    
    1. 当执行栈清空后,会去执行事件队列,事件队列分微任务(es6叫jobs)、宏任务(es6叫task)
      • 微任务包括 process.nextTick ,promise ,Object.observe ,MutationObserver
      • 宏任务包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering
    2. 每次先清空微任务,然后把宏任务中的第一个加入执行栈,然后回到1循环

1.2 理解Node中事件循环与浏览器中事件循环的差异

虽然两者都是基于事件驱动的,但是它们在一些关键方面是不同的。

Node中的Event Loop是基于libuv实现的,这个库为Node.js提供了在多平台上处理并发的能力(更关注网络、文件读取等服务端操作)。而浏览器的事件循环主要关注用户交互,例如UI渲染、用户输入等。

libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。 Event Loop就是在libuv中实现的。

1.2.1 Node.js的libuv引擎的事件循环模型

在Node.js中,事件循环被分为几个不同的阶段,每个阶段都有一个用于存放回调函数的队列,事件循环会按照固定的顺序去执行这些队列中的回调函数,这些阶段包括:

Node中事件循环有几个阶段

image.png 看InComing,Node中事件循环执行从poll阶段开始

Node 事件循环会按照特定的顺序(Timers -> I/O -> Check)执行

  1. timers: 执行定时器的回调如setTimeout() 和 setInterval()。
  2. pending callbacks: 处理操作系统的回调,如TCP错误类型等。上一轮循环中少数的I/O callback会放在这一阶段执行。
  3. idle, prepare: 内部使用。
  4. poll: 等待新的I/O事件,执行与I/O相关的回调(如网络请求、文件系统操作、数据库操作、进程操作等),node在一些特殊情况下会阻塞在这里。
  5. check: setImmediate()的回调会在这个阶段执行。
  6. close callbacks: 执行关闭事件的回调,例如例socket.on('close', ...)

注意:

宏任务也有各自的队列。比如timer任务队列、check任务队列。在Node中,走到相应阶段,会拿相应阶段的任务队列的current任务队列出来执行 (比如两个timeout在同期被加入timer队列,并在进入timer阶段前都加入了)。

那么进入timer时,先执行一个timer,再清空微任务队列,再执行一个timer,然后再进入Node循环的下一个阶段pending阶段。

详解poll阶段

v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段。

  1. 外部输入,进入poll阶段
  2. 如果进入时Poll队列不为空,Node.js会遍历队列并同步地处理所有事件,然后继续下一个阶段(setImmediate)。
  3. 如果进入时Poll队列为空,Node.js会查看是否有已经设置的setTimeout需要处理。如果有,Node.js会结束Poll阶段并跳转到Timers阶段处理setTimeout回调。
  4. 如果进入时Poll为空也没有定时器到期: 如果有 setImmediate() 回调需要执行,Poll 阶段将结束并进入 Check 阶段执行那些回调。
  5. 如果进入时Poll为空也没有定时器、SetImmediate: 那么事件循环在Poll阶段等待,直到有新的I/O回调被加入。

二、 Q & A

1. 定时器准确吗?

setTimeout(callback,0)定时器就算是0也不是马上加入timer队列的,这是因为setTimeout设置为0也需要1ms才能执行(浏览器是4ms),而eventloop从启动到经过timer阶段,可能大于也可能小于1ms,所以timer的执行时机有一定随机性

2. process.nextTick VS setImmediate()

就用户而言,我们有两个类似的调用,但它们的名称令人困惑。 process.nextTick()在同一阶段立即触发 setImmediate()在事件循环的下一次迭代或“滴答”时触发 本质上,名称应该互换。process.nextTick()比 触发得更快setImmediate(),但这是过去的产物,不太可能改变。进行此切换会破坏 npm 上的大部分软件包。每天都有更多的新模块被添加,这意味着我们等待的每一天,都会发生更多潜在的损坏。虽然它们令人困惑,但名称本身不会改变。

3. setTimeout VS setImmediate

Node文档说明

image.png 当二者同时在主模块中被调用时,二者执行的顺序不一定(受到进程性能的影响)

image.png 当二者在同一个I/O中调用时,setImmediate > setTimeout。具体解释:

当Node.js开始执行这段代码时,它首先会注册fs.readFile()的回调,然后代码执行结束,进入到Event Loop中。(按照时间循环的阶段timer=>poll=>Immediate)

  • 在timers阶段,虽然setTimeout的延迟时间是0,但因为没有满足条件的timers(此时fs.readFile()还没完成,所以setTimeout的回调还没被注册),所以会跳过这个阶段。
  • 然后进入I/O callbacks阶段,此时fs.readFile()已经完成,它的回调被执行。在这个回调中,setTimeout和setImmediate的回调被注册。
  • 这时候已经跳过了timers阶段,所以会直接进入immediate阶段,执行setImmediate的回调,打印出'immediate'。
  • 在下一次循环的timers阶段,执行setTimeout的回调,打印出'timeout'。

4. process.nextTick优先级高于promise

process.nextTick() 是一个特殊的 API,用于在当前操作完成后,尽可能快地执行回调。这意味着无论事件循环的阶段如何,只要调用了 process.nextTick(),它的回调就会被添加到 "nextTick 队列",并在当前阶段结束后立即执行。

这样设计的目的是为了让开发者可以尽快地执行某些操作,而无需等待下一次事件循环。这也意味着,如果你过多地使用 process.nextTick(),并且每次的回调中又调用了 process.nextTick(),可能会导致事件循环被阻塞,因为 Node.js 会持续清空 "nextTick 队列",而无法进入下一个阶段。

需要注意的是,虽然 process.nextTick() 在执行时机上类似于微任务(microtask),但在 Node.js 中,它们是不同的队列。微任务(如 Promise 的回调)会在每个阶段结束后执行,但总是在 "nextTick 队列" 之后。这意味着,如果同时有 process.nextTick() 和 Promise 的回调,process.nextTick() 的回调会先执行。 (如果是普通的微任务,是按照先进先出顺序来执行的)

5. 当次事件循环,比如在执行微任务的时候解析到新的微任务,是在当前事件循环执行吗

如果在执行微任务(microtask)的过程中生成了新的微任务,那么新的微任务会被立即添加到微任务队列,并在当前的事件循环周期中执行。

例如,在Node.js和浏览器环境中,Promise的回调函数和process.nextTick函数(仅在Node.js中)会在微任务队列中执行。如果在这些函数的执行过程中又创建了新的Promise或调用了新的process.nextTick,那么这些新的微任务将被立即添加到微任务队列,并在当前事件循环周期中继续执行,直到微任务队列为空。

这种机制确保了微任务可以被尽快执行,但也可能导致一个事件循环周期被延长,因为每个新的微任务都会在当前事件循环周期中执行,而不是等到下一个事件循环周期。因此,在设计异步逻辑时,需要注意避免创建大量的微任务,否则可能会影响事件循环的效率。

6. 事件循环与页面渲染影响 => 事件循环去谈论性能优化

浏览器中的事件循环对页面渲染有直接的影响,因为JavaScript的执行和浏览器的渲染都是在同一个线程上进行的,即主线程。当主线程在执行JavaScript时,渲染过程将被暂停,这就是为什么长时间运行的JavaScript任务可能会导致页面无响应。

现在,来看一下如何在了解事件循环的基础上进行性能优化:

  1. 避免长时间运行的任务:由于JavaScript的执行会阻塞页面渲染,因此我们应尽量避免执行长时间的JavaScript任务。可以将长任务分解为多个小任务,然后使用setTimeout或requestAnimationFrame将它们分散到多个事件循环中执行。

  2. 利用requestAnimationFrame进行动画渲染:requestAnimationFrame方法能保证回调函数在浏览器下一次重绘之前执行,这是进行页面动画最佳的方式。与setTimeout相比,它可以更有效地利用事件循环,减少页面的重绘次数,提高性能。

  3. 避免阻塞主线程的I/O操作:在浏览器中,应尽量使用异步API(如Fetch或XMLHttpRequest)进行网络请求,避免阻塞主线程。同样,如果使用了Web Workers进行后台处理,应通过postMessage等方法异步地与主线程通信。

参考文档