Event Loop
在實(shí)踐的過(guò)程中,你是否遇到過(guò)以下場(chǎng)景,為什么 setTimeout 會(huì)比 Promise 后執(zhí)行,明明代碼寫(xiě)在 Promise 之前。這其實(shí)涉及到了 Event Loop 相關(guān)的知識(shí),我們來(lái)詳細(xì)地了解 Event Loop 相關(guān)知識(shí),知道 JS 異步運(yùn)行代碼的原理。
進(jìn)程與線程
涉及面試題:進(jìn)程與線程區(qū)別?JS 單線程帶來(lái)的好處?
相信大家經(jīng)常會(huì)聽(tīng)到 JS 是單線程執(zhí)行的,但是你是否疑惑過(guò)什么是線程?
講到線程,那么肯定也得說(shuō)一下進(jìn)程。本質(zhì)上來(lái)說(shuō),兩個(gè)名詞都是 CPU 工作時(shí)間片的一個(gè)描述。
進(jìn)程描述了 CPU 在運(yùn)行指令及加載和保存上下文所需的時(shí)間,放在應(yīng)用上來(lái)說(shuō)就代表了一個(gè)程序。線程是進(jìn)程中的更小單位,描述了執(zhí)行一段指令所需的時(shí)間。
把這些概念拿到瀏覽器中來(lái)說(shuō),當(dāng)你打開(kāi)一個(gè) Tab 頁(yè)時(shí),其實(shí)就是創(chuàng)建了一個(gè)進(jìn)程,一個(gè)進(jìn)程中可以有多個(gè)線程,比如渲染線程、JS 引擎線程、HTTP 請(qǐng)求線程等等。當(dāng)你發(fā)起一個(gè)請(qǐng)求時(shí),其實(shí)就是創(chuàng)建了一個(gè)線程,當(dāng)請(qǐng)求結(jié)束后,該線程可能就會(huì)被銷毀。
上文說(shuō)到了 JS 引擎線程和渲染線程,大家應(yīng)該都知道,在 JS 運(yùn)行的時(shí)候可能會(huì)阻止 UI 渲染,這說(shuō)明了兩個(gè)線程是互斥的。這其中的原因是因?yàn)?JS 可以修改 DOM,如果在 JS 執(zhí)行的時(shí)候 UI 線程還在工作,就可能導(dǎo)致不能安全的渲染 UI。這其實(shí)也是一個(gè)單線程的好處,得益于 JS 是單線程運(yùn)行的,可以達(dá)到節(jié)省內(nèi)存,節(jié)約上下文切換時(shí)間,沒(méi)有鎖的問(wèn)題的好處。當(dāng)然前面兩點(diǎn)在服務(wù)端中更容易體現(xiàn),對(duì)于鎖的問(wèn)題,形象的來(lái)說(shuō)就是當(dāng)我讀取一個(gè)數(shù)字 15 的時(shí)候,同時(shí)有兩個(gè)操作對(duì)數(shù)字進(jìn)行了加減,這時(shí)候結(jié)果就出現(xiàn)了錯(cuò)誤。解決這個(gè)問(wèn)題也不難,只需要在讀取的時(shí)候加鎖,直到讀取完畢之前都不能進(jìn)行寫(xiě)入操作。
執(zhí)行棧
涉及面試題:什么是執(zhí)行棧?
可以把執(zhí)行棧認(rèn)為是一個(gè)存儲(chǔ)函數(shù)調(diào)用的棧結(jié)構(gòu),遵循先進(jìn)后出的原則。

