以前的一篇文章寫了瀏覽器中的 JavaScript 的運行機制,談到瀏覽器和 Node 關於事件循環的實現有着很大不一樣。本文將理清 Node 的事件循環機制,以做對比。 另一個重大變動須要知道的,自從 Node V11 後,Node 中的事件循環已經和瀏覽器中表現一致了!具體見文。前端
從官網的介紹中得知,Node 自己依賴於好幾個類庫構建而成的,底層都是 C/C++ 編寫的,用於調用系統層級的 API和處理異步、網絡等工做。node
由這張總體的架構圖能夠看到,Node 最重要的是 V8 和 libuv 這兩部分,V8 不用說,是解析運行 JavaScript 的引擎,沒有 V8 就沒有 Node 的今天,而 libuv 是 Node 另外一個重要的基石,提供事件循環和線程池,負責全部 I/O 任務的分發與執行,對開發者來講不可見,只須要調用封裝好的 Node API 就能夠了。git
下面是另外一張相似的原理圖,可見要想深刻理解 Node,必需要了解 libuv 的設計(深刻理解須要學習 C++、操做系統)。github
在 I/O 操做中,代碼有阻塞和非阻塞之分,也就是同步和異步代碼,Node 一樣提供了兩套 API:數據庫
// 同步阻塞
const fs = require('fs');
const data = fs.readFileSync('/file.md');
// 異步非阻塞
const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
if (err) throw err;
});
複製代碼
因爲在 Node 中 JavaScript 的執行是單線程的,因此一旦發生長時間阻塞,會嚴重影響後面代碼的運行,不像其餘語言能夠新開一個線程處理。若是選擇了非阻塞的方式,就能夠釋放當前的 JS 引擎的工做,用於處理後面的請求,好比一個服務器請求要花 50ms,其中 45ms 花在了數據庫 I/O 上,選擇了非阻塞代碼,能夠將這 45ms 處理其餘請求。這極大了提升了併發性能。promise
另一個要注意的是,千萬不要混用阻塞和非阻塞代碼:瀏覽器
// !此代碼有嚴重問題,會形成 BUG
const fs = require('fs');
fs.readFile('/file.md', (err, data) => {
if (err) throw err;
console.log(data);
});
fs.unlinkSync('/file.md');
// 正確作法!
fs.readFile('/file.md', (readFileErr, data) => {
if (readFileErr) throw readFileErr;
console.log(data);
fs.unlink('/file.md', (unlinkErr) => {
if (unlinkErr) throw unlinkErr;
});
});
複製代碼
libuv 是一個跨平臺的類庫,提供了事件循環(event-loop)機制和異步 I/O 機制,下圖是其架構圖:安全
libuv 做爲底層的基石,將上層傳遞下來的 I/O 請求分配線程池,好比定時器請求、讀取文件請求、網絡請求等,待其完成後,再由事件循環機制分配各個請求的註冊回調事件到任務隊列(或者說消息隊列)中,通知上層的 JS 引擎,空閒時撈起隊列中的回調函數。服務器
上圖中描繪了事件循環機制的具體過程:網絡
事件循環機制將如上所示不停地保持運行狀態,管理任務隊列,調度各種事件,協調上下層之間的工做。而對於前端開發者來講,只須要考慮 JS 代碼運行的模型,實現異步非阻塞的具體過程將交由 libuv 負責。
具體到事件循環(event loop)中,也是分爲好幾個工做階段:
setTimeout
和 setInterval
的 callbacksetImmediate
設定的 callbacksocket.on('close', ...)
最重要的是 timers、poll、check 階段:
指定一個下限時間,而不是精確時間,在到達下限時間後,timers 會盡量執行回調,但系統調度或者其它回調的執行可能會延遲它們。
從技術上來講,poll 階段會影響 timers 階段的執行,因此 setTimeout(callback, time)
中, time
參數只是一個下限時間,是指到了這個時間後將 callback 置於 timers 階段的隊列中,至於真正的執行須要看事件循環有沒有輪到這一階段。
輪詢(poll)階段是事件循環最重要的部分。它有兩個重要功能:
事件循環將同步執行 poll 隊列裏的回調,直到隊列爲空或執行的回調達到系統上限。若是沒有其餘階段的事要處理,事件循環將會一直阻塞在這個階段,等待新的 I/O 事件加入 poll 隊列中。
若是其餘階段出現了事件,則有如下狀況:
setImmediate
設定了回調, 事件循環將結束 poll 階段往下進入 check 階段來執行 check 隊列(裏面的回調 callback)。setTimeout
或者 setInterval
回調,則事件循環將往上繞回 timers 階段,並執行 timer 隊列這個階段容許在 poll 階段結束後當即執行回調。若是 poll 階段空閒,而且有被 setImmediate
設定的回調,事件循環會轉到 check 階段而不是繼續等待。
setImmediate
能夠看做一個特殊的定時器,libuv 專門爲它設置了一個獨立階段。
一般上來說,隨着代碼執行,事件循環終將進入 poll 階段,在這個階段等待 incoming connection, request 等等。可是,只要有被 setImmediate
設定了回調,一旦 poll 階段空閒,那麼程序將結束 poll 階段並進入 check 階段,而不是繼續等待 poll 事件。
這裏能夠用僞代碼說明狀況:
// 事件循環自己至關於一個死循環,當代碼開始執行的時候,事件循環就已經啓動了
// 而後順序調用不一樣階段的方法
while(true){
// timer階段
timer()
// I/O callbacks階段
IO()
// idle階段
IDLE()
// poll階段,大部分時候,事件循環將會停留在此階段,等待 I/O 事件
poll()
// check階段
check()
// close階段
close()
}
複製代碼
另一個要理解的是,事件循環就像這段僞代碼指明的,是一輪又一輪循環往復,因此事件回調函數進入的時機也很重要,如下面的代碼舉例:
// 案例一
setTimeout(() => {
console.log('setTimeout')
})
setImmediate(() => {
console.log('setImmediate')
})
// 輸出結果:
// 不肯定,setTimeout 和 setImmediate 沒法肯定進入時機
// 案例二:
fs.readFile('file.path', (err, file) => {
setTimeout(() => {
console.log('setTimeout')
})
setImmediate(() => {
console.log('setImmediate')
})
})
// 輸出結果:setImmediate setTimeout
// 案例三:
setTimeout(() => {
console.log('setTimeout1')
setImmediate(() => {
console.log('setImmediate2')
})
setTimeout(() => {
console.log('setTimeout2')
})
})
// 輸出結果:setTimeout1 setImmediate2 setTimeout2
複製代碼
這幾個案例中,咱們經過分析得以一窺事件循環的機制:
setTimeout
進入 timers 隊列時機不肯定,可能比 setImmediate
早,也可能晚,這就致使了循環下來,輸出的不肯定fs.readFile
是在 I/O poll 階段,因此事件循環的下一個階段必然是 check 階段,而後再繞回開頭的 timers 階段。因此必定是 setImmediate
比 setTimeout
早setImmediate
和 setTimeout
,而後事件循環離開 timers 階段,往下先進入 check 階段,再回回過頭來進入 timers 階段。輸出結果必然也是肯定的。在 Node 中,還有另外一個很是重要的概念是微任務和宏任務,微任務是指不在事件循環階段中的任務,Node 中只有 process.nextTick 和 Promise callbacks 兩個微任務,它們都有各自的隊列,不屬於事件循環的一部分。
那麼它們的運行時機是在何時呢?
很簡單,無論在什麼地方調用,它們老是在各個階段之間執行,上一階段完成,進入下一階段前,會清空 nextTick 和 promsie 隊列!並且 nextTick 要比 promsie 要高!
下面是具體的一個案例,能夠看到微任務 Promise,是在 timers 階段以後才清空。說明微任務是在階段之間纔會處理,與瀏覽器只要有微任務就清空很不同!
setTimeout(() => {
console.log('timeout1');
Promise.resolve(1).then(() => {
console.log('Promise1')
})
});
setTimeout(() => {
console.log('timeout2');
Promise.resolve(1).then(() => {
console.log('Promise2')
})
});
// 輸出
// timeout1
// timeout2
// Promise1
// Promise2
複製代碼
process.nextTick
有一個很大的問題,它會發生「餓死」 I/O 的潛在風險:
fs.readFile('file.path', (err, file) => {})
const loopTick = () => {
process.nextTick(loopTick)
}
複製代碼
這段代碼將會一直停留在 nextTick 階段,沒法進入到 fs.readFile
的回調中,這就是所謂的 I/O starving。
要解決這個問題,使用 setImmediate
替代,由於 setImmediate
屬於事件循環,就算不停地循環,也不會阻塞整個事件循環機制,由於事件循環會在一輪又一輪處理 check 階段的回調,而不像 nextTick 那麼霸道,必須當即清空!
這特性在一些基礎庫中用得多。好比替代原生 Promise 的庫 Q.js 和 Bluebird.js,相比於原生 Promise 的底層實現,Q 是基於 process.nextTick 來作流程控制,而 Bluebird 使用了 setImmediate。
隨着 Node V11 的發佈,nextTick 回調和 Promise 微任務將會在各個獨立的 setTimeout
and setImmediate
之間運行,即使當前的 timers 隊列或者 check 隊列不爲空。這就跟瀏覽器的事件循環表現保持了一致!
固然,這對於從 Node V11 如下升級到 V11 以上的代碼來講,多是個潛在的風險。
前面談了微任務中的 Promise 隊列,那麼對於一樣是異步的 async 函數代碼應該如何解釋呢?
本段將簡單說明如下 async 代碼機制。
async 函數本質上是 Promise 的語法糖。每次咱們使用 await, 解釋器都建立一個 Promise 對象,而後把剩下的 async 函數中的操做放到 then 回調函數中。async/await 的實現,離不開 Promise。從字面意思來理解,async 是「異步」的簡寫,而 await 是 async wait 的簡寫能夠認爲是等待異步方法執行完成。
setTimeout(function () {
console.log('setTimeout')
})
Promise.resolve().then(() => {
console.log('resolved')
})
async function async2() {
console.log('async2')
}
async function async1() {
console.log('async')
await async2()
console.log('async done')
}
async1()
// => 等同於
setTimeout(function () {
console.log('setTimeout')
})
Promise.resolve().then(() => {
console.log('resolved')
})
function async2() {
console.log('async2')
}
function async1() {
console.log('async')
// wait 會將 async2 變爲一個 Promise
new Promise((resolve, reject) => {
// 執行成功後進入 resolve 階段
async2()
resolve()
})
// await 下面的代碼運行時機是在上面的 Promise 運行以後
.then(() => {
console.log('async done')
})
}
async1()
複製代碼
最後整理一些代碼練習,注意,這裏區分新舊版本!
// 案例一
setImmediate(function(){
console.log("setImmediate");
setImmediate(function(){
console.log("嵌套setImmediate");
});
process.nextTick(function(){
console.log("nextTick");
})
});
// 舊版本 setImmediate nextTick setImmediate2
// 新版本 setImmediate nextTick setImmediate2
// 案例二
setTimeout(() => {
console.log('timeout1');
Promise.resolve(1).then(() => {
console.log('Promise1')
})
});
setTimeout(() => {
console.log('timeout2');
Promise.resolve(1).then(() => {
console.log('Promise2')
})
});
// 舊版本 timeout1 timeout2 Promise1 Promise2
// 新版本 timeout1 Promise1 timeout2 Promise2
// 案例三
setTimeout(() => {
console.log('timeout0');
Promise.resolve('resolved').then(res => console.log(res));
Promise.resolve('time resolved').then(res => console.log(res));
process.nextTick(() => {
console.log('nextTick1');
process.nextTick(() => {
console.log('nextTick2');
});
});
process.nextTick(() => {
console.log('nextTick3');
});
console.log('sync');
setTimeout(() => {
console.log('timeout2');
});
});
setTimeout(() => {
console.log('setTimeout3')
process.nextTick(() => {
console.log('nextTick4');
});
Promise.resolve('resolved3').then(res => console.log(res));
})
// 舊版本:
// timeout0
// sync
// setTimeout3
// nextTick1
// nextTick3
// nextTick4
// nextTick2
// resolved
// time resolved
// resolved3
// timeout2
// 新版本:
// timeout0
// sync
// nextTick1
// nextTick3
// nextTick2
// resolved
// time resolved
// setTimeout3
// nextTick4
// resolved3
// timeout2
// 案例四:
async function async1(){
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2(){
console.log('async2')
}
console.log('script start')
async1();
new Promise(function(resolve){
console.log('promise1')
resolve();
console.log('promise2')
}).then(function(){
console.log('promise3')
})
console.log('script end')
// 輸出
// script start
// async1 start
// async2
// promise1
// promise2
// script end
// async1 end
// promise3
// 案例四:
process.nextTick(() => console.log('nextTick'));
new Promise(function(resolve){
console.log('promise1')
resolve();
console.log('promise2')
}).then(function(){
console.log('promise3')
})
// 舊版本:promise1 promise2 nextTick promise3
// 新版本:promise1 promise2 nextTick promise3
複製代碼
注意這裏面的第三個案例有點奇怪,若是你能看出來的話,這點須要深刻了解 async 的機制。
講了這麼多關於 Node 事件循環的機制,跟瀏覽器怎麼個不一樣法,到最後發現,新版本 Node 已經跟瀏覽器特性保持一致了!隨着新版本的逐漸普及,之後只要記住一種運行模型就能夠了,固然,Node 的事件循環階段過程也不能忘,有必要的話甚至能夠了解一些 libuv 的機制。從一個坑挖向另外一個坑~