Node中的事件循環和異步API

1. 介紹

單線程編程會因阻塞I/O致使硬件資源得不到更優的使用。多線程編程也由於編程中的死鎖、狀態同步等問題讓開發人員頭痛。
Node在二者之間給出了它的解決方案:利用單線程,遠離多線程死鎖、狀態同步等問題;利用異步I/O,讓單線程遠離阻塞,以好使用CPU。html

實際上,node只是在應用層屬於單線程,底層其實經過libuv維護了一個阻塞I/O調用的線程池。前端

可是:在應用層面,JS是單線程的,業務代碼中不能存在耗時過長的代碼,不然可能會嚴重拖後續代碼(包括回調)的處理。若是遇到須要複雜的業務計算時,應當想辦法啓用獨立進程或交給其餘服務進行處理。node

1.1 異步I/O

在Node中,JS是在單線程中執行的沒錯,可是內部完成I/O工做的另有線程池,使用一個主進程和多個I/O線程來模擬異步I/O。
當主線程發起I/O調用時,I/O操做會被放在I/O線程來執行,主線程繼續執行下面的任務,在I/O線程完成操做後會帶着數據通知主線程發起回調。git

1.2 事件循環

事件循環是Node的執行模型,正是這種模型使得回調函數很是廣泛。
在進程啓動時,Node便會建立一個相似while(true)的循環,執行每次循環的過程就是判斷有沒有待處理的事件,若是有,就取出事件及其相關的回調並執行他們,而後進入下一個循環。若是再也不有事件處理,就退出進程。github

clipboard.png

Event loop是一種程序結構,是實現異步的一種機制。Event loop能夠簡單理解爲:編程

  1. 全部任務都在主線程上執行,造成一個執行棧(execution context stack)。
  2. 主線程以外,還存在一個"任務隊列"(task queue)。系統把異步任務放到"任務隊列"之中,而後主線程繼續執行後續的任務。
  3. 一旦"執行棧"中的全部任務執行完畢,系統就會讀取"任務隊列"。若是這個時候,異步任務已經結束了等待狀態,就會從"任務隊列"進入執行棧,恢復執行。
  4. 主線程不斷重複上面的第三步。

Node中事件循環階段解析:segmentfault

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤ connections,  │
│  └──────────┬────────────┘      │  data, etc.   │
│  ┌──────────┴────────────┐      └───────────────┘
│  │         check         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

每一個階段都有一個FIFO的回調隊列(queue)要執行。而每一個階段有本身的特殊之處,簡單說,就是當event loop進入某個階段後,會執行該階段特定的(任意)操做,而後纔會執行這個階段的隊列裏的回調。當隊列被執行完,或者執行的回調數量達到上限後,event loop會進入下個階段。瀏覽器

Phases Overview 階段總覽微信

  • timers: 這個階段執行setTimeout()setInterval()設定的回調。
  • I/O callbacks: 執行幾乎全部的回調,除了close callbackssetTimeout()setInterval()setImmediate()的回調。
  • idle, prepare: 僅內部使用。
  • poll: 獲取新的I/O事件;node會在適當條件下阻塞在這裏。
  • check: 執行setImmediate()設定的回調。
  • close callbacks: 執行好比socket.on('close', ...)的回調。

1. timers

一個timer指定一個下限時間而不是準確時間,定時器setTimeout()setInterval()在達到這個下限時間後執行回調。在指定的時間事後,timers會盡早的執行回調,可是系統調度或者其餘回調的執行可能會延遲它們。
從技術上來講,poll階段控制timers何時執行,而執行的具體位置在timers。
下限的時間有一個範圍:[1, 2147483647],若是設定的時間不在這個範圍,將被設置爲1。多線程

2. I/O callbacks

執行除了close callbackssetTimeout()setInterval()setImmediate()回調以外幾乎全部回調,好比說TCP鏈接發生錯誤。

3. idle, prepare

系統內部的一些調用。

4. poll

這是最複雜的一個階段。poll會檢索新的I/O events,而且會在合適的時候阻塞,等待回調被加入。

poll階段有兩個主要的功能:一是執行下限時間已經達到的timers的回調,一是處理poll隊列裏的事件。
注:Node不少API都是基於事件訂閱完成的,這些API的回調應該都在poll階段完成。

當事件循環進入poll階段:

  • poll隊列不爲空的時候,事件循環確定是先遍歷隊列並同步執行回調,直到隊列清空或執行回調數達到系統上限。
  • poll隊列爲空的時候,這裏有兩種狀況。

    • 若是代碼已經被setImmediate()設定了回調,那麼事件循環直接結束poll階段進入check階段來執行check隊列裏的回調。
    • 若是代碼沒有被設定setImmediate()設定回調:

      • 若是有被設定的timers,那麼此時事件循環會檢查timers,若是有一個或多個timers下限時間已經到達,那麼事件循環將繞回timers階段,並執行timers的有效回調隊列。
      • 若是沒有被設定timers,這個時候事件循環是阻塞在poll階段等待事件回調被加入poll隊列。

