浏览器和NodeJS中的 Event Loop 事件循环
title: 浏览器和NodeJS中的 Event Loop 事件循环
date: 2018-12-06 23:06:23
tags: [JavaScript, NodeJS]
categories: JavaScript
前言
搞懂 Event Loop 花了我挺长时间的,提示本文较长,阅读需有耐心。
由于JavaScript是单线程的,当有两个任务执行时后一个必须等前一个执行完成后才能执行,所以JavaScript会将任务分为两类,同步任务 和 异步任务(异步任务其实还可以分为 宏任务 与 微任务,这个后面会提)。
异步任务肯定是在同步任务之后的,但是异步任务之间又是怎么样的一个顺序呢,比如多个setTimeout事件又是怎么样一个执行顺序?这就涉及到事件循环:Event Loop。
另外,浏览器和NodeJS的事件循环是不一样的。
基本概念
首先来讲清楚前言中的两个概念。
- 同步任务:指的是当前在执行栈(主线程)中运行的任务。只有当前一个任务执行完,下一个任务才会接着执行,不管前一个任务执行需要多久。
- 异步任务:暂时不进入执行栈,而是先放到任务队列中。当执行栈的任务执行完并清空后,才会取出任务队列中的任务到执行栈中去执行。
JavaScript是单线程的。假如一个操作需花费很长时间,那么此时浏览器就会一直等待这个操作完成,就会造成不好的体验。因此,JS里有同步任务与异步任务,这样就避免了页面堵塞。
那么 JS 引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入执行栈了。这种循环检查的机制,就叫做事件循环(Event Loop)。
浏览器环境下的 Event Loop
我们来看一下这段代码:
function fn1() {
console.log('in fn1...')
fn2()
}
function fn2 () {
console.log('in fn2...')
}
setTimeout(()=>{
console.log('setTimeout1 run...')
}, 0)
setTimeout(()=>{
console.log('setTimeout2 run...')
}, 1000)
fn1()
输出:
in fn1...
in fn2...
setTimeout1 run...
setTimeout2 run...
执行完同步任务后,JS引擎来看我们的任务队列。如果定时器到了,就立刻把其回调函数放到执行栈里去执行。
这结果在预料之中,那再来看一下另一段代码:
function execTime(t) {
let start = Date.now()
while(Date.now() - start < t){}
}
function fn() {
console.log('1')
fn1()
console.log('3')
}
function fn1 () {
console.log('2')
}
setTimeout(()=>{
execTime(3000)
console.log('setTimeout1 run1...')
}, 0)
setTimeout(()=>{
console.log('setTimeout2 run2...')
}, 1000)
setTimeout(()=>{
console.log('setTimeout3 run3...')
}, 2000)
fn()
这次的输出为:
1
2
3
setTimeout1 run1...
setTimeout2 run2...
setTimeout3 run3...
这段代码需要你自己来运行一下,你会发现,后三条结果是3秒后一起出现的,你给定时器设定的时间其实并不准确。
原因:当同步任务执行完后,第一个setTimeout的定时器瞬间就到了,它的回调函数被放到执行栈中执行。你看了代码会知道,这个任务需要至少3秒才能执行完。而在这期间,后两个定时器的时间也到了,那么它们的回调函数也会被放到执行栈中等待执行。但是,因为第一个任务没有执行完,所以后面的任务需要等待。因此3秒后,这三条结果会同时出现。
根据这个原理,可以联想到如果你滚动一个页面时经常卡顿,不流畅,那么就是你在onscroll的回调函数中写了太多代码,这些代码需要执行很长的时间。你每一次滚动就是一次触发,把回调函数放到任务队列中,然后一个个取出来放到执行栈中去执行,但是你放的太快了,每个任务执行的时间又太长了,导致后续的滚动你希望能立刻看到效果,但实际上还没有轮到它执行,所以会感觉卡顿。
同理,像click事件或者AJAX中的onreadystatechange等等,它们的回调函数放到任务队列,也是一样的逻辑。
MacroTask 和 MicroTask
异步任务队列还可以分为 宏任务队列 与 微任务队列。
概念:
- 宏任务(MacroTask):
包括 setTimeout、 setInterval、 setImmediate、 I/O、 UI渲染 - 微任务(MicroTask):
包括 Promise、 process.nextTick、 Object.observe、 MutationObserver
谨记:
- 先执行 宏任务 再执行 微任务。
-
new Promise(fn).then(success)
的 fn 是立即执行的,而 success 会被放入微任务。
机制:
- 首先会执行宏任务,如果宏任务中存在宏任务,则会把该任务放到宏任务队列中。如果该任务里存在微任务,则把微任务放在微任务队列。
- 在这个宏任务执行完后,首先去看微任务队列中是否有任务,然后把微任务推到执行栈中执行。
- 执行完微任务队列,这一次循环就结束了,然后再进行在宏任务队列中进行下一个宏任务,微任务,直至回调队列清空。
再来看一段代码:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
let promise = new Promise((resolve, reject)=>{
console.log(1)
resolve()
})
promise.then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
输出:
script start
1
promise1
promise2
setTimeout
我们来分析一下,
第一次循环:
- 先执行同步代码(也是宏任务),然后setTimeout被放入宏任务队列,promise1、promise2被放入微任务队列。
- 同步代码执行完,取出微任务队列的promise1、promise2放入执行栈并执行(因为先宏再微),至此第一次循环结束。
第二次循环:
- 取出宏任务setTimeout推入执行栈执行,如果它里面有微任务,就放到微任务队列等待被执行(该代码中没有)。
- 宏任务setTimeout执行完,JS引擎去看微任务队列(空),至此循环结束。
NodeJS 中的 Event Loop
NodeJS中的事件循环跟浏览器环境下的不一样。
当NodeJS启动时会做 3 件事:
- 初始化 Event Loop
- 开始执行你写的脚本
- 开始处理 Event Loop
NodeJS 的 Event Loop 有 6 个阶段:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
- timers 阶段:这个阶段执行 setTimeout 和 setInterval 的回调函数。
- I/O callbacks 阶段:不在 timers 阶段、close callbacks 阶段和 check 阶段这三个阶段执行的回调,都由此阶段负责,这几乎包含了所有回调函数。
- idle, prepare 阶段(译注:看起来是两个阶段,不过这不重要):event loop 内部使用的阶段(译注:我们不用关心这个阶段)
- poll 阶段:获取新的 I/O 事件。在某些场景下 Node.js 会阻塞在这个阶段。
- check 阶段:执行 setImmediate() 的回调函数。
- close callbacks 阶段:执行关闭事件的回调函数,如 socket.on('close', fn) 里的 fn。
其中最重要的是这三个阶段:timers、poll和check
timers阶段
计时器实际上是在指定多久以后可以执行某个回调函数,而不是指定某个函数的确切执行时间。当指定的时间达到后,计时器的回调函数会尽早被执行。如果操作系统很忙,或者 Node.js 正在执行一个耗时的函数,那么计时器的回调函数就会被推迟执行。
poll 阶段(轮询阶段)
poll 阶段有两个功能:
- 如果发现计时器的时间到了,就绕回到 timers 阶段执行计时器的回调。
- 然后再,执行 poll 队列里的回调。
当 event loop 进入 poll 阶段,如果发现没有计时器,就会:
- 如果 poll 队列不是空的,event loop 就会依次执行队列里的回调函数,直到队列被清空或者到达 poll 阶段的时间上限。
- 如果 poll 队列是空的,就会:
- 如果有 setImmediate() 任务,event loop 就结束 poll 阶段去往 check 阶段。
- 如果没有 setImmediate() 任务,event loop 就会等待新的回调函数进入 poll 队列,并立即执行它。
一旦 poll 队列为空,event loop 就会检查计时器有没有到期,如果有计时器到期了,event loop 就会回到 timers 阶段执行计时器的回调。
check 阶段
这个阶段允许开发者在 poll 阶段结束后立即执行一些函数。如果 poll 阶段空闲了,同时存在 setImmediate() 任务,event loop 就会进入 check 阶段,执行setImmediate() 回调。
举例分析(重点)
- 开始运行Event Loop后,timers阶段会去看脚本里是否设置了定时器setTimeout,比如一个4ms延迟与一个100ms延迟的定时器,把它放到timers队列中,直接进入到poll阶段。
- 进入到poll阶段,poll阶段会去看定时器时间是否到了。
- 此时如果4ms到了,就进入后面的阶段然后回到timers阶段执行4ms定时器的回调函数。接着又重复了一遍上述过程。
- 此时如果4ms没到,poll阶段就去处理它队列里的任务了。直到4ms到了,就循环到timers阶段执行回调。
但是这里就有问题了,如果poll阶段处理的这个任务花费超过100ms了,虽然定时器到了,但它的回调会等poll处理完任务后立即循环进入timers阶段再执行。
- 从poll阶段进入check阶段时,主要是看是否有setImmediate() 任务,如果有则立即执行,然后再进入close callbacks 阶段,进行循环,进入timers阶段。
setImmediate() vs setTimeout()
setImmediate 和 setTimeout 很相似,但是其回调函数的调用时机却不一样。
setImmediate() 的作用是在当前 poll 阶段结束后调用一个函数。 setTimeout() 的作用是在一段时间后调用一个函数。一般来说 setImmediate 会先于 setTimeout 执行,但是第一次启动的时候不一样,这两者的回调的执行顺序取决于 setTimeout 和 setImmediate 被调用时的环境。
如果 setTimeout 和 setImmediate 都是在主模块(main module)中被调用的,那么回调的执行顺序取决于当前进程的性能,这个性能受其他应用程序进程的影响。
举例来说,如果在主模块中运行下面的脚本,那么两个回调的执行顺序是无法判断的:
setTimeout(()=>{
console.log('setTimeout')
},0)
setImmediate(()=>{
console.log('setImmediate')
})
结果:
setTimeout
setImmediate
setImmediate
setTimeout
为什么会发生这种情况呢?
因为我们启动NodeJS时, NodeJS会做三件事, 初始化event loop,运行脚本,开始event loop。运行脚本与开始event loop这两件事不是同时执行的,它两中间间隔多少并不清楚,这跟环境性能有关。然后要注意的一点,setTimeout的延迟时间最小为4ms,所以这里的0相当于4。
- 可能两者间隔5ms,当进入timers阶段的时候,NodeJS发现,4ms已经过了,立即执行setTimeout定时器回调,然后执行setImmediate。
- 也可能两者间隔3ms,当进入timers阶段的时候,NodeJS发现,4ms还没过,就进入下一阶段,一直到checked,执行setImmediate,然后等到4ms时再执行setTimeout。
process.nextTick()
从技术上来讲 process.nextTick() 并不是 event loop 的一部分。实际上,event loop 再次进入循环前,会去先执行process.nextTick()。
setTimeout(()=>{
console.log('setTimeout')
},0)
setImmediate(()=>{
console.log('setImmediate')
})
proces.nextTick(()=>{
console.log('nextTick')
})
上述代码中nextTick先于其它两个执行,Vue中有Vue.nextTick()方法就是类似的思想。