Js是单线程语言,其依靠异步+事件循环的机制达成了非阻塞的效果。异步+非阻塞的特点使其在web应用中流行起来、也使得Node具有处理并发的能力。
一、 谈谈事件循环
1.1 事件循环在Js中的意义 — 异步+事件循环达成非阻塞
-
js是单线程的语言 单线程就会涉及到如果长任务它一直在等待,就会造成浏览器的卡顿
-
依靠异步+事件循环机制让js实现非阻塞 (事件循环从宏任务开始,script其实就是一个宏任务)
-
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) })
- 当执行栈清空后,会去执行事件队列,事件队列分微任务(es6叫jobs)、宏任务(es6叫task)
- 微任务包括 process.nextTick ,promise ,Object.observe ,MutationObserver
- 宏任务包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering
- 每次先清空微任务,然后把宏任务中的第一个加入执行栈,然后回到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中事件循环有几个阶段
看InComing,Node中事件循环执行从poll阶段开始
Node 事件循环会按照特定的顺序(Timers -> I/O -> Check)执行
- timers: 执行定时器的回调如setTimeout() 和 setInterval()。
- pending callbacks: 处理操作系统的回调,如TCP错误类型等。上一轮循环中少数的I/O callback会放在这一阶段执行。
- idle, prepare: 内部使用。
- poll: 等待新的I/O事件,执行与I/O相关的回调(如网络请求、文件系统操作、数据库操作、进程操作等),node在一些特殊情况下会阻塞在这里。
- check: setImmediate()的回调会在这个阶段执行。
- close callbacks: 执行关闭事件的回调,例如例socket.on('close', ...)
注意:
宏任务也有各自的队列。比如timer任务队列、check任务队列。在Node中,走到相应阶段,会拿相应阶段的任务队列的current任务队列出来执行 (比如两个timeout在同期被加入timer队列,并在进入timer阶段前都加入了)。
那么进入timer时,先执行一个timer,再清空微任务队列,再执行一个timer,然后再进入Node循环的下一个阶段pending阶段。
详解poll阶段
v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段。
- 外部输入,进入poll阶段
- 如果进入时Poll队列不为空,Node.js会遍历队列并同步地处理所有事件,然后继续下一个阶段(setImmediate)。
- 如果进入时Poll队列为空,Node.js会查看是否有已经设置的setTimeout需要处理。如果有,Node.js会结束Poll阶段并跳转到Timers阶段处理setTimeout回调。
- 如果进入时Poll为空也没有定时器到期: 如果有 setImmediate() 回调需要执行,Poll 阶段将结束并进入 Check 阶段执行那些回调。
- 如果进入时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
当二者同时在主模块中被调用时,二者执行的顺序不一定(受到进程性能的影响)
当二者在同一个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任务可能会导致页面无响应。
现在,来看一下如何在了解事件循环的基础上进行性能优化:
-
避免长时间运行的任务:由于JavaScript的执行会阻塞页面渲染,因此我们应尽量避免执行长时间的JavaScript任务。可以将长任务分解为多个小任务,然后使用setTimeout或requestAnimationFrame将它们分散到多个事件循环中执行。
-
利用requestAnimationFrame进行动画渲染:requestAnimationFrame方法能保证回调函数在浏览器下一次重绘之前执行,这是进行页面动画最佳的方式。与setTimeout相比,它可以更有效地利用事件循环,减少页面的重绘次数,提高性能。
-
避免阻塞主线程的I/O操作:在浏览器中,应尽量使用异步API(如Fetch或XMLHttpRequest)进行网络请求,避免阻塞主线程。同样,如果使用了Web Workers进行后台处理,应通过postMessage等方法异步地与主线程通信。
参考文档