極簡 Node.js 入門 - 2.4 定時器

謙行發表於2020-08-13

極簡 Node.js 入門系列教程:https://www.yuque.com/sunluyong/node

本文更佳閱讀體驗:https://www.yuque.com/sunluyong/node/timer

timer 用於安排函式在未來某個時間點被呼叫,Node.js 中的定時器函式實現了與 Web 瀏覽器提供的定時器 API 類似的 API,但是使用了事件迴圈實現,Node.js 中有四個相關的方法

  1. setTimeout(callback, delay[, ...args])
  2. setInterval(callback[, ...args])
  3. setImmediate(callback[, ...args])
  4. 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);

會列印 5 4 3 2 1 或者 5 4 3 1 2

同步 & 非同步

第五行是同步執行,其它都是非同步的

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);

各個階段主要任務

  1. timers:執行 setTimeout、setInterval 回撥
  2. pending callbacks:執行 I/O(檔案、網路等) 回撥
  3. idle, prepare:僅供系統內部呼叫
  4. poll:獲取新的 I/O 事件,執行相關回撥,在適當條件下把阻塞 node
  5. check:setImmediate 回撥在此階段執行
  6. close callbacks:執行 socket 等的 close 事件回撥

日常開發中絕大部分非同步任務都是在 timers、poll、check 階段處理的

timers

Node.js 會在 timers 階段檢查是否有過期的 timer,如果存在則把回撥放到 timer 佇列中等待執行,Node.js 使用單執行緒,受限於主執行緒空閒情況和機器其它程式影響,並不能保證 timer 按照精確時間執行
定時器主要有兩種

  1. Immediate
  2. Timeout

Immediate 型別的計時器回撥會在 check 階段被呼叫,Timeout 計時器會在設定的時間過期後儘快的呼叫回撥,但

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

多次執行會發現列印的順序不一樣

poll

poll 階段主要有兩個任務

  1. 計算應該阻塞和輪詢 I/O 的時間
  2. 然後,處理 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 的呼叫機制確實也能為我們解決一些棘手的問題

  1. 允許使用者在 even tloop 開始之前 處理異常、執行清理任務
  2. 允許回撥在呼叫棧 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!');
});

參考

  1. Node.js 事件迴圈,定時器和 process.nextTick()
  2. 深入理解js事件迴圈機制(Node.js篇)

相關文章