­

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

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

本文更佳閱讀體驗://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篇)
Tags: