Node.js 事件循環


本文地址 http://www.cnblogs.com/jasonxuli/p/6074231.html
 
 
 
>>> 文末有簡單總結
 
 
 
什么是事件循環(Event Loop)
 
事件循環能讓 Node.js 執行非阻塞 I/O 操作 -- 盡管JavaScript事實上是單線程的 -- 通過在可能的情況下把操作交給操作系統內核來實現。
 
由於大多數現代系統內核是多線程的,內核可以處理后台執行的多個操作。當其中一個操作完成的時候,內核告訴 Node.js,相應的回調就被添加到輪詢隊列(poll queue)並最終得到執行。本主題隨后會解釋更多相關細節。
 
 
事件循環
 
Node.js 開始的時候會初始化事件循環,處理目標腳本,腳本可能會進行異步API調用、定時任務或者process.nextTick(),然后開始進行事件循環。
 
下面的表格簡要描述了事件循環的操作順序。
 
     ┌───────────────────────┐
┌─> │        timers                        │
│   └──────────┬────────────┘
│   ┌──────────┴────────────┐
│   │     I/O callbacks                    │
│   └──────────┬────────────┘
│   ┌──────────┴────────────┐
│   │     idle, prepare                    │
│   └──────────┬────────────┘      ┌───────────────┐
│   ┌──────────┴────────────┐      │   incoming:             │
│   │         poll                         │<───┤  connections,           │
│   └──────────┬────────────┘      │   data, etc.            │
│   ┌──────────┴────────────┐      └───────────────┘
│   │        check                         │
│   └──────────┬────────────┘
│   ┌──────────┴────────────┐
└──┤    close callbacks                   │
         └──────────────────────———————─┘
注:每個方框代表事件循環中的一個階段。
 
每個階段都有一個需要執行的回調函數的先入先出(FIFO)隊列。同時,每個階段都是特殊的,基本上,當事件循環進行到某個階段時,會執行該階段特有的操作,然后執行該階段隊列中的回調,直到隊列空了或者達到了執行次數限制。這時候,事件循環會進入下一個階段,循環往復。
 
由於這些操作可能產生更多的計划任務操作,並且輪詢階段處理的新事件會被加入到內核的隊列,輪詢事件被處理的時候會有新的輪詢事件加入。於是,長時回調任務會導致輪詢階段的時間超過了定時器的閾值。 詳情見 定時器(timers)和輪詢(poll)部分。
 
注:Windows 和 Unix/Linux 的實現有輕微的矛盾之處,但並不影響剛才的描述。 最重要的部分都有了。實際上有七八個階段,但我們關注的 -- Node.js 實際使用的 -- 就是上面這些。
 
 
階段總覽 (Phases Overview)
 
  • 計時器(timers):本階段執行setTimeout() 和 setInterval() 計划的回調;
  • I/O 回調: 執行幾乎全部發生異常的 close 回調, 由定時器和setImmediate()計划的回調;
  • 空閑,預備(idle,prepare):只內部使用;
  • 輪詢(poll): 獲取新的 I/O 事件;nodejs這時會適當進行阻塞;
  • 檢查(check): 調用 setImmediate() 的回調;
  • close callbacks: 例如 socket.on('close', ... );
 
在事件循環運行之間,Node.js 檢查是否有正在等待的異步I/O 或者定時器,如果沒有就清除並結束。
 
 
階段細節
 
定時器(timers)
 
定時器的用途是讓指定的回調函數在某個閾值后會被執行,具體的執行時間並不一定是那個精確的閾值。定時器的回調會在制定的時間過后盡快得到執行,然而,操作系統的計划或者其他回調的執行可能會延遲該回調的執行。
 
注:從技術上來看,輪詢階段控制了定時器的執行時機。
 
例如,你設定了在100ms后執行某個操作,然后腳本開始執行一個需要95ms的文件讀取操作:
 
var fs = require('fs');

function someAsyncOperation (callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } var timeoutScheduled = Date.now(); setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled"); }, 100); // do someAsyncOperation which takes 95 ms to completesomeAsyncOperation(function () { var startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { ; // do nothing  } });

 

 
當事件循環進入輪詢階段時,隊列是空的(fs.readFile()還沒完成),因此時間會繼續流逝知道最快的定時器需要執行。過了95ms后,fs.readFile() 讀完文件了,它的回調被添加到輪詢隊列,這個回調需要執行10ms。等到這個回調執行完,隊列中沒有回調了,這時事件循環看到了最近到時的定時器,然后回到定時器階段(timers phase)來執行之前的定時器回調。
在這個例子中,從定義定時器到回調執行中間過了105ms。
 
注:為了防止輪詢階段持續時間太長,libuv 會根據操作系統的不同設置一個輪詢的上限。
 
 
I/O callbacks
 
這個階段執行一些諸如TCP錯誤之類的系統操作的回調。例如,如果一個TCP socket 在嘗試連接時收到了 ECONNREFUSED錯誤,某些 *nix 系統會等着報告這個錯誤。這個就會被排到本階段的隊列中。
 
 
輪詢(poll)
 
輪詢階段有兩個主要功能:
1,執行已經到時的定時器腳本,然后
2,處理輪詢隊列中的事件。
 
當事件循環進入到輪詢階段卻沒有發現定時器時:
  • 如果輪詢隊列非空,事件循環會迭代回調隊列並同步執行回調,直到隊列空了或者達到了上限(前文說過的根據操作系統的不同而設定的上限)。
  • 如果輪詢隊列是空的:
    • 如果有setImmediate()定義了回調,那么事件循環會終止輪詢階段並進入檢查階段去執行定時器回調;
    • 如果沒有setImmediate(),事件回調會等待回調被加入隊列並立即執行。
 
一旦輪詢隊列空了,事件循環會查找已經到時的定時器。如果找到了,事件循環就回到定時器階段去執行回調。
 
 
檢查(check)
 
這個階段允許回調函數在輪詢階段完成后立即執行。如果輪詢階段空閑了,並且有回調已經被 setImmediate() 加入隊列,事件循環會進入檢查階段而不是在輪詢階段等待。
 
setImmediate() 是個特殊的定時器,在事件循環中一個單獨的階段運行。它使用libuv的API 來使得回調函數在輪詢階段完成后執行。
 
基本上,隨着代碼的執行,事件循環會最終進入到等待狀態的輪詢階段,可能是等待一個連接、請求等。然而,如果有一個setImmediate() 設置了一個回調並且輪詢階段空閑了,那么事件循環會進入到檢查階段而不是等待輪詢事件。    ---- 這車軲轆話說來說去的
 
 
關閉事件的回調(close callbacks)
 
如果一個 socket 或句柄(handle)被突然關閉(is closed abruptly),例如 socket.destroy(), 'close' 事件會被發出到這個階段。否則這種事件會通過 process.nextTick() 被發出。
 
 
setImmediate() vs setTimeout()
 
這兩個很相似,但調用時機會的不同會導致它們不同的表現。
 
  • setImmediate() 被設計成一旦輪詢階段完成就執行回調函數;
  • setTimeout() 規划了在某個時間值過后執行回調函數;
 
這兩個執行的順序會因為它們被調用時的上下文而有所不同。如果都是在主模塊調用,那么它們會受到進程性能的影響(運行在本機的其他程序會影響它們)。
 
例如,如果我們在非 I/O 循環中運行下面的腳本(即在主模塊中),他倆的順序是不固定的,因為會受到進程性能的影響:
 
// timeout_vs_immediate.jssetTimeout(function timeout () {
  console.log('timeout');
},0); setImmediate(function immediate () { console.log('immediate'); });

 

$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
但是如果把它們放進 I/O 循環中,setImmediate() 的回調總是先執行:
 
// timeout_vs_immediate.jsvar fs = require('fs')

fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout') }, 0) setImmediate(() => { console.log('immediate') }) })

 

$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout
 
setImmediate() 比 setTimeout() 優勢的地方是 setImmediate() 在 I/O 循環中總是先於任何定時器,不管已經定義了多少定時器。
 
 
process.nextTick()
 
理解 process.nextTick()
 
你可能已經注意到了 process.nextTick() 沒有在上面那個表格里出現,雖然它確實是一個異步API。這是因為它技術上不屬於事件循環。然而,nextTickQueue 會在當前操作結束后被處理,不管是在事件循環的哪個階段。
 
回頭看看之前那個表格,你在某個階段的任何時候調用它,它的所有回調函數都會在事件循環繼續進行之前得到處理。有時候這會導致比較糟糕的情況,因為它允許你用遞歸調用的方式去“阻塞” I/O,這會讓事件循環無法進入到輪詢階段。
 
為什么要允許這樣
 
部分是因為 Node.js 的設計哲學:API 應該總是異步的,即使本不需要是異步。
 
blablabla,后面幾段看的我有點尷尬+暈。既尷尬又暈是覺得這幾段說的有點啰嗦,而且舉的例子不合適。例子要么是同步的,不是異步的。要么是例子里的寫法完全可以避免,比如應該先添加 'connect' 事件監聽再進行 .connect() 操作;又或者變量聲明最好放在變量使用之前,可以避免變量的提前聲明和當時賦值的麻煩。
 
難道是我沒理解里面的秘辛?
 
 
process.nextTick() vs setTimeout()
 
這兩個函數有些相似但是名字讓人困惑:
  • process.netxtTick() 在事件循環的當前階段立即生效;
  • setImmediate() 生效是在接下來的迭代或者事件循環的下一次tick;
 
本質上,它們的名字應該互換一下。process.nextTick() 比 setImmediate() 更“立刻”執行,但這是個歷史問題沒法改變。如果改了,npm上大堆的包就要掛了。
 
我們推薦開發者在所有情況下都使用 setImmediate() 因為它更顯而易見(reason about),另外兼容性也更廣,例如瀏覽器端。
 
為什么使用 process.nextTick() 
 
有兩大原因:
 
  1. 允許用戶處理錯誤,清理不需要的資源,或許在事件循環結束前再次嘗試發送請求;
  2. 必須讓回調函數在調用棧已經清除(unwound)后並且事件循環繼續下去之前執行;
 
下面的兩個例子都是類似的,即在 line1 派發事件,卻在 line2 才添加監聽,因此監聽的回調是不可能被執行到的。
於是可以用 process.nextTick() 使得當前調用棧先執行完畢,也即先執行 line2 注冊事件監聽,然后在 nextTick 派發事件。
 
const EventEmitter = require('events');
const util = require('util'); function MyEmitter() { EventEmitter.call(this); // use nextTick to emit the event once a handler is assigned process.nextTick(function () { this.emit('event'); }.bind(this)); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', function() { console.log('an event occurred!'); });

 

 
 
 
翻譯總結:
 
這篇文章寫的不太簡練,也可能為了有更多的受眾吧,我感覺車軲轆話比較多,一個意思要說好幾遍。
 
從編程應用的角度簡單來說:
 
Node.js 中的事件循環大概有七八個階段,每個階段都有自己的隊列(queue),需要等本階段的隊列處理完成后才進入其他階段。階段之間會互相轉換,循環順序並不是完全固定的 ,因為很多階段是由外部的事件觸發的。
 
其中比較重要的是三個:
 
  1. 定時器階段 timers:
    定時器階段執行定時器任務(setTimeOut(), setInterval())。
  2. 輪詢階段 poll:
          輪詢階段由 I/O 事件觸發,例如 'connect','data' 等。這是比較重/重要的階段,因為大部分程序功能就是為了 I/O 數據。
          本階段會處理定時器任務和 poll 隊列中的任務,具體邏輯:
    • 處理到期的定時器任務,然后
    • 處理隊列任務,直到隊列空了或者達到上限
    • 如果隊列任務沒了:
      • 如果有 setImmediate(),終止輪詢階段並進入檢查階段去執行;
      • 如果沒有 setImmediate(),那么就查看有沒有到期的定時器,有的話就回到定時器階段執行回調函數;
  1. 檢查階段 check:
          當輪詢階段空閑並且已經有 setImmediate() 的時候,會進入檢查階段並執行。
 
比較次要但也列在表格中的兩個:
 
  1. I/O 階段:
          本階段處理 I/O 異常錯誤;
  1. 'close'事件回調:
          本階段處理各種 'close' 事件回調;
 
關於 setTimeout(), setImmediate(), process.nextTick():
 
  • setTimeout()           在某個時間值過后盡快執行回調函數;
  • setImmediate()       一旦輪詢階段完成就執行回調函數;
  • process.nextTick()   在當前調用棧結束后就立即處理,這時也必然是“事件循環繼續進行之前” ;
 
優先級順序從高到低: process.nextTick() > setImmediate() > setTimeout()
注:這里只是多數情況下,即輪詢階段(I/O 回調中)。比如之前比較 setImmediate() 和 setTimeout() 的時候就區分了所處階段/上下文。
 
 
另:
 
關於調用棧,事件循環還可以參考這篇文章:
 
這篇文章里對事件任務區分了大任務(macro task) 、小任務(micro task),每個事件循環只處理一個大任務 ,但會處理完所有小任務。
這一點和前面的文章說的不同。

examples of microtasks:

  • process.nextTick
  • promises
  • Object.observe

examples of macrotasks:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM