title: 瀏覽器和NodeJS中的 Event Loop 事件循環(huán)
date: 2018-12-06 23:06:23
tags: [JavaScript, NodeJS]
categories: JavaScript
前言
搞懂 Event Loop 花了我挺長時(shí)間的,提示本文較長,閱讀需有耐心。
由于JavaScript是單線程的,當(dāng)有兩個(gè)任務(wù)執(zhí)行時(shí)后一個(gè)必須等前一個(gè)執(zhí)行完成后才能執(zhí)行,所以JavaScript會(huì)將任務(wù)分為兩類,同步任務(wù) 和 異步任務(wù)(異步任務(wù)其實(shí)還可以分為 宏任務(wù) 與 微任務(wù),這個(gè)后面會(huì)提)。
異步任務(wù)肯定是在同步任務(wù)之后的,但是異步任務(wù)之間又是怎么樣的一個(gè)順序呢,比如多個(gè)setTimeout事件又是怎么樣一個(gè)執(zhí)行順序?這就涉及到事件循環(huán):Event Loop。
另外,瀏覽器和NodeJS的事件循環(huán)是不一樣的。
基本概念
首先來講清楚前言中的兩個(gè)概念。
- 同步任務(wù):指的是當(dāng)前在執(zhí)行棧(主線程)中運(yùn)行的任務(wù)。只有當(dāng)前一個(gè)任務(wù)執(zhí)行完,下一個(gè)任務(wù)才會(huì)接著執(zhí)行,不管前一個(gè)任務(wù)執(zhí)行需要多久。
- 異步任務(wù):暫時(shí)不進(jìn)入執(zhí)行棧,而是先放到任務(wù)隊(duì)列中。當(dāng)執(zhí)行棧的任務(wù)執(zhí)行完并清空后,才會(huì)取出任務(wù)隊(duì)列中的任務(wù)到執(zhí)行棧中去執(zhí)行。
JavaScript是單線程的。假如一個(gè)操作需花費(fèi)很長時(shí)間,那么此時(shí)瀏覽器就會(huì)一直等待這個(gè)操作完成,就會(huì)造成不好的體驗(yàn)。因此,JS里有同步任務(wù)與異步任務(wù),這樣就避免了頁面堵塞。
那么 JS 引擎怎么知道異步任務(wù)有沒有結(jié)果,能不能進(jìn)入主線程呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務(wù)執(zhí)行完了,引擎就會(huì)去檢查那些掛起來的異步任務(wù),是不是可以進(jìn)入執(zhí)行棧了。這種循環(huán)檢查的機(jī)制,就叫做事件循環(huán)(Event Loop)。
瀏覽器環(huán)境下的 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...
執(zhí)行完同步任務(wù)后,JS引擎來看我們的任務(wù)隊(duì)列。如果定時(shí)器到了,就立刻把其回調(diào)函數(shù)放到執(zhí)行棧里去執(zhí)行。
這結(jié)果在預(yù)料之中,那再來看一下另一段代碼:
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...
這段代碼需要你自己來運(yùn)行一下,你會(huì)發(fā)現(xiàn),后三條結(jié)果是3秒后一起出現(xiàn)的,你給定時(shí)器設(shè)定的時(shí)間其實(shí)并不準(zhǔn)確。
原因:當(dāng)同步任務(wù)執(zhí)行完后,第一個(gè)setTimeout的定時(shí)器瞬間就到了,它的回調(diào)函數(shù)被放到執(zhí)行棧中執(zhí)行。你看了代碼會(huì)知道,這個(gè)任務(wù)需要至少3秒才能執(zhí)行完。而在這期間,后兩個(gè)定時(shí)器的時(shí)間也到了,那么它們的回調(diào)函數(shù)也會(huì)被放到執(zhí)行棧中等待執(zhí)行。但是,因?yàn)榈谝粋€(gè)任務(wù)沒有執(zhí)行完,所以后面的任務(wù)需要等待。因此3秒后,這三條結(jié)果會(huì)同時(shí)出現(xiàn)。
根據(jù)這個(gè)原理,可以聯(lián)想到如果你滾動(dòng)一個(gè)頁面時(shí)經(jīng)常卡頓,不流暢,那么就是你在onscroll的回調(diào)函數(shù)中寫了太多代碼,這些代碼需要執(zhí)行很長的時(shí)間。你每一次滾動(dòng)就是一次觸發(fā),把回調(diào)函數(shù)放到任務(wù)隊(duì)列中,然后一個(gè)個(gè)取出來放到執(zhí)行棧中去執(zhí)行,但是你放的太快了,每個(gè)任務(wù)執(zhí)行的時(shí)間又太長了,導(dǎo)致后續(xù)的滾動(dòng)你希望能立刻看到效果,但實(shí)際上還沒有輪到它執(zhí)行,所以會(huì)感覺卡頓。
同理,像click事件或者AJAX中的onreadystatechange等等,它們的回調(diào)函數(shù)放到任務(wù)隊(duì)列,也是一樣的邏輯。
MacroTask 和 MicroTask
異步任務(wù)隊(duì)列還可以分為 宏任務(wù)隊(duì)列 與 微任務(wù)隊(duì)列。
概念:
- 宏任務(wù)(MacroTask):
包括 setTimeout、 setInterval、 setImmediate、 I/O、 UI渲染 - 微任務(wù)(MicroTask):
包括 Promise、 process.nextTick、 Object.observe、 MutationObserver
謹(jǐn)記:
- 先執(zhí)行 宏任務(wù) 再執(zhí)行 微任務(wù)。
-
new Promise(fn).then(success)
的 fn 是立即執(zhí)行的,而 success 會(huì)被放入微任務(wù)。
機(jī)制:
- 首先會(huì)執(zhí)行宏任務(wù),如果宏任務(wù)中存在宏任務(wù),則會(huì)把該任務(wù)放到宏任務(wù)隊(duì)列中。如果該任務(wù)里存在微任務(wù),則把微任務(wù)放在微任務(wù)隊(duì)列。
- 在這個(gè)宏任務(wù)執(zhí)行完后,首先去看微任務(wù)隊(duì)列中是否有任務(wù),然后把微任務(wù)推到執(zhí)行棧中執(zhí)行。
- 執(zhí)行完微任務(wù)隊(duì)列,這一次循環(huán)就結(jié)束了,然后再進(jìn)行在宏任務(wù)隊(duì)列中進(jìn)行下一個(gè)宏任務(wù),微任務(wù),直至回調(diào)隊(duì)列清空。
再來看一段代碼:
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
我們來分析一下,
第一次循環(huán):
- 先執(zhí)行同步代碼(也是宏任務(wù)),然后setTimeout被放入宏任務(wù)隊(duì)列,promise1、promise2被放入微任務(wù)隊(duì)列。
- 同步代碼執(zhí)行完,取出微任務(wù)隊(duì)列的promise1、promise2放入執(zhí)行棧并執(zhí)行(因?yàn)橄群暝傥ⅲ链说谝淮窝h(huán)結(jié)束。
第二次循環(huán):
- 取出宏任務(wù)setTimeout推入執(zhí)行棧執(zhí)行,如果它里面有微任務(wù),就放到微任務(wù)隊(duì)列等待被執(zhí)行(該代碼中沒有)。
- 宏任務(wù)setTimeout執(zhí)行完,JS引擎去看微任務(wù)隊(duì)列(空),至此循環(huán)結(jié)束。
NodeJS 中的 Event Loop
NodeJS中的事件循環(huán)跟瀏覽器環(huán)境下的不一樣。
當(dāng)NodeJS啟動(dòng)時(shí)會(huì)做 3 件事:
- 初始化 Event Loop
- 開始執(zhí)行你寫的腳本
- 開始處理 Event Loop
NodeJS 的 Event Loop 有 6 個(gè)階段:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
- timers 階段:這個(gè)階段執(zhí)行 setTimeout 和 setInterval 的回調(diào)函數(shù)。
- I/O callbacks 階段:不在 timers 階段、close callbacks 階段和 check 階段這三個(gè)階段執(zhí)行的回調(diào),都由此階段負(fù)責(zé),這幾乎包含了所有回調(diào)函數(shù)。
- idle, prepare 階段(譯注:看起來是兩個(gè)階段,不過這不重要):event loop 內(nèi)部使用的階段(譯注:我們不用關(guān)心這個(gè)階段)
- poll 階段:獲取新的 I/O 事件。在某些場景下 Node.js 會(huì)阻塞在這個(gè)階段。
- check 階段:執(zhí)行 setImmediate() 的回調(diào)函數(shù)。
- close callbacks 階段:執(zhí)行關(guān)閉事件的回調(diào)函數(shù),如 socket.on('close', fn) 里的 fn。
其中最重要的是這三個(gè)階段:timers、poll和check
timers階段
計(jì)時(shí)器實(shí)際上是在指定多久以后可以執(zhí)行某個(gè)回調(diào)函數(shù),而不是指定某個(gè)函數(shù)的確切執(zhí)行時(shí)間。當(dāng)指定的時(shí)間達(dá)到后,計(jì)時(shí)器的回調(diào)函數(shù)會(huì)盡早被執(zhí)行。如果操作系統(tǒng)很忙,或者 Node.js 正在執(zhí)行一個(gè)耗時(shí)的函數(shù),那么計(jì)時(shí)器的回調(diào)函數(shù)就會(huì)被推遲執(zhí)行。
poll 階段(輪詢階段)
poll 階段有兩個(gè)功能:
- 如果發(fā)現(xiàn)計(jì)時(shí)器的時(shí)間到了,就繞回到 timers 階段執(zhí)行計(jì)時(shí)器的回調(diào)。
- 然后再,執(zhí)行 poll 隊(duì)列里的回調(diào)。
當(dāng) event loop 進(jìn)入 poll 階段,如果發(fā)現(xiàn)沒有計(jì)時(shí)器,就會(huì):
- 如果 poll 隊(duì)列不是空的,event loop 就會(huì)依次執(zhí)行隊(duì)列里的回調(diào)函數(shù),直到隊(duì)列被清空或者到達(dá) poll 階段的時(shí)間上限。
- 如果 poll 隊(duì)列是空的,就會(huì):
- 如果有 setImmediate() 任務(wù),event loop 就結(jié)束 poll 階段去往 check 階段。
- 如果沒有 setImmediate() 任務(wù),event loop 就會(huì)等待新的回調(diào)函數(shù)進(jìn)入 poll 隊(duì)列,并立即執(zhí)行它。
一旦 poll 隊(duì)列為空,event loop 就會(huì)檢查計(jì)時(shí)器有沒有到期,如果有計(jì)時(shí)器到期了,event loop 就會(huì)回到 timers 階段執(zhí)行計(jì)時(shí)器的回調(diào)。
check 階段
這個(gè)階段允許開發(fā)者在 poll 階段結(jié)束后立即執(zhí)行一些函數(shù)。如果 poll 階段空閑了,同時(shí)存在 setImmediate() 任務(wù),event loop 就會(huì)進(jìn)入 check 階段,執(zhí)行setImmediate() 回調(diào)。
舉例分析(重點(diǎn))
- 開始運(yùn)行Event Loop后,timers階段會(huì)去看腳本里是否設(shè)置了定時(shí)器setTimeout,比如一個(gè)4ms延遲與一個(gè)100ms延遲的定時(shí)器,把它放到timers隊(duì)列中,直接進(jìn)入到poll階段。
- 進(jìn)入到poll階段,poll階段會(huì)去看定時(shí)器時(shí)間是否到了。
- 此時(shí)如果4ms到了,就進(jìn)入后面的階段然后回到timers階段執(zhí)行4ms定時(shí)器的回調(diào)函數(shù)。接著又重復(fù)了一遍上述過程。
- 此時(shí)如果4ms沒到,poll階段就去處理它隊(duì)列里的任務(wù)了。直到4ms到了,就循環(huán)到timers階段執(zhí)行回調(diào)。
但是這里就有問題了,如果poll階段處理的這個(gè)任務(wù)花費(fèi)超過100ms了,雖然定時(shí)器到了,但它的回調(diào)會(huì)等poll處理完任務(wù)后立即循環(huán)進(jìn)入timers階段再執(zhí)行。
- 從poll階段進(jìn)入check階段時(shí),主要是看是否有setImmediate() 任務(wù),如果有則立即執(zhí)行,然后再進(jìn)入close callbacks 階段,進(jìn)行循環(huán),進(jìn)入timers階段。
setImmediate() vs setTimeout()
setImmediate 和 setTimeout 很相似,但是其回調(diào)函數(shù)的調(diào)用時(shí)機(jī)卻不一樣。
setImmediate() 的作用是在當(dāng)前 poll 階段結(jié)束后調(diào)用一個(gè)函數(shù)。 setTimeout() 的作用是在一段時(shí)間后調(diào)用一個(gè)函數(shù)。一般來說 setImmediate 會(huì)先于 setTimeout 執(zhí)行,但是第一次啟動(dòng)的時(shí)候不一樣,這兩者的回調(diào)的執(zhí)行順序取決于 setTimeout 和 setImmediate 被調(diào)用時(shí)的環(huán)境。
如果 setTimeout 和 setImmediate 都是在主模塊(main module)中被調(diào)用的,那么回調(diào)的執(zhí)行順序取決于當(dāng)前進(jìn)程的性能,這個(gè)性能受其他應(yīng)用程序進(jìn)程的影響。
舉例來說,如果在主模塊中運(yùn)行下面的腳本,那么兩個(gè)回調(diào)的執(zhí)行順序是無法判斷的:
setTimeout(()=>{
console.log('setTimeout')
},0)
setImmediate(()=>{
console.log('setImmediate')
})
結(jié)果:
setTimeout
setImmediate
setImmediate
setTimeout
為什么會(huì)發(fā)生這種情況呢?
因?yàn)槲覀儐?dòng)NodeJS時(shí), NodeJS會(huì)做三件事, 初始化event loop,運(yùn)行腳本,開始event loop。運(yùn)行腳本與開始event loop這兩件事不是同時(shí)執(zhí)行的,它兩中間間隔多少并不清楚,這跟環(huán)境性能有關(guān)。然后要注意的一點(diǎn),setTimeout的延遲時(shí)間最小為4ms,所以這里的0相當(dāng)于4。
- 可能兩者間隔5ms,當(dāng)進(jìn)入timers階段的時(shí)候,NodeJS發(fā)現(xiàn),4ms已經(jīng)過了,立即執(zhí)行setTimeout定時(shí)器回調(diào),然后執(zhí)行setImmediate。
- 也可能兩者間隔3ms,當(dāng)進(jìn)入timers階段的時(shí)候,NodeJS發(fā)現(xiàn),4ms還沒過,就進(jìn)入下一階段,一直到checked,執(zhí)行setImmediate,然后等到4ms時(shí)再執(zhí)行setTimeout。
process.nextTick()
從技術(shù)上來講 process.nextTick() 并不是 event loop 的一部分。實(shí)際上,event loop 再次進(jìn)入循環(huán)前,會(huì)去先執(zhí)行process.nextTick()。
setTimeout(()=>{
console.log('setTimeout')
},0)
setImmediate(()=>{
console.log('setImmediate')
})
proces.nextTick(()=>{
console.log('nextTick')
})
上述代碼中nextTick先于其它兩個(gè)執(zhí)行,Vue中有Vue.nextTick()方法就是類似的思想。