極簡 Node.js 入門系列教程:https://www.yuque.com/sunluyong/node
timer 用於安排函式在未來某個時間點被呼叫,Node.js 中的定時器函式實現了與 Web 瀏覽器提供的定時器 API 類似的 API,但是使用了事件迴圈實現,Node.js 中有四個相關的方法
- setTimeout(callback, delay[, ...args])
- setInterval(callback[, ...args])
- setImmediate(callback[, ...args])
- process.nextTick(callback[, ...args])
前兩個含義和 web 上的是一致的,後兩個是 Node.js 獨有的,效果看起來就是 setTimeout(callback, 0),在 Node.js 程式設計中使用的最多
Node.js 不保證回撥被觸發的確切時間,也不保證它們的順序,回撥會在儘可能接近指定的時間被呼叫。setTimeout 當 delay 大於 2147483647 或小於 1 時,則 delay 將會被設定為 1, 非整數的 delay 會被截斷為整數
奇怪的執行順序
看一個示例,用幾種方法分別非同步列印一個數字
setImmediate(console.log, 1);
setTimeout(console.log, 1, 2);
Promise.resolve(3).then(console.log);
process.nextTick(console.log, 4);
console.log(5);
同步 & 非同步
第五行是同步執行,其它都是非同步的
setImmediate(console.log, 1);
setTimeout(console.log, 1, 2);
Promise.resolve(3).then(console.log);
process.nextTick(console.log, 4);
/****************** 同步任務和非同步任務的分割線 ********************/
console.log(5);
所以先列印 5,這個很好理解,剩下的都是非同步操作,Node.js 按照什麼順序執行呢?
event loop
Node.js 啟動後會初始化事件輪詢,過程中可能處理非同步呼叫、定時器排程和 process.nextTick(),然後開始處理event loop。官網中有這樣一張圖用來介紹 event loop 操作順序
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
event loop 的每個階段都有一個任務佇列,當 event loop 進入給定的階段時,將執行該階段的任務佇列,直到佇列清空或執行的回撥達到系統上限後,才會轉入下一個階段,當所有階段被順序執行一次後,稱 event loop 完成了一個 tick
非同步操作都被放到了下一個 event loop tick 中,process.nextTick 在進入下一次 event loop tick 之前執行,所以肯定在其它非同步操作之前
setImmediate(console.log, 1);
setTimeout(console.log, 1, 2);
Promise.resolve(3).then(console.log);
/****************** 下次 event loop tick 分割線 ********************/
process.nextTick(console.log, 4);
/****************** 同步任務和非同步任務的分割線 ********************/
console.log(5);
各個階段主要任務
- timers:執行 setTimeout、setInterval 回撥
- pending callbacks:執行 I/O(檔案、網路等) 回撥
- idle, prepare:僅供系統內部呼叫
- poll:獲取新的 I/O 事件,執行相關回撥,在適當條件下把阻塞 node
- check:setImmediate 回撥在此階段執行
- close callbacks:執行 socket 等的 close 事件回撥
日常開發中絕大部分非同步任務都是在 timers、poll、check 階段處理的
timers
Node.js 會在 timers 階段檢查是否有過期的 timer,如果存在則把回撥放到 timer 佇列中等待執行,Node.js 使用單執行緒,受限於主執行緒空閒情況和機器其它程式影響,並不能保證 timer 按照精確時間執行
定時器主要有兩種
- Immediate
- Timeout
Immediate 型別的計時器回撥會在 check 階段被呼叫,Timeout 計時器會在設定的時間過期後儘快的呼叫回撥,但
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
poll
poll 階段主要有兩個任務
- 計算應該阻塞和輪詢 I/O 的時間
- 然後,處理 poll 佇列裡的事件
當event loop進入 poll 階段且沒有被排程的計時器時
- 如果 poll 佇列不是空的 ,event loop 將迴圈訪問回撥佇列並同步執行,直到佇列已用盡或者達到了系統或達到最大回撥數
- 如果 poll 佇列是空的
- 如果有 setImmediate() 任務,event loop 會在結束 poll 階段後進入 check 階段
- 如果沒有 setImmediate()任務,event loop 阻塞在 poll 階段等待回撥被新增到佇列中,然後立即執行
一旦 poll 佇列為空,event loop 將檢查 timer 佇列是否為空,如果非空則進入下一輪 event loop
上面提到了如果在不同的 I/O 裡,不能確定 setTimeout 和 setImmediate 的執行順序,但如果 setTimeout 和 setImmediate 在一個 I/O 回撥裡,肯定是 setImmediate 先執行,因為在 poll 階段檢查到有 setImmediate() 任務,event loop 直接進入 check 階段執行 setImmediate 回撥
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
check
在該階段執行 setImmediate 回撥
為什麼 Promise.then 比 setTimeout 早一些
前端同學肯定都聽說過 micoTask 和 macroTask,Promise.then 屬於 microTask,在瀏覽器環境下 microTask 任務會在每個 macroTask 執行最末端呼叫
在 Node.js 環境下 microTask 會在每個階段完成之間呼叫,也就是每個階段執行最後都會執行一下 microTask 佇列
setImmediate(console.log, 1);
setTimeout(console.log, 1, 2);
/****************** microTask 分割線 ********************/
Promise.resolve(3).then(console.log); // microTask 分割線
/****************** 下次 event loop tick 分割線 ********************/
process.nextTick(console.log, 4);
/****************** 同步任務和非同步任務的分割線 ********************/
console.log(5);
setImmediate VS process.nextTick
setImmediate 聽起來是立即執行,process.nextTick 聽起來是下一個時鐘執行,為什麼效果是反過來的?這就要從那段不堪回首的歷史講起
最開始的時候只有 process.nextTick 方法,沒有 setImmediate 方法,通過上面的分析可以看出來任何時候呼叫 process.nextTick(),nextTick 會在 event loop 之前執行,直到 nextTick 佇列被清空才會進入到下一 event loop,如果出現 process.nextTick 的遞迴呼叫程式沒有被正確結束,那麼 IO 的回撥將沒有機會被執行
const fs = require('fs');
fs.readFile('a.txt', (err, data) => {
console.log('read file task done!');
});
let i = 0;
function test(){
if(i++ < 999999) {
console.log(`process.nextTick ${i}`);
process.nextTick(test);
}
}
test();
執行程式將返回
nextTick 1
nextTick 2
...
...
nextTick 999999
read file task done!
於是乎需要一個不這麼 bug 的呼叫,setImmediate 方法出現了,比較令人費解的是在 process.nextTick 起錯名字的情況下,setImmediate 也用了一個錯誤的名字以示區分。。。
那麼是不是程式設計中應該杜絕使用 process.nextTick 呢?官方推薦大部分時候應該使用 setImmediate,同時對 process.nextTick 的最大呼叫堆疊做了限制,但 process.nextTick 的呼叫機制確實也能為我們解決一些棘手的問題
- 允許使用者在 even tloop 開始之前 處理異常、執行清理任務
- 允許回撥在呼叫棧 unwind 之後,下次 event loop 開始之前執行
一個類繼承了 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!');
});
在建構函式執行 this.emit('event')
會導致事件觸發比事件回撥函式繫結早,使用 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!');
});