Node的不少API都是基於事件訂閱完成的,好比fs.readFile,這些回調應該都在poll階段完成。

5. check

setImmediate()在這個階段執行。

這個階段容許在poll階段結束後當即執行回調。若是poll階段空閒,而且有被setImmediate()設定的回調,那麼事件循環直接跳到check執行而不是阻塞在poll階段等待poll 事件們 (poll events)被加入。

注意:若是進行到了poll階段,setImmediate()具備最高優先級,只要poll隊列爲空且註冊了setImmediate(),不管是否有timers達到下限時間,setImmediate()的代碼都先執行。

6. close callbacks

若是一個socket或handle被忽然關掉(好比socket.destroy()),close事件將在這個階段被觸發,不然將經過process.nextTick()觸發。

1.3 請求對象

對於Node中的異步I/O調用而言,回調函數不禁開發者來調用,從JS發起調用到I/O操做完成,存在一箇中間產物,叫請求對象
在JS發起調用後,JS調用Node的核心模塊,核心模塊調用C++內建模塊,內建模塊經過libuv判斷平臺並進行系統調用。在進行系統調用時,從JS層傳入的方法和參數都被封裝在一個請求對象中,請求對象被放在線程池中等待執行。JS當即返回繼續後續操做。

1.4 執行回調

在線程可用時,線程會取出請求對象來執行I/O操做,執行完後將結果放在請求對象中,並歸還線程。
在事件循環中,I/O觀察者會不斷的找到線程池中已經完成的請求對象,從中取出回調函數和數據並執行。

clipboard.png

跑完當前執行環境下能跑完的代碼。每個事件消息都被運行直到完成爲止,在此以前,任何其餘事件都不會被處理。這和C等一些語言不通,它們可能在一個線程裏面,函數跑着跑着忽然停下來,而後其餘線程又跑起來了。JS這種機制的一個典型的壞處,就是當某個事件處理耗時過長時,後面的事件處理都會被延後,直到這個事件處理結束,在瀏覽器環境中運行時,可能會出現某個腳本運行時間過長,頁面無響應的提示。Node環境則可能出現大量用戶請求被掛起,不能及時響應的狀況。

2. 非I/O的異步API

Node中除了異步I/O以外,還有一些與I/O無關的異步API,分別是:setTimeout()setInterval()process.nextTick()setImmediate(),他們並非像普通I/O操做那樣真的須要等待事件異步處理結束再進行回調,而是出於定時或延遲處理的緣由才設計的。

2.1 setTimeout()setInterval()

這兩個方法實現原理與異步I/O類似,只不過不用I/O線程池的參與。
使用它們建立的定時器會被放入timers隊列的一個紅黑樹中,每次事件循環執行時會從相應隊列中取出並判斷是否超過定時時間,超過就造成一個事件,回調當即執行。
因此,和瀏覽器中同樣,這個並不精確,會被長時間的同步事件阻塞。

clipboard.png

值得一提的是,在Node的setTimeout的源碼中:

// Node源碼
  after *= 1; // coalesce to number or NaN
  if (!(after >= 1 && after <= TIMEOUT_MAX)) {
    if (after > TIMEOUT_MAX) {
      process.emitWarning(...);
    }
    after = 1; // schedule on next tick, follows browser behavior
  }

意思是若是沒有設置這個after,或者小於1,或者大於TIMEOUT_MAX(2^31-1),都會被強制設置爲1ms。也就是說setTimeout(xxx,0)其實等同於setTimeout(xxx,1)。

2.2 setImmediate()

setImmediate()是放在check階段執行的,其實是一個特殊的timer,跑在event loop中一個獨立的階段。它使用libuv的API來設定在 poll 階段結束後當即執行回調。
來看看這個例子:

setTimeout(function() {
  console.log('setTimeout')
}, 0)
setImmediate(function() {
  console.log('setImmediate')
})                                // 輸出不穩定

setTimeout與setImmediate前後入隊以後,首先進入的是timers階段,若是咱們的機器性能通常或者加入了一個同步長耗時操做,那麼進入timers階段,1ms已通過去了,那麼setTimeout的回調會首先執行。
若是沒有到1ms,那麼在timers階段的時候,超時時間沒到,setTimeout回調不執行,事件循環來到了poll階段,這個時候隊列爲空,此時有代碼被setImmediate(),因而先執行了setImmediate()的回調函數,以後在下一個事件循環再執行setTimemout的回調函數。

setTimeout(function() {
  console.log('set timeout')
}, 0)
setImmediate(function() {
  console.log('set Immediate')
})
for (let i = 0; i < 100000; i++) {}           // 能夠保證執行時間超過1ms
// 穩定輸出: setTimeout    setImmediate