執(zhí)行??梢暬?/p>
當(dāng)開(kāi)始執(zhí)行 JS 代碼時(shí),首先會(huì)執(zhí)行一個(gè) main 函數(shù),然后執(zhí)行我們的代碼。根據(jù)先進(jìn)后出的原則,后執(zhí)行的函數(shù)會(huì)先彈出棧,在圖中我們也可以發(fā)現(xiàn),foo 函數(shù)后執(zhí)行,當(dāng)執(zhí)行完畢后就從棧中彈出了。
平時(shí)在開(kāi)發(fā)中,大家也可以在報(bào)錯(cuò)中找到執(zhí)行棧的痕跡
function foo() {
throw new Error('error')
}
function bar() {
foo()
}
bar()

函數(shù)執(zhí)行順序
大家可以在上圖清晰的看到報(bào)錯(cuò)在 foo 函數(shù),foo 函數(shù)又是在 bar 函數(shù)中調(diào)用的。
當(dāng)我們使用遞歸的時(shí)候,因?yàn)闂?纱娣诺暮瘮?shù)是有限制的,一旦存放了過(guò)多的函數(shù)且沒(méi)有得到釋放的話,就會(huì)出現(xiàn)爆棧的問(wèn)題
function bar() {
bar()
}
bar()

爆棧
瀏覽器中的 Event Loop
涉及面試題:異步代碼執(zhí)行順序?解釋一下什么是 Event Loop ?
上一小節(jié)我們講到了什么是執(zhí)行棧,大家也知道了當(dāng)我們執(zhí)行 JS 代碼的時(shí)候其實(shí)就是往執(zhí)行棧中放入函數(shù),那么遇到異步代碼的時(shí)候該怎么辦?其實(shí)當(dāng)遇到異步的代碼時(shí),會(huì)被掛起并在需要執(zhí)行的時(shí)候加入到 Task(有多種 Task) 隊(duì)列中。一旦執(zhí)行棧為空,Event Loop 就會(huì)從 Task 隊(duì)列中拿出需要執(zhí)行的代碼并放入執(zhí)行棧中執(zhí)行,所以本質(zhì)上來(lái)說(shuō) JS 中的異步還是同步行為。

