【nodejs原理&源碼賞析(7)】【譯】Node.js中的事件循環,定時器和process.nextTick(nodejs 原理)
示例代碼托管在:http://www.github.com/dashnowords/blogs

博客園地址:《大史住在大前端》原創博文目錄
華為云社區地址:【你要的前端打怪升級指南】
【nodejs原理&源碼賞析(7)】【譯】Node.js中的事件循環,定時器和process.nextTickEvent Loop 是什么?Event Loop 基本解釋事件循環階段概覽事件循環細節timerspending callbackspoll階段checkclose callbackssetImmediate( )和setTimeout( )proess.nextTick( )理解 process.nextTick()為什么會允許這種情況存在?process.nextTick( )對比setImmediate( )為什么使用process.nextTick()
原文地址:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick
如果你常年游走于Nodejs中文網,可能已經錯過了官方網站上的第一手資料,Nodejs中文網并沒有翻譯這些非常高質量的核心文章,只提供了中文版的API文檔(已經很不容易了,沒有任何黑它的意思,我也是中文網的受益者),它們涵蓋了Node.js中從核心概念到相關工具等等非常重要的知識,下面是博文的目錄,你知道該怎么做了。
Event Loop 是什么?
事件循環是Node.js能夠實現非阻塞I/O的基礎,盡管JavaScript應用是單線程運行的,但是它可以將操作向下傳遞到系統內核去執行。
大多數現代系統內核都是支持多線程的,它們可以同時在后臺處理多個操作。當其中任何一個任務完成后,內核會通知Node.js,這樣它就可以把對應的回調函數添加進poll隊列,回調函數最終就能夠被執行,后文中我們還會進行更詳細的解釋。
Event Loop 基本解釋
當Node.js開始運行時,它就會初始化Event Loop,然后處理腳本文件(或者在REPL(read-eval-print-loop)環境中執行,本文不做深入探討)中的異步API調用,定時器,或process.nextTick方法調用,然后就會開始處理事件循環(Event Loop)。
下圖展示了事件循環的各個階段(每一個盒子被稱為事件循環中一個“階段”):
每一個階段都維護了一個先進先出的待執行回調函數隊列,盡管每一個階段都有自己獨特的處理方式,但總體來說,當事件循環進入一個具體的階段時,它將處理與這個階段有關的所有操作,然后執行這個階段對應隊列中的回調函數直到隊列為空,或者達到了該階段允許運行函數的數量的最大值,當滿足任何一個條件時,事件循環都會進入下一個階段,以此類推。
因為任何階段相關的操作都可能導致更多的待執行操作產生,而新事件會被內核添加進poll隊列中,當poll隊列中的回調函數被執行時允許繼續向當前階段的poll隊列中添加新的回調函數,于是長時間運行的回調函數可能就會導致事件循環在poll階段停留時間過長,你可以在后文的timers和poll章節查看更多的內容。
提示:Windows和Unix/Linux在實現上有細小的差別,但并不影響本文的演示,不同的系統可能會存在7-8個階段,但是最終要的階段上圖中已經展示了,這些是Node.js實際會使用到的。
事件循環階段概覽
timers-本階段執行通過setTimeout( )和setInterval( )添加的已經到時的計劃任務
pending callbacks-將一些I/O回調函數延遲到下一循環執行(這里不是很確定)
idle,prepare-內部使用的階段
poll-檢查新的I/O事件;執行相關I/O的回調(除了“close回調”,“定時器回調”和setImmediate( )添加的回調外幾乎所有其他回調函數);node有可能會在這里產生阻塞
check-執行setImmediate( )添加的回調函數
close callbacks-用于關閉功能的回調函數,例如socket.on('close',......)
在每輪事件周期之間,Node.js會檢查是否有處于等待中的異步I/O或定時器,如果沒有的話就會關閉當前程序。
事件循環細節
timers
一個timer會明確一個時間點,回調函數會在時間超過這個時間點后被執行,而不是開發者希望的精確時間。一旦定時器時間過期,回調函數就會盡可能早地被調度執行,然而操作系統的調度方式和其他的回調函數都有可能會導致某個定時器回調函數被延遲。
提示:技術上來說,poll階段控制著timers如何被執行。
下面的示例中,你使用了一個100ms后過期的定時器,接著花費了95ms使用異步文件讀取API異步讀取了某個文件:
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
}
});
當事件循環進入poll階段時,它的待執行隊列是空的(fs.readFile( )還沒有完成),所以它將等待一定時間(當前時間距離最快到期的定時器到期時間之間的差值)。95ms過去后,fs.readFile( )完成了文件讀取,并花費了10ms將回調函數添加進poll的執行隊列是它被執行。當回調函數執行完畢后,隊列中沒有更多的回調函數了,事件循環就會再次檢查下一個待觸發的timer是否已經到期,如果是,則事件循環就會繞回timers階段去執行到期timer的回調函數。在這個示例中,你會看到timer從設置定時器到回調函數被觸發一共花費了105ms.
注意:為了避免在poll階段阻塞事件循環,libuv(Node.js底層用于實現事件循環和異步特性的C語言庫)設置了一個硬上限值(該值會根據系統不同而有變化),使得poll階段只能將有限數量的回調函數添加進poll隊列。
pending callbacks
這個階段會執行一些系統操作的回調函數,例如一些TCP的錯誤。比如一個TCP的socket對象嘗試連接另一個socket時收到了ECONNREFUSED,一些Linux系統會希望匯報這類錯誤,這類回調函數就會被添加在pending callbacks階段的待執行隊列中。
poll階段
poll階段有兩個主要的功能:
計算需要阻塞的時長,以便可以將完成的I/O添加進待執行隊列
執行poll隊列中產生的事件
當事件循環進入poll階段且此時并沒有待執行的timer時,會按照下述邏輯來判斷:
如果poll隊列不為空,事件循環會以同步的方式逐個迭代執行隊列中的回調函數直到隊列耗盡,或到達系統設置的處理事件數量限制。
如果poll隊列為空,則按照下述邏輯繼續判斷:
如果腳本中使用setImmediate( )方法添加了回調函數,事件循環就會結束poll階段,并進入check階段來執行這些添加的回調函數。
如果沒有使用setimmediate( )添加的回調,事件循環就會等待其他回調函數被添加進隊列并立即執行添加的函數。
一旦poll隊列為空,事件循環就會檢查是否有已經到期的timers定時器,如果有一個或多個定時器到期,事件循環就會回到timers階段來執行這些定時器的回調函數。
check
這個階段允許開發者在poll階段結束后立即執行一些回調函數。如果poll階段出現閑置或者腳本中使用setImmediate( )添加了回調函數,事件循環事件循環就會主動進入check階段而不會停下來等待。
setImmediate( )實際上是一個運行在獨立階段的特殊定時器。它通過調用libuv提供的API添加那些希望在poll階段完成以后執行的回調函數。
通常,隨著代碼的執行,事件循環最終會到達poll階段,它會在這里等待incoming connection,request等請求事件。然而,如果一個回調函數被setImmediate( )添加時poll階段處于空閑狀態,它就會結束并進入check階段而不是繼續等待poll事件。
close callbacks
如果一個socket或者句柄被突然關閉(比如調用socket.destroy( )),close事件就會在這個階段被發出。否則(其他形式觸發的關閉)事件將會通過process.nextTick( )來發送。
setImmediate( )和setTimeout( )
setImmediate( )和setTimeout( )非常相似,但是表現卻不相同。
setImmediate( )被設計來在當前poll階段完成后執行一些腳本
setTimeout( )會把一個腳本添加為一定時間過去后才執行的“待執行任務”
這兩種定時器被執行的順序依賴于調用定時器的上下文。如果都是在主模塊中調用,定時器就會與process的性能相關(這也意味著它可能被同一個機器上的其他應用影響)。
例如下面的腳本中,如果我們一個不包含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周期中,immediate回調函數就會率先被執行:
// 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( )的主要優勢在于在I/O回調函數中調用時,不論程序中有多少timers,它添加的回調函數總是比其他timers更早執行。
proess.nextTick( )
理解 process.nextTick()
你可能已經注意到盡管同樣作為異步API的一部分,process.nextTick( )并沒有展示在上面的圖表中,因為技術層面來講它并不是事件循環中的一部分。nextTickQueue隊列將會在當前操作執行完后立即執行,無論當前處于事件循環的哪個階段,這里所說的操作是指底層的C/C++句柄到待執行JavaScript代碼的過渡(這句怪怪的,不知道怎么翻譯,原文是 an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed)。
再來看上面的圖表,任何時候當你在某個階段調用process.nextTick( ),所有傳入的回調函數都會在event loop繼續之前先被解析執行。這可能會造成非常嚴重的影響,因為它允許你阻塞通過遞歸調用process.nextTick( )而使得事件循環產生阻塞,是它無法到達poll階段。
為什么會允許這種情況存在?
為什么這種匪夷所思的情況要被包含在Node.js中呢?一部分是由于Node.js的設計哲學決定的,Node.js中認為API無論是否有必要,都應該異步執行,例如下面的代碼示例片段:
function apiCall(arg, callback) {
if(typeof arg !== 'string')
return process.nextTick(callback, new TypeError('argument should be string'));
}
這個示例對參數進行了檢查,如果參數類型是錯誤的,它就會將這個錯誤傳遞給回調函數。這個API允許process.nextTick獲取添加在callback之后的其他參數,并支持以冒泡的方式將其作為callback調用時傳入的參數,這樣你就不必通過函數嵌套來實現了。
這里我們做的事情是允許剩余的代碼執行完畢后再傳遞一個錯誤給用戶。通過使用process.nextTick( )就可以確保apiCall( )方法總是在剩余的代碼執行完和事件循環繼續進行這兩個時間點之間來執行回調函數。為了達到這個目的,JS調動棧就會允許立刻執行一些回調函數并允許用戶在其中遞歸觸發調用process.nextTick( ),但是卻不會造成爆棧(超過JavaScript引擎設置的調用棧最大容量)。
這種設計哲學可能會導致一些潛在的情況。例如下面的示例:
let bar;
// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback){callback();}
// the callback is called before `someAsyncApiCall` completes
someAsyncApiCall(()=>{
console.log('bar',bar);
});
bar = 1;
用戶定義的someAsyncApiCall( )雖然從注釋上看是異步的,但實際上是一個同步執行的函數。當它被調用時,回調函數和someAsyncApiCall( )實際上處于事件循環的同一個階段,這里并沒有任何實質上的異步行為,結果就是,回調函數嘗試獲取bar這個標識符的值盡管作用域中并沒有為這個變量賦值,因為腳本剩余的部分并沒有執行完畢。
如果將回調函數替換為process.nextTick( )的形式,腳本中剩余的代碼就可以執行完畢,這就使得變量和函數的初始化語句可以優先于傳入的回調函數而被執行,這樣做的另一個好處是它不會推動事件循環前進。這就使得用戶可以在事件循環繼續進行之前對一些可能的告警或者錯誤進行處理。比如下面的例子:
let bar;
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(()=>{
console.log('bar',bar);
});
bar = 1;
真實的場景中你會看到像下面這樣的使用方式:
const server = net.createServer(()=>{}).listen(8080);
server.on('listening',()=>{});
當端口號傳入后,就會立刻被綁定。所以listening回調就會立即被執行,問題是.on('listening')這個回調的設置看起來并沒有執行到。
這里實際上listening事件的發送就是被nextTick( )添加到待執行隊列中的,這樣后面的同步代碼就可以執行完畢,這樣的機制使得用戶可以在后文設置更多的事件-。
process.nextTick( )對比setImmediate( )
這兩個方法的命名令很多開發者感到迷惑。
process.nextTick( )會在事件循環的同一個階段立刻觸發
setImmediate( )會在下一輪事件循環觸發或者說事件循環的tick時觸發
事實上它們實際做的事情和它們的命名應該交換一下。process.nextTick( )比setTimeout( )添加的回調要更早觸發,但這種歷史問題是很難去修正的,它會導致一大批npm包無法正常運作。每天還有大量的新的模塊發布,這就意味著每過一天都有可能引發更多的破壞,盡管它們會造成混淆,但只能將錯就錯了。
我們推薦開發者在開發中堅持使用setImmediate( ),因為它的執行時機相對更容易推測(另外它也使得代碼可以兼容更多的環境例如瀏覽器JS)。
為什么使用process.nextTick()
兩個最主要的理由是:
它允許用戶優先處理錯誤,清理任何后續階段不再使用的資源,或者在事件循環繼續進行之前嘗試重新發送請求。
有時也需要在調用棧并不為空時去執行一些回調函數。
比如下面的示例:
const server = net.createServer();
server.on('connection',conn=>{});
server.listen(8000);
server.on('listening',()=>{});
設想listen()在事件循環開始時先執行,但是listening事件的監聽函數由setImmediate()來添加。除非傳入hostname,否則端口不會被綁定。對于事件循環來說,它一定會到達poll階段,如果此時已經有connection連接,那么connection事件就會在poll階段被發出,但listening事件要等到check階段能夠被發出。
另一個示例是執行一個構造函數,它繼承了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!');
});
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。