Nodejs深度探秘:Event Loop的本質(zhì)和異步代碼中的Zalgo問題
Nodejs是一個(gè)高效的異步服務(wù)平臺,因此非常適合于開發(fā)高并發(fā)的后臺服務(wù)。要滿足高并發(fā),后臺服務(wù)需要做到的是能夠及時(shí)響應(yīng)客戶端發(fā)送過來的請求。這里要注意的是”響應(yīng)“而不是”完成“,客戶端可能要求后臺從數(shù)據(jù)庫查詢特定數(shù)據(jù),后臺接收請求后會告訴客戶端”你的要求我收到而且正在處理,當(dāng)我處理完成了再通知你”。由此NodeJS能完成高并發(fā)的原因在于,它會將那些耗時(shí)長的處理提交給線程池處理,它的主線程則一直響應(yīng)客戶端的請求,等到線程池把耗時(shí)久的任務(wù)完成,主線程拿到結(jié)果后再發(fā)送給對應(yīng)的客戶。
因此NodeJS的基本模式是,由一個(gè)主線程不斷接收客戶端請求,如果請求需要一定時(shí)間才完成,主線程會將任務(wù)丟給線程池,然后繼續(xù)回頭處理其他客戶的請求。在主線程的循環(huán)中,它會不斷輪詢特定隊(duì)列,看看是否有數(shù)據(jù)可以處理,如果有那么它就從隊(duì)列中取下來,然后將數(shù)據(jù)進(jìn)行處理后發(fā)送給需要的客戶端。由于主線程不用長時(shí)間阻塞,因此它能夠在給定時(shí)間內(nèi)對大量的客戶端請求進(jìn)行響應(yīng),這是它能實(shí)現(xiàn)高并發(fā)的原因。
主線程不斷輪詢特定隊(duì)列是否有數(shù)據(jù)的過程也叫event loop。其基本流程如下:
NodeJS代碼的特點(diǎn)在于,任何我們自己寫的代碼,它在執(zhí)行時(shí)一定在主線程中,而且你不用擔(dān)心因多線程導(dǎo)致的重入等問題。在NodeJS代碼中,一旦有異步調(diào)用產(chǎn)生,執(zhí)行流就會將這個(gè)調(diào)用提交給它的線程池,然后直接指向異步調(diào)用后面的代碼,例如:
console.log(1)
setTimer(()=>{console.log(2), 0)
console.log(3)
上面代碼運(yùn)行時(shí)輸出結(jié)果是1,3,2,這是因?yàn)閟etTimer是異步函數(shù),在主線程里不會得到執(zhí)行,主線程會把這個(gè)時(shí)鐘任務(wù)交給線程池,等到時(shí)鐘結(jié)束后,里面的回調(diào)就會放置在上圖中的時(shí)鐘隊(duì)列,因此主線程會越過setTimer直接指向它后面的語句,等到主線程下次循環(huán)到上圖中的時(shí)鐘隊(duì)列位置時(shí)才會把setTimer設(shè)置的回調(diào)函數(shù)拿出來執(zhí)行。
由此對于NodeJS的event loop來說它包含若干個(gè)階段,每個(gè)階段對應(yīng)上圖的一個(gè)方塊。在每個(gè)階段,主線程會從對應(yīng)隊(duì)列中獲取數(shù)據(jù)返回給客戶端,或者是將存儲在隊(duì)列中的回調(diào)函數(shù)進(jìn)行執(zhí)行,當(dāng)隊(duì)列清空,或者訪問的隊(duì)列元素超過給定值后就會進(jìn)入下一個(gè)階段。
從上圖可以看出,所有時(shí)鐘相關(guān)的回調(diào)都在Timer階段執(zhí)行,例如代碼使用setTimer, setInterval等接口時(shí),NodeJS會把時(shí)鐘請求提交給操作系統(tǒng),一旦時(shí)鐘結(jié)束后,操作系統(tǒng)會通知NodeJS,后者就會把時(shí)鐘對應(yīng)的回調(diào)掛入Timer階段對應(yīng)的隊(duì)列。第二個(gè)階段是操作系統(tǒng)在某項(xiàng)情況下需要通知特定事件給NodeJS,例如TCP連接請求被拒絕,數(shù)據(jù)庫連接失敗等;idle階段屬于nodejs內(nèi)部使用,主線程會執(zhí)行一些nodejs內(nèi)部特定回調(diào)函數(shù)執(zhí)行一些內(nèi)部事務(wù),這部分通常與我們開發(fā)無關(guān);poll階段應(yīng)該是nodejs主線程的主要工作所在,當(dāng)文件打開成功,數(shù)據(jù)從文件中讀入,或者數(shù)據(jù)寫入文件等相應(yīng)IO事件發(fā)生時(shí),對應(yīng)的回調(diào)函數(shù)都會存儲在這個(gè)階段的隊(duì)列,典型的fs.writeFile(p, (err, data)=>{})調(diào)用,它對應(yīng)的回調(diào)函數(shù)就在這個(gè)階段才能執(zhí)行。check階段執(zhí)行由setImmediate提交的回調(diào)函數(shù),setImmediate和setTimeout(callback, 0)其實(shí)性質(zhì)一樣,只不過這兩個(gè)異步函數(shù)對應(yīng)的回調(diào)在不同的階段執(zhí)行,如果我們再代碼中同時(shí)執(zhí)行setImmediate和setTimeout(callback, 0),那么哪個(gè)回調(diào)先執(zhí)行就取決于主線程當(dāng)前處于哪個(gè)階段,我們可以做個(gè)實(shí)驗(yàn),在本地創(chuàng)建一個(gè)文件例如hello.txt,然后創(chuàng)建index.js,在里面添加代碼如下:
setTimeout(function() {
console.log('setTimeout')
}, 0)
setImmediate(function() {
console.log('setImmediate')
})
在多次運(yùn)行index.js情況下,有時(shí)候setTimeout先打印,有時(shí)候setImmediate先打印,這取決于主線程處于哪個(gè)階段,如果它執(zhí)行時(shí)主線程已經(jīng)越過check階段,那么setTimeout將先打印,反之亦然。如果我們在IO回調(diào)中執(zhí)行上面代碼,例如:
fs.readFile('./hello.txt', ()=> {
setTimeout(function() {
console.log('setTimeout in read file')
}, 0)
setImmediate(function() {
console.log('setImmediate in read file')
})
})
那么setImmediate in read file一定會先打印,因?yàn)閞eadFile的回調(diào)在poll階段執(zhí)行,而check階段緊跟著poll,因此讀取文件的回調(diào)執(zhí)行后主線程進(jìn)入check階段,于是setImmediate設(shè)置的回調(diào)一定先執(zhí)行。
上圖中還有一個(gè)process.nextTick,它也是一個(gè)異步函數(shù),但它不屬于event loop的任何階段,當(dāng)當(dāng)前event loop階段走完重新回到timer階段時(shí),主線程會先查看是否有nextTick提供的回調(diào),如果有,那么先執(zhí)行給定回調(diào)然后再進(jìn)入timer階段。它本質(zhì)上跟setImmediate沒有什么區(qū)別,只不過后者屬于event loop的特定階段而前者不屬于event loop,因此它最大的作用是讓代碼在主線程進(jìn)入下一輪循環(huán)前做一些操作,例如釋放掉一些沒用的資源。
由于nodejs的異步模式,有些錯(cuò)誤可能很難處理,這類問題稱之為Zalgo問題,他們的特點(diǎn)是把同步邏輯和異步邏輯組合在一起從而導(dǎo)致難以復(fù)現(xiàn)和難以調(diào)試的Bug,一個(gè)例子如下:
import {readFile} from 'fs'
const cache = new Map()
function problemRead(filename, cb) {
if (cache.has(filename)) {
cb(cache.get(filename))
} else {
readFile(filename, 'utf8', (err, data)= {
cache.set(filename, data)
cb(data)
})
}
}
在上面代碼中,problemRead有兩種模式,一種是如果緩存沒有存在,那么使用readFile進(jìn)行異步讀取,如果緩存已經(jīng)存在,那么cb對應(yīng)的回調(diào)函數(shù)將直接執(zhí)行,因此cb有可能在執(zhí)行時(shí)存在不同上下文環(huán)境,這種情況很容易導(dǎo)致代碼出現(xiàn)問題,例如創(chuàng)建文件zalgo.mjs,實(shí)現(xiàn)代碼如下:
function createFileReader(filename) {
const listeners = []
problemRead(filename, value=>{
listeners.forEach(listener => listener(value))
})
return {
onDataReady: listener => listeners.push(listener)
}
}
const reader1 = createFileReader('./hello.txt')
reader1.onDataReady(data => {
console.log("calling from reader1: ", data)
const reader2 = createFileReader('./hello.txt')
reader2.onDataReady(data => {
//這里的回調(diào)不會被調(diào)用
console.log('calling from reader2: ', data)
})
})
上面代碼執(zhí)行時(shí)只會輸出:
calling from reader1: hello world!
也就是read2對應(yīng)的回調(diào)沒有調(diào)用。它的原因是這樣,第一次調(diào)用createFileReader時(shí),由于數(shù)據(jù)沒有緩存,因此代碼調(diào)用異步接口readFile,前面我們說過任何異步調(diào)用都會提交內(nèi)線程池,它絕不會在主線程中運(yùn)行,因此readFile接下來的代碼會直接運(yùn)行,于是我們就有機(jī)會把reader1對應(yīng)的回調(diào)加入到listeners隊(duì)列,等到回調(diào)完成后,reader1的回調(diào)函數(shù)已經(jīng)存儲在listeners中,于是在回調(diào)中遍歷listeners隊(duì)列,取出其中的回調(diào)函數(shù)執(zhí)行,這樣reader1指定的回調(diào)就能得以執(zhí)行。
在reader2對應(yīng)的createFileReader函數(shù)執(zhí)行后,對應(yīng)的數(shù)據(jù)已經(jīng)存儲在緩存中,于是代碼直接將listener2隊(duì)列中的回調(diào)元素拿出來執(zhí)行,注意這個(gè)時(shí)候reader2.onDataReady對應(yīng)代碼還沒有執(zhí)行,因此reader2對應(yīng)的回調(diào)函數(shù)還沒有來得及放入到listeners隊(duì)列,于是它就得不到執(zhí)行的機(jī)會。這種問題很難調(diào)試,首先它不好重現(xiàn),如果createReader后面繼續(xù)存在被調(diào)用,那么reader2對應(yīng)的回調(diào)就可以被執(zhí)行,同時(shí)上面代碼reader2的回調(diào)沒有執(zhí)行,同時(shí)代碼也不產(chǎn)生任何異?;蝈e(cuò)誤,這使得問題的定位會非常困難,nodejs社區(qū)把這種問題叫做upleasing zalgo,這是一個(gè)特定的典故。這給我們的教訓(xùn)是,在代碼中要不全部使用異步模式,要不就同步模式,決不能兩種交叉混合使用。