Node 事件循環機制

以前的一篇文章寫了瀏覽器中的 JavaScript 的運行機制,談到瀏覽器和 Node 關於事件循環的實現有着很大不一樣。本文將理清 Node 的事件循環機制,以做對比。 另一個重大變動須要知道的,自從 Node V11 後,Node 中的事件循環已經和瀏覽器中表現一致了!具體見文。前端

目錄

Node 的設計架構

從官網的介紹中得知,Node 自己依賴於好幾個類庫構建而成的,底層都是 C/C++ 編寫的,用於調用系統層級的 API和處理異步、網絡等工做。node

  • V8: JavaScript 引擎,由谷歌公司維護
  • libuv:它是一個 C 編寫的高性能、事件驅動、非阻塞型 I/O 類庫,而且提供了跨平臺的API
  • llhttp:C 編寫的 HTTP 解析類庫
  • c-ares:C 編寫的用於處理某些異步的 DNS 請求
  • OpenSSL:安全相關
  • zlib:壓縮

Node 架構圖一

Node 架構圖二

由這張總體的架構圖能夠看到,Node 最重要的是 V8 和 libuv 這兩部分,V8 不用說,是解析運行 JavaScript 的引擎,沒有 V8 就沒有 Node 的今天,而 libuv 是 Node 另外一個重要的基石,提供事件循環和線程池,負責全部 I/O 任務的分發與執行,對開發者來講不可見,只須要調用封裝好的 Node API 就能夠了。git

下面是另外一張相似的原理圖,可見要想深刻理解 Node,必需要了解 libuv 的設計(深刻理解須要學習 C++、操做系統)。github

Node 架構圖三

阻塞和非阻塞

在 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

libuv 是一個跨平臺的類庫,提供了事件循環(event-loop)機制異步 I/O 機制,下圖是其架構圖:安全

Libuv 架構圖

libuv 做爲底層的基石,將上層傳遞下來的 I/O 請求分配線程池,好比定時器請求、讀取文件請求、網絡請求等,待其完成後,再由事件循環機制分配各個請求的註冊回調事件到任務隊列(或者說消息隊列)中,通知上層的 JS 引擎,空閒時撈起隊列中的回調函數。服務器

事件循環

上圖中描繪了事件循環機制的具體過程:網絡

  1. 事件調度器(Event demultiplexer )接受 I/O 請求(如文件、網絡),將其發給各個具體的線程或者專門的模塊進行處理(底層實現比較複雜,具體見 NodeJS Event Loop Series)
  2. 等 I/O 處理完了,事件調度器將註冊的回調事件放在任務隊列(或者也能夠叫 Event Queue)中
  3. 循環往復

事件循環機制將如上所示不停地保持運行狀態,管理任務隊列,調度各種事件,協調上下層之間的工做。而對於前端開發者來講,只須要考慮 JS 代碼運行的模型,實現異步非阻塞的具體過程將交由 libuv 負責。

事件循環的幾個階段

具體到事件循環(event loop)中,也是分爲好幾個工做階段:

事件循環各個階段

  • 定時器(timers)階段:這個階段執行 setTimeoutsetInterval 的 callback
  • 待定回調(pending I/O callbacks)階段:這個階段執行一些系統操做的回調,好比 TCP 錯誤。名字會讓人誤解爲執行 I/O 回調處理程序, 實際上 I/O 回調會由 poll 階段處理。
  • idle, prepare 階段:僅 node 內部使用
  • 輪詢(poll)階段:獲取新的 I/O 事件, 例如操做讀取文件等等,適當的條件下 node 將阻塞在這裏
  • 檢查(check)階段:執行 setImmediate 設定的 callback
  • 關閉的回調函數(close callbacks)階段:一些關閉的回調函數,如:socket.on('close', ...)

最重要的是 timers、poll、check 階段:

  1. 定時器(timers)階段:

指定一個下限時間,而不是精確時間,在到達下限時間後,timers 會盡量執行回調,但系統調度或者其它回調的執行可能會延遲它們。

從技術上來講,poll 階段會影響 timers 階段的執行,因此 setTimeout(callback, time) 中, time 參數只是一個下限時間,是指到了這個時間後將 callback 置於 timers 階段的隊列中,至於真正的執行須要看事件循環有沒有輪到這一階段。

  1. 輪詢(poll)階段

輪詢(poll)階段是事件循環最重要的部分。它有兩個重要功能:

  • 處理 poll 隊列的事件
  • 當有已超時的 timer,執行它的回調函數

事件循環將同步執行 poll 隊列裏的回調,直到隊列爲空或執行的回調達到系統上限。若是沒有其餘階段的事要處理,事件循環將會一直阻塞在這個階段,等待新的 I/O 事件加入 poll 隊列中。

若是其餘階段出現了事件,則有如下狀況:

  • 若是 check 隊列已經被 setImmediate 設定了回調, 事件循環將結束 poll 階段往下進入 check 階段來執行 check 隊列(裏面的回調 callback)。
  • 若是 timers 隊列有到時的 setTimeout 或者 setInterval 回調,則事件循環將往上繞回 timers 階段,並執行 timer 隊列
  1. 檢查(check)階段

這個階段容許在 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 階段。因此必定是 setImmediatesetTimeout
  • 案例三:跟案例二類似,進入事件循環是在 timers 階段,當此隊列的回調執行完成後,又新增了 setImmediatesetTimeout,而後事件循環離開 timers 階段,往下先進入 check 階段,再回回過頭來進入 timers 階段。輸出結果必然也是肯定的。

微任務和宏任務

在 Node 中,還有另外一個很是重要的概念是微任務和宏任務,微任務是指不在事件循環階段中的任務,Node 中只有 process.nextTick 和 Promise callbacks 兩個微任務,它們都有各自的隊列,不屬於事件循環的一部分。

事件循環和微任務隊列

那麼它們的運行時機是在何時呢?

很簡單,無論在什麼地方調用,它們老是在各個階段之間執行,上一階段完成,進入下一階段前,會清空 nextTick 和 promsie 隊列!並且 nextTick 要比 promsie 要高!

舊版本Node微任務處理時機

下面是具體的一個案例,能夠看到微任務 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
複製代碼

nextTick 的問題

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。

定時器和微任務在NodeV11版本後的變動

隨着 Node V11 的發佈,nextTick 回調和 Promise 微任務將會在各個獨立的 setTimeout and setImmediate 之間運行,即使當前的 timers 隊列或者 check 隊列不爲空。這就跟瀏覽器的事件循環表現保持了一致!

固然,這對於從 Node V11 如下升級到 V11 以上的代碼來講,多是個潛在的風險。

async 代碼運行機制

前面談了微任務中的 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 的機制。從一個坑挖向另外一個坑~

參考

相關文章
相關標籤/搜索