事件循環(huán)
不同的任務(wù)源會(huì)被分配到不同的 Task 隊(duì)列中,任務(wù)源可以分為 微任務(wù)(microtask) 和 宏任務(wù)(macrotask)。在 ES6 規(guī)范中,microtask 稱為 jobs,macrotask 稱為 task。下面來(lái)看以下代碼的執(zhí)行順序:
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 => promise1 => promise2 => async1 end => setTimeout
注意:新的瀏覽器中不是如上打印的,因?yàn)?await 變快了,具體內(nèi)容可以往下看
首先先來(lái)解釋下上述代碼的 async 和 await 的執(zhí)行順序。當(dāng)我們調(diào)用 async1 函數(shù)時(shí),會(huì)馬上輸出 async2 end,并且函數(shù)返回一個(gè) Promise,接下來(lái)在遇到 await的時(shí)候會(huì)就讓出線程開(kāi)始執(zhí)行 async1 外的代碼,所以我們完全可以把 await 看成是讓出線程的標(biāo)志。
然后當(dāng)同步代碼全部執(zhí)行完畢以后,就會(huì)去執(zhí)行所有的異步代碼,那么又會(huì)回到 await 的位置執(zhí)行返回的 Promise 的 resolve 函數(shù),這又會(huì)把 resolve 丟到微任務(wù)隊(duì)列中,接下來(lái)去執(zhí)行 then 中的回調(diào),當(dāng)兩個(gè) then 中的回調(diào)全部執(zhí)行完畢以后,又會(huì)回到 await 的位置處理返回值,這時(shí)候你可以看成是 Promise.resolve(返回值).then(),然后 await 后的代碼全部被包裹進(jìn)了 then 的回調(diào)中,所以 console.log('async1 end') 會(huì)優(yōu)先執(zhí)行于 setTimeout。
如果你覺(jué)得上面這段解釋還是有點(diǎn)繞,那么我把 async 的這兩個(gè)函數(shù)改造成你一定能理解的代碼
new Promise((resolve, reject) => {
console.log('async2 end')
// Promise.resolve() 將代碼插入微任務(wù)隊(duì)列尾部
// resolve 再次插入微任務(wù)隊(duì)列尾部
resolve(Promise.resolve())
}).then(() => {
console.log('async1 end')
})
也就是說(shuō),如果 await 后面跟著 Promise 的話,async1 end 需要等待三個(gè) tick 才能執(zhí)行到。那么其實(shí)這個(gè)性能相對(duì)來(lái)說(shuō)還是略慢的,所以 V8 團(tuán)隊(duì)借鑒了 Node 8 中的一個(gè) Bug,在引擎底層將三次 tick 減少到了二次 tick。但是這種做法其實(shí)是違法了規(guī)范的,當(dāng)然規(guī)范也是可以更改的,這是 V8 團(tuán)隊(duì)的一個(gè) PR,目前已被同意這種做法。
所以 Event Loop 執(zhí)行順序如下所示:
- 首先執(zhí)行同步代碼,這屬于宏任務(wù)
- 當(dāng)執(zhí)行完所有同步代碼后,執(zhí)行棧為空,查詢是否有異步代碼需要執(zhí)行
- 執(zhí)行所有微任務(wù)
- 當(dāng)執(zhí)行完所有微任務(wù)后,如有必要會(huì)渲染頁(yè)面
- 然后開(kāi)始下一輪 Event Loop,執(zhí)行宏任務(wù)中的異步代碼,也就是
setTimeout中的回調(diào)函數(shù)
所以以上代碼雖然 setTimeout 寫(xiě)在 Promise 之前,但是因?yàn)?Promise 屬于微任務(wù)而 setTimeout 屬于宏任務(wù),所以會(huì)有以上的打印。
微任務(wù)包括 process.nextTick ,promise ,MutationObserver,其中 process.nextTick 為 Node 獨(dú)有。
宏任務(wù)包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。
這里很多人會(huì)有個(gè)誤區(qū),認(rèn)為微任務(wù)快于宏任務(wù),其實(shí)是錯(cuò)誤的。因?yàn)楹耆蝿?wù)中包括了 script ,瀏覽器會(huì)先執(zhí)行一個(gè)宏任務(wù),接下來(lái)有異步代碼的話才會(huì)先執(zhí)行微任務(wù)。
Node 中的 Event Loop
涉及面試題:Node 中的 Event Loop 和瀏覽器中的有什么區(qū)別?process.nexttick 執(zhí)行順序?
Node 中的 Event Loop 和瀏覽器中的是完全不相同的東西。
Node 的 Event Loop 分為 6 個(gè)階段,它們會(huì)按照順序反復(fù)運(yùn)行。每當(dāng)進(jìn)入某一個(gè)階段的時(shí)候,都會(huì)從對(duì)應(yīng)的回調(diào)隊(duì)列中取出函數(shù)去執(zhí)行。當(dāng)隊(duì)列為空或者執(zhí)行的回調(diào)函數(shù)數(shù)量到達(dá)系統(tǒng)設(shè)定的閾值,就會(huì)進(jìn)入下一階段。

