簡單來講 Event loop 經過將請求分發到別的地方,使得 Node.js 可以實現非阻塞 (non-blocking) I/O 操做html
流程是這樣的,你執行 node index.js
或者 npm start
之類的操做啓動服務,全部的同步代碼會被執行,而後會判斷是否有 Active handle,若是沒有就會中止。node
好比你的 index.js
是下面這樣,那進程運行完便會直接中止git
// index.js
console.log('Hello world');
複製代碼
可是,通常來講咱們都會啓動 http 模塊,好比下面的 express 的 hello world 事例github
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
複製代碼
這裏運行了 app.listen
函數就是一個 active handle,有這個的存在,就至關於 Node.js "有理由"繼續運行下去,這樣咱們就進入了 Event loop。express
Event loop 包含一系列階段 (phase),每一個階段都是隻執行屬於本身的的任務 (task) 和微任務 (micro task),這些階段依次爲:npm
先說簡單的 timer 階段,當你使用 setTimeout()
和 setInterval()
的時候,傳入的回調函數就是在這個階段執行。promise
setTimeout(() => {
console.log('Hello world') // 這一行在 timer 階段執行
}, 1000)
複製代碼
check 階段和 timer 相似,當你使用 setImmediate()
函數的時候,傳入的回調函數就是在 check 階段執行。瀏覽器
setImmediate(() => {
console.log('Hello world') // 這一行在 check 階段執行
})
複製代碼
poll 階段基本上涵蓋了剩下的全部的狀況,你寫的大部分回調,若是不是上面兩種(還要除掉 micro task,後面會講),那基本上就是在 poll 階段執行的。性能優化
// io 回調
fs.readFile('index.html', "utf8", (err, data) => {
console.log('Hello world') // 在 poll 階段執行
});
// http 回調
http.request('http://example.com', (res) => {
res.on('data', () => {})
res.on('end', () => {
console.log('Hello world') // 在 poll 階段執行
})
}).end()
複製代碼
這裏解答一個我本身的困惑,由於我實際上是卡在這裏卡了好久。不知道讀者有沒有注意到,那就是爲何咱們一直在講回調 (callback)?難道 Node.js 就全是回調麼?服務器
嗯,還真的基本上都是。
固然這裏的回調是廣義的回調,你們能夠想想,當咱們運行 server.listen()
以後,剩下的代碼是否是都是對各個不一樣的請求的處理。只要是請求的處理函數,就都算是回調了,並且更準確的說,這些回調都會進入 poll 階段。
上面的圖就是 Event loop 的各個階段,注意到,除了咱們上面講的以外,每一個 phase 還有一個 microtask 的階段。這個階段就是咱們下面主要要講的 process.nextTick
和 Promise
的回調函數運行的地方。
咱們能夠想像成每一個階段有三個 queue,
process.nextTick
的 queuePromise
queue首先採用先進先出的方式處理該階段的 task,當全部同步的 task 處理完畢後,先清空 process.nextTick
隊列,而後是 Promise
的隊列。這裏須要注意的是,不一樣於遞歸調用 setTimeout
,若是在某一個階段一直遞歸調用 process.nextTick
,會致使 main thread 一直停留在該階段,表現相似於同步代碼的 while(true)
,須要避免踩坑。
實踐是檢驗真理的惟一標準,下面代碼的運行結果若是和你想的同樣,那就說明你掌握了上面的知識,若是不同,那就再看一遍吧。
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
testEventLoop()
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
function testEventLoop() {
console.log('=============')
// Timer
setTimeout(() => {
console.log('Timer phase')
process.nextTick(() => {
console.log('Timer phase - nextTick')
})
Promise.resolve().then(() => {
console.log('Timer phase - promise')
})
});
// Check
setImmediate(() => {
console.log('Check phase')
process.nextTick(() => {
console.log('Check phase - nextTick')
})
Promise.resolve().then(() => {
console.log('Check phase - promise')
})
})
// Poll
console.log('Poll phase');
process.nextTick(() => {
console.log('Poll phase - nextTick')
})
Promise.resolve().then(() => {
console.log('Poll phase - promise')
})
}
複製代碼
結果
=============
Poll phase
Poll phase - nextTick
Poll phase - promise
Check phase
Check phase - nextTick
Check phase - promise
Timer phase
Timer phase - nextTick
Timer phase - promise
複製代碼
總結下第一部分的內容咱們能夠發現,其實 Event loop 就是咱們所認爲的 Node.js 的單線程,也就是 main-thread,負責 dispatch tasks 和執行 JavaScript 代碼。那當咱們發起 I/O 請求的時候,好比讀取文件,是誰來負責執行的呢?這個問題就涉及到咱們這個部分的主要內容 - Node.js 的異步實現方式。
直接說結論,調用操做系統的接口,都是由 Node.js 調用 libuv 的 API 實現的,其中咱們能夠將這些異步的 Node.js API 分爲兩類:
下面的表列出了哪些 API 分別使用哪一種調用機制,固然這些都是由 libuv 封裝實現的,Node.js 無需清楚操做系統的類型,或者是異步的方式。
舉例來講,咱們使用的 http 模塊就是使用的 kernel async 的方式。這種異步方式由內核直接實現,因此像下面的代碼,多個請求之間不會有明顯的時間間隔。
const https = require('https')
function testHttps() {
const num = 6;
const startTime = Date.now();
console.log('--------------------')
for(let i=1; i <= num; i++) {
https.request('https://nebri.us/static/me.jpg', (res) => {
res.on('data', () => {})
res.on('end', () => {
const endTIme = Date.now();
const diff = endTIme - startTime;
console.log(`https time ${diff}ms`)
})
}).end()
}
}
testHttps()
/** -------------------- https time 4105ms https time 4332ms https time 4337ms https time 4422ms https time 4454ms https time 4499ms */
複製代碼
其中一個使用線程池的例子是 pbkdf2
加密函數。加密是一個很耗費計算 (CPU intensive) 的操做,由 libuv 線程池來模擬異步。線程池默認只有 4 個線程,因此當咱們同時調用 6 個加密操做,後面 2 個會被前面 4 個 block。因此最後的結果會像下面的代碼,能夠看到第五個明顯比前四個要慢。
const crypto = require('crypto')
function testCrypto() {
const num = 6
const startTime = Date.now();
console.log('--------------------')
for(let i=1; i <= num; i++) {
crypto.pbkdf2('secret', 'salt', 10000, 512, 'sha512', () => {
const endTIme = Date.now();
const diff = endTIme - startTime;
console.log(`Crypto time ${diff}ms`)
})
}
}
testCrypto()
/** -------------------- Crypto time 69ms Crypto time 69ms Crypto time 70ms Crypto time 72ms Crypto time 132ms Crypto time 132ms */
複製代碼
還有些特殊的狀況,好比 fs.readFile
,儘管官方文檔說 fs.readFile
也是使用 libuv 線程池的,理論上來講,應該和 pbkdf2
相似,因爲線程池的緣由,第五個文件的讀取應該被前四個阻塞,但實際上能夠看到結果並非這樣。這個我不是很肯定,可是估計是在 Node.js 這裏作了 partition 處理,至於什麼是 partition?後面會講。
const fs = require('fs')
function testFile(){
const num = 6
const startTime = Date.now();
console.log('--------')
for(let i=1; i <= num; i++) {
fs.readFile(`index${i}.html`, "utf8", (err, data) => {
const endTIme = Date.now();
const diff = endTIme - startTime;
console.log(`Read file ${i} ` + diff)
});
}
}
testFile()
/** -------- Read file 5 138 Read file 1 159 Read file 2 191 Read file 6 218 Read file 4 243 Read file 3 270 -------- Read file 2 416 Read file 6 444 Read file 4 474 Read file 1 501 Read file 3 531 Read file 8 560 Read file 9 587 Read file 5 656 Read file 7 689 */
複製代碼
這部分主要講咱們在寫 Node.js 的時候須要注意什麼,其實基本上也就只有一點,和瀏覽器環境相似,那就是不要阻塞你的主線程 (Do not block you main thread)。至於爲何想必你們也都知道,主線程指的是 Event loop,這個被阻塞的話,相似於服務器被 DDOS 攻擊,沒有辦法處理新的請求了。
Node.js API 提供了不少同步的調用方式,一句話,儘可能不要用,由於這些同步調用會阻塞 Event loop。好比 fs.readFileSync()
,儘管是使用 libuv 線程池讀取文件的,可是 Event loop 仍是會主動阻塞等待完成。Event loop 這段阻塞的時間完徹底全是浪費的,因此,不要用。
從下面的圖片咱們能看出什麼?這裏先補充一些背景知識:
可是從上面的圖片咱們能夠發現,在 idle 的時候和在高併發的時候,tick duration 表現很類似。這裏就引出了一個 Event loop 的細節,Event loop 在閒置的時候,究竟在幹嗎。直觀理解可能會認爲,閒置的時候就一直轉圈圈,但從上面的圖咱們能夠發現,實際上不是的。當 poll 階段空閒的時候:
爲何不少人說 Node.js 不適合作 CPU intensive 的 task。這個其實應區別來講,首先,由於咱們的主線程其實就是 Event loop。咱們的 JavaScript 代碼就運行在 Event loop,若是 JavaScript 代碼涉及到太多的計算,的確會致使 Event loop 阻塞。可是實際上 CPU intensive 的部分咱們能夠交給別人來作,這個操做就叫作 offloading。好比 pbkd2
加密,是交給 libuv 的線程池來搞定的,並不會阻塞主線程,也就不會有什麼問題。
上面的 offloading 至關於把任務交給別人作,咱們只要作任務完成後的回調就能夠。還有一種不阻塞主線程的方式叫 partition (能夠看成時間切片) 。好比咱們要計算一個累加,若是遇到大數的狀況,有可能會阻塞主線程。可是能夠用 partition 的方式異步處理,這樣就將時間複雜度從原來的 O(n) 變成 n * O(1),不會阻塞 Event loop。
function normalAdd(n) {
const start = Date.now();
let sum = 0
for (let i=1; i <=n; i++) {
sum += i
}
const end = Date.now();
const diff = end - start;
console.log('normal time ' + diff)
return sum
}
function partitionAdd(n, cb) {
const start = Date.now();
let sum = 0
let i = 1
const count = () => {
if (i <= n) {
sum += i
i += 1
return setImmediate(count)
}
const end = Date.now();
const diff = end - start;
console.log('partition time ' + diff)
cb(sum)
}
setImmediate(count)
}
console.log(normalAdd(1000000));
partitionAdd(1000000, console.log);
/** normal time 4 500000500000 partition time 943 500000500000 */
複製代碼
由上面的圖能夠看出 Event loop duration 沒辦法反應出服務當前的健康狀況,由於空閒狀況和高併發狀況的表現相似,那咱們有什麼方式監控能咱們的 Node.js 服務是否正常處理用戶請求呢?**Event loop latency ** 是一個很好的指標。
咱們知道 setTimeout
的回調函數過時後會在 timer 階段執行,可是若是若是 poll 階段的任務執行時間過長,setTimeout
的回調函數過時後也不必定當即執行,而是會有一段時間的 delay,若是這個 delay 的時間過長,就說明 Event loop 在 poll 階段被阻塞了。
console.log('start', Date.now())
setTimeout(() => {
console.log('end', Date.now())
}, 1000)
// end - start 有可能會 > 1000ms
複製代碼