事件迴圈是 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/
更多node相關知識,請存取:!
以上就是了解Node中的事件迴圈、process.nextTick()的詳細內容,更多請關注TW511.COM其它相關文章!