timer
timers 階段會(huì)執(zhí)行 setTimeout 和 setInterval 回調(diào),并且是由 poll 階段控制的。
同樣,在 Node 中定時(shí)器指定的時(shí)間也不是準(zhǔn)確時(shí)間,只能是盡快執(zhí)行。
I/O
I/O 階段會(huì)處理一些上一輪循環(huán)中的少數(shù)未執(zhí)行的 I/O 回調(diào)
idle, prepare
idle, prepare 階段內(nèi)部實(shí)現(xiàn),這里就忽略不講了。
poll
poll 是一個(gè)至關(guān)重要的階段,這一階段中,系統(tǒng)會(huì)做兩件事情
- 回到 timer 階段執(zhí)行回調(diào)
- 執(zhí)行 I/O 回調(diào)
并且在進(jìn)入該階段時(shí)如果沒(méi)有設(shè)定了 timer 的話,會(huì)發(fā)生以下兩件事情
- 如果 poll 隊(duì)列不為空,會(huì)遍歷回調(diào)隊(duì)列并同步執(zhí)行,直到隊(duì)列為空或者達(dá)到系統(tǒng)限制
- 如果 poll 隊(duì)列為空時(shí),會(huì)有兩件事發(fā)生
- 如果有
setImmediate回調(diào)需要執(zhí)行,poll 階段會(huì)停止并且進(jìn)入到 check 階段執(zhí)行回調(diào) - 如果沒(méi)有
setImmediate回調(diào)需要執(zhí)行,會(huì)等待回調(diào)被加入到隊(duì)列中并立即執(zhí)行回調(diào),這里同樣會(huì)有個(gè)超時(shí)時(shí)間設(shè)置防止一直等待下去
- 如果有
當(dāng)然設(shè)定了 timer 的話且 poll 隊(duì)列為空,則會(huì)判斷是否有 timer 超時(shí),如果有的話會(huì)回到 timer 階段執(zhí)行回調(diào)。
check
check 階段執(zhí)行 setImmediate
close callbacks
close callbacks 階段執(zhí)行 close 事件
在以上的內(nèi)容中,我們了解了 Node 中的 Event Loop 的執(zhí)行順序,接下來(lái)我們將會(huì)通過(guò)代碼的方式來(lái)深入理解這塊內(nèi)容。
首先在有些情況下,定時(shí)器的執(zhí)行順序其實(shí)是隨機(jī)的
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
對(duì)于以上代碼來(lái)說(shuō),setTimeout 可能執(zhí)行在前,也可能執(zhí)行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的 - 進(jìn)入事件循環(huán)也是需要成本的,如果在準(zhǔn)備時(shí)候花費(fèi)了大于 1ms 的時(shí)間,那么在 timer 階段就會(huì)直接執(zhí)行
setTimeout回調(diào) - 那么如果準(zhǔn)備時(shí)間花費(fèi)小于 1ms,那么就是
setImmediate回調(diào)先執(zhí)行了
當(dāng)然在某些情況下,他們的執(zhí)行順序一定是固定的,比如以下代碼:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
在上述代碼中,setImmediate 永遠(yuǎn)先執(zhí)行。因?yàn)閮蓚€(gè)代碼寫(xiě)在 IO 回調(diào)中,IO 回調(diào)是在 poll 階段執(zhí)行,當(dāng)回調(diào)執(zhí)行完畢后隊(duì)列為空,發(fā)現(xiàn)存在 setImmediate 回調(diào),所以就直接跳轉(zhuǎn)到 check 階段去執(zhí)行回調(diào)了。
上面介紹的都是 macrotask 的執(zhí)行情況,對(duì)于 microtask 來(lái)說(shuō),它會(huì)在以上每個(gè)階段完成前清空 microtask 隊(duì)列,下圖中的 Tick 就代表了 microtask

setTimeout(() => {
console.log('timer21')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
})
對(duì)于以上代碼來(lái)說(shuō),其實(shí)和瀏覽器中的輸出是一樣的,microtask 永遠(yuǎn)執(zhí)行在 macrotask 前面。
最后我們來(lái)講講 Node 中的 process.nextTick,這個(gè)函數(shù)其實(shí)是獨(dú)立于 Event Loop 之外的,它有一個(gè)自己的隊(duì)列,當(dāng)每個(gè)階段完成后,如果存在 nextTick 隊(duì)列,就會(huì)清空隊(duì)列中的所有回調(diào)函數(shù),并且優(yōu)先于其他 microtask 執(zhí)行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
對(duì)于以上代碼,大家可以發(fā)現(xiàn)無(wú)論如何,永遠(yuǎn)都是先把 nextTick 全部打印出來(lái)。