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