這樣就能夠穩定輸出了。

再一個栗子:

const fs = require('fs')
fs.readFile('./filePath.js', (err, data) => {
  setTimeout(() => console.log('setTimeout') , 0)
  setImmediate(() => console.log('setImmediate'))
  console.log('開始了')
  for (let i = 0; i < 100000; i++) {}        
})                                         // 輸出 開始了 setImmediate setTimeout

這裏咱們就會發現,setImmediate永遠先於setTimeout執行。
fs.readFile的回調是在poll階段執行的,當其回調執行完畢以後,setTimeout與setImmediate前後入了timerscheck的隊列,繼續到pollpoll隊列爲空,此時發現有setImmediate,因而事件循環先進入check階段執行回調,以後在下一個事件循環再在timers階段中執行setTimeout回調,雖然這個setTimeout已經到了超時時間。

再來個栗子:
一樣的,這段代碼也是同樣的道理:

setTimeout(() => {
    setImmediate(() => console.log('setImmediate') );
    setTimeout(() => console.log('setTimeout') , 0);
}, 0);

以上的代碼在timers階段執行外部的setTimeout回調後,內層的setTimeout和setImmediate入隊,以後事件循環繼續日後面的階段走,走到poll階段的時候發現隊列爲空,此時有代碼被setImmedate(),因此直接進入check階段執行響應回調(注意這裏沒有去檢測timers隊列中是否有成員到達超時事件,由於setImmediate()優先)。以後在下一個事件循環的timers階段中再去執行相應的回調。

2.3 process.nextTick()Promise

對於這兩個,咱們能夠把它們理解成一個微任務。也就是說,它們其實不屬於事件循環的一部分。

有時咱們想要當即異步執行一個任務,可能會使用延時爲0的定時器,可是這樣開銷很大。咱們能夠換而使用process.nextTick(),它會將傳入的回調放入nextTickQueue隊列中,下一輪Tick以後取出執行,無論事件循環進行到什麼地步,都在當前執行棧的操做結束的時候調用,參見Nodejs官網

process.nextTick方法指定的回調函數,老是在當前執行隊列的尾部觸發,多個process.nextTick語句老是一次執行完(無論它們是否嵌套),遞歸調用process.nextTick,將會沒完沒了,主線程根本不會去讀取事件隊列,致使阻塞後續調用,直至達到最大調用限制。

相比於在定時器中採用紅黑樹樹的操做時間複雜度爲0(lg(n)),而process.nextTick()的時間複雜度爲0(1),相比之下更高效。

來舉一個複雜的栗子,這個栗子搞懂基本上就所有理解了:

setTimeout(() => {
  process.nextTick(() => console.log('nextTick1'))
  
  setTimeout(() => {
    console.log('setTimout1')
    process.nextTick(() => {
      console.log('nextTick2')
      setImmediate(() => console.log('setImmediate1'))
      process.nextTick(() => console.log('nextTick3'))
    })
    setImmediate(() => console.log('setImmediate2'))
    process.nextTick(() => console.log('nextTick4'))
    console.log('sync2')
    setTimeout(() => console.log('setTimout2'), 0)
  }, 0)
  
  console.log('sync1')
}, 0) 
// 輸出: sync1 nextTick1 setTimout1 sync2 nextTick2 nextTick4 nextTick3 setImmediate2 setImmediate1 setTimout2

2.4 結論

  1. process.nextTick(),效率最高,消費資源小,但會阻塞CPU的後續調用;
  2. setTimeout(),精確度不高,可能有延遲執行的狀況發生,且由於動用了紅黑樹,因此消耗資源大;
  3. setImmediate(),消耗的資源小,也不會形成阻塞,但效率也是最低的。

網上的帖子大多深淺不一,甚至有些先後矛盾,在下的文章都是學習過程當中的總結,若是發現錯誤,歡迎留言指出~

參考:
Node——異步I/O
Node探祕之事件循環
Node探祕之事件循環--setTimeout/setImmediate/process.nextTick的差異
細說setTimeout/setImmediate/process.nextTick的區別
深刻淺出Nodejs
Node官方文檔
由setTimeout和setImmediate執行順序的隨機性窺探Node的事件循環機制
Node.js的event loop及timer/setImmediate/nextTick
Node.js 探祕:初識單線程的 Node.js | Taobao FED | 淘寶前端團隊
Node.js 事件循環機制 - 一像素 - 博客園

PS:歡迎你們關注個人公衆號【前端下午茶】,一塊兒加油吧~

另外能夠加入「前端下午茶交流羣」微信羣,長按識別下面二維碼便可加我好友,備註加羣,我拉你入羣~

相關文章
相關標籤/搜索