Event Loop
在實(shí)踐的過(guò)程中酣难,你是否遇到過(guò)以下場(chǎng)景谍夭,為什么 setTimeout
會(huì)比 Promise
后執(zhí)行,明明代碼寫在 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ì)聽到 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)你打開一個(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í)間缅帘,沒有鎖的問(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)行寫入操作弟塞。
執(zhí)行棧
涉及面試題:什么是執(zhí)行棧凭峡?
可以把執(zhí)行棧認(rèn)為是一個(gè)存儲(chǔ)函數(shù)調(diào)用的棧結(jié)構(gòu),遵循先進(jìn)后出的原則决记。
執(zhí)行椣牒保可視化
當(dāng)開始執(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í)在開發(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ù)且沒有得到釋放的話描睦,就會(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ì)就讓出線程開始執(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
徊件。
如果你覺得上面這段解釋還是有點(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è)面
- 然后開始下一輪 Event Loop彰导,執(zhí)行宏任務(wù)中的異步代碼,也就是
setTimeout
中的回調(diào)函數(shù)
所以以上代碼雖然 setTimeout
寫在 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í)如果沒有設(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) - 如果沒有
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è)代碼寫在 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)。