事件循環是Node.js 處理非阻塞I/O 操作的機制——儘管JavaScript 是單線程處理的——當有可能的時候,它們會把操作轉移到系統核心中去。
既然目前大多數核心都是多執行緒的,它們可在背景處理多種操作。當其中的一個操作完成的時候,核心通知Node.js 將適合的回呼函數加入到輪詢隊列中等待時機執行。我們在本文後面會進行詳細介紹。
當Node.js 啟動後,它會初始化事件循環,處理已提供的輸入腳本(或丟入REPL,本文不涉及到),它可能會呼叫一些非同步的API、調度計時器,或調用process.nextTick()
,然後開始處理事件迴圈。
下面的圖表展示了事件循環操作順序的簡化概覽。
┌──────────────────────────┐ ┌─>│ timers │ │ └──────────────┬─────────────┘ │ ┌──────────────┴──────────────┐ │ │ pending callbacks │ │ └──────────────┬─────────────┘ │ ┌──────────────┴──────────────┐ │ │ idle, prepare │ │ └──────────────┬─────────────┘ ┌───────────────┐ │ ┌──────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────────┬─────────────┘ │ data, etc. │ │ ┌──────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └──────────────┬─────────────┘ │ ┌──────────────┴──────────────┐ └──┤ close callbacks │ └───────────────────────────┘
注意:每個框被稱為事件循環機制的一個階段。
每個階段都有一個FIFO 佇列來執行回調。雖然每個階段都是特殊的,但通常情況下,當事件循環進入給定的階段時,它將執行特定於該階段的任何操作,然後執行該階段佇列中的回調,直到佇列用盡或最大回調數已執行。當該佇列已用盡或達到回調限制,事件循環將移動到下一階段,等等。
由於這些操作中的任何一個都可能調度_更多的_操作和由核心排列在輪詢階段被處理的新事件, 且在處理輪詢中的事件時,輪詢事件可以排隊。因此,長時間運行的回調可以允許輪詢階段運行長於計時器的閾值時間。有關詳細信息,請參閱計時器和輪詢部分。
注意:在Windows 和Unix/Linux 實作之間存在細微的差異,但這對演示來說並不重要。最重要的部分在這裡。實際上有七或八個步驟,但我們關心的是Node.js 實際上使用以上的某些步驟。
setTimeout()
和setInterval()
的調度回呼函數。setImmediate()
調度的之外),其餘情況node 將在適當的時候在此阻塞。setImmediate()
回呼函數在這裡執行。socket.on('close', ...)
。在每次執行的事件循環之間,Node.js 檢查它是否在等待任何非同步I/O 或計時器,如果沒有的話,則完全關閉。
計時器指定可以執行所提供回調的閾值,而不是使用者希望其執行的確切時間。在指定的一段時間間隔後, 計時器回呼將會盡可能提早運作。但是,作業系統調度或其它正在運行的回調可能會延遲它們。
注意:輪詢階段控制何時定時器執行。
例如,假設您調度了一個在100 毫秒後逾時的定時器,然後您的腳本開始非同步讀取會耗費95 毫秒的檔案:
const fs = require('fs'); function someAsyncOperation(callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } const timeoutScheduled = Date.now(); setTimeout(() => { const delay = Date.now() - timeoutScheduled; console.log(`${delay}ms have passed since I was scheduled`); }, 100); // do someAsyncOperation which takes 95 ms to complete someAsyncOperation(() => { const startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { // do nothing } });
當事件循環進入輪詢階段時,它有一個空隊列(此時fs.readFile()
尚未完成),因此它將等待剩餘的毫秒數,直到達到最快的一個計時器閾值為止。當它等待95 毫秒過後時, fs.readFile()
完成讀取文件,它的那個需要10 毫秒才能完成的回調,將被添加到輪詢隊列中並執行。當回調完成時,佇列中不再有回調,因此事件循環機制將查看最快到達閾值的計時器,然後將回到計時器階段,以執行定時器的回調。在本範例中,您將看到調度計時器到它的回呼被執行之間的總延遲將為105 毫秒。
注意:為了防止輪詢階段餓死事件循環,libuv(實現Node.js 事件循環和平台的所有異步為的C 函數庫),在停止輪詢以獲得更多事件之前,還有一個硬性最大值(依賴系統)。
此階段會對某些系統操作(如TCP 錯誤類型)執行回呼。例如,如果TCP 套接字在嘗試連線時接收到ECONNREFUSED
,則某些*nix 的系統希望等待報告錯誤。這將被排隊以在掛起的回調階段執行。
輪詢階段有兩個重要的功能:
計算應該阻塞和輪詢I/O 的時間。
然後,處理輪詢隊列裡的事件。
當事件循環進入輪詢階段且_沒有被調度的計時器時_,將發生以下兩種情況之一:
如果輪詢隊列不是空的
,事件循環將循環訪問回調隊列並同步執行它們,直到隊列已用盡,或達到了與系統相關的硬性限制。
如果輪詢佇列是空的,還有兩件事發生:
如果腳本被setImmediate()
調度,則事件循環將結束輪詢階段,並繼續檢查階段以執行那些被調度的腳本。
如果腳本未被setImmediate()
調度,則事件循環將等待回呼被加入到佇列中,然後立即執行。
一旦輪詢隊列為空,事件循環將檢查_已達到時間閾值的計時器_。如果一個或多個計時器已準備就緒,則事件循環將繞回計時器階段以執行這些計時器的回調。
此階段允許人員在輪詢階段完成後立即執行回呼。如果輪詢階段變成空閒狀態,並且腳本使用setImmediate()
後被排列在佇列中,則事件循環可能會繼續到檢查階段而不是等待。
setImmediate()
實際上是一個在事件循環的單獨階段運行的特殊計時器。它使用一個libuv API 來安排回調在輪詢階段完成後執行。
通常,在執行程式碼時,事件循環最終會命中輪詢階段,在那裡等待傳入連線、請求等。但是,如果回呼已使用setImmediate()
調度過,並且輪詢階段變為空閒狀態,則它將結束此階段,並繼續到檢查階段而不是繼續等待輪詢事件。
如果套接字或處理函數突然關閉(例如socket.destroy()
),則'close'
事件將在這個階段發出。否則它將透過process.nextTick()
發出。
setImmediate()
和setTimeout()
很類似,但基於被呼叫的時機,他們也有不同表現。
setImmediate()
設計為一旦在目前輪詢階段完成, 就執行腳本。setTimeout()
在最小閾值(ms 單位)過後執行腳本。執行計時器的順序將根據呼叫它們的上下文而異。如果二者都從主模組內調用,則計時器將受進程效能的約束(這可能會受到電腦上其他正在執行應用程式的影響)。
例如,如果執行以下不在I/O 週期(即主模組)內的腳本,則執行兩個計時器的順序是非確定性的,因為它受進程效能的約束:
// timeout_vs_immediate.js setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); $ node timeout_vs_immediate.js timeout immediate $ node timeout_vs_immediate.js immediate timeout
但是,如果你把這兩個函數放入一個I/O 循環內調用,setImmediate 總是被優先調用:
// timeout_vs_immediate.js const 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()
在圖示中沒有顯示,即使它是非同步API 的一部分。這是因為process.nextTick()
技術上不是事件循環的一部分。相反,它將在當前操作完成後處理nextTickQueue
, 而不管事件循環的當前階段如何。這裡的一個_操作_被視為一個從底層C/C++ 處理器開始過渡,並且處理需要執行的JavaScript 程式碼。
回顧我們的圖示,任何時候在給定的階段中調用process.nextTick()
,所有傳遞到process.nextTick()
的回調將在事件循環繼續之前解析。這可能會造成一些糟糕的情況,因為它允許您透過遞歸process.nextTick()
呼叫來「餓死」您的I/O ,阻止事件循環到達輪詢階段。
為什麼這樣的事情會包含在Node.js 中?它的一部分是一個設計理念,其中API 應該始終是非同步的,即使它不必是。以此程式碼段為例:
function apiCall(arg, callback) { if (typeof arg !== 'string') return process.nextTick( callback, new TypeError('argument should be string') ); }
程式碼段進行參數檢查。如果不正確,則會將錯誤傳遞給回呼函數。最近對API 進行了更新,允許傳遞參數給process.nextTick()
,這將允許它接受任何在回調函數位置之後的參數,並將參數傳遞給回調函數作為回調函數的參數,這樣您就不必嵌套函數了。
我們正在做的是將錯誤傳回給用戶,但僅在執行用戶的其餘程式碼之後。透過使用process.nextTick()
,我們保證apiCall()
始終在使用者程式碼的其餘部分_之後_和在讓事件循環繼續進行_之前_,執行其回調函數。為了實現這一點,JS 呼叫棧被允許展開,然後立即執行提供的回調,允許進行遞歸調用process.nextTick()
,而不觸碰RangeError: 超过V8 的最大调用堆栈大小
限制。
這種設計原理可能會導致一些潛在的問題。 以此程式碼段為例:
let bar; // this has an asynchronous signature, but calls callback synchronously function someAsyncApiCall(callback) { callback(); } // the callback is called before `someAsyncApiCall` completes. someAsyncApiCall(() => { // since someAsyncApiCall has completed, bar hasn't been assigned any value console.log('bar', bar); // undefined }); bar = 1;
使用者將someAsyncApiCall()
定義為具有非同步簽名,但實際上它是同步運行的。當調用它時,提供給someAsyncApiCall()
的回調是在事件循環的同一階段內被調用,因為someAsyncApiCall()
實際上並沒有非同步執行任何事情。結果,回調函數在嘗試引用bar
,但作用域中可能還沒有該變量,因為腳本尚未運行完成。
透過將回調置於process.nextTick()
中,腳本仍具有運行完成的能力,允許在呼叫回調之前初始化所有的變數、函數等。它還具有不讓事件循環繼續的優點,適用於讓事件循環繼續之前,警告使用者發生錯誤的情況。下面是上一個使用process.nextTick()
的範例:
let bar; function someAsyncApiCall(callback) { process.nextTick(callback); } someAsyncApiCall(() => { console.log('bar', bar); // 1 }); bar = 1;
這又是另一個真實的例子:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
只有傳遞連接埠時,連接埠才會立即被綁定。因此,可以立即呼叫'listening'
回呼。問題是.on('listening')
的回調在那個時間點尚未被設定。
為了繞過這個問題, 'listening'
事件被排在nextTick()
中,以允許腳本運行完成。這讓用戶設定所想設定的任何事件處理器。
就使用者而言,我們有兩個類似的調用,但它們的名稱令人費解。
process.nextTick()
在同一階段立即執行。setImmediate()
在事件循環的接下來的迭代或'tick' 上觸發。實質上,這兩個名稱應該交換,因為process.nextTick()
比setImmediate()
觸發得更快,但這是過去遺留問題,因此不太可能改變。如果貿然進行名稱交換,將會破壞npm 上的大部分軟體包。每天都有更多新的模組在增加,這意味著我們要多等待每一天,則會有更多潛在破壞。儘管這些名稱使人感到困惑,但它們本身名字不會改變。
我們建議開發人員在所有情況下都使用setImmediate()
,因為它更容易理解。
有兩個主要原因:
允許使用者處理錯誤,清理任何不需要的資源,或在事件循環繼續之前重試請求。
有時有讓回調在棧展開後,但在事件循環繼續之前運行的必要。
以下是一個符合使用者預期的簡單範例:
const server = net.createServer(); server.on('connection', (conn) => {}); server.listen(8080); server.on('listening', () => {});
假設listen()
在事件循環開始時運行,但listening 的回呼被放置在setImmediate()
中。除非傳遞過主機名,才會立即綁定到連接埠。為使事件循環繼續進行,它必須命中輪詢階段,這意味著有可能已經接收了一個連接,並在偵聽事件之前觸發了連接事件。
另一個範例運行的函式建構函式是從EventEmitter
繼承的,它想要呼叫建構函式:
const EventEmitter = require('events'); const util = require('util'); function MyEmitter() { EventEmitter.call(this); this.emit('event'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('an event occurred!'); });
你無法立即從建構函式中觸發事件,因為腳本尚未處理到使用者為該事件指派回呼函數的地方。因此,在建構函式本身中可以使用process.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(() => { this.emit('event'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('an event occurred!'); });
來源:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/