Event Loop、計時器、nextTick

方應杭在飢人谷發表於2018-03-25

原文:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

以下是譯文:

什麼是事件迴圈(Event Loop,注意空格)

JavaScript 是單執行緒的,有了 event loop 的加持,Node.js 才可以非阻塞地執行 I/O 操作,把這些操作儘量轉移給作業系統來執行。

我們知道大部分現代作業系統都是多執行緒的,這些作業系統可以在後臺執行多個操作。當某個操作結束了,作業系統就會通知 Node.js,然後 Node.js 就(可能)會把對應的回撥函式新增到 poll(輪詢)佇列,最終這些回撥函式會被執行。下文中我們會闡述其細節。

Event Loop 詳解

當 Node.js 啟動時,會做這幾件事

  1. 初始化 event loop
  2. 開始執行指令碼(或者進入 REPL,本文不涉及 REPL)。這些指令碼有可能會呼叫一些非同步 API、設定計時器或者呼叫 process.nextTick()
  3. 開始處理 event loop

如何處理 event loop 呢?下圖給出了一個簡單的概覽:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製程式碼

其中每個方框都是 event loop 中的一個階段。

每個階段都有一個「先入先出佇列」,這個佇列存有要執行的回撥函式(譯註:存的是函式地址)。不過每個階段都有其特有的使命。一般來說,當 event loop 達到某個階段時,會在這個階段進行一些特殊的操作,然後執行這個階段的佇列裡的所有回撥。 什麼時候停止執行這些回撥呢?下列兩種情況之一會停止:

  1. 佇列的操作全被執行完了
  2. 執行的回撥數目到達指定的最大值 然後,event loop 進入下一個階段,然後再下一個階段。

一方面,上面這些操作都有可能新增計時器;另一方面,作業系統會向 poll 佇列中新增新的事件,當 poll 佇列中的事件被處理時可能會有新的 poll 事件進入 poll 佇列。結果,耗時較長的回撥函式可以讓 event loop 在 poll 階段停留很久,久到錯過了計時器的觸發時機。你可以在下文的 timers 章節和 poll 章節詳細瞭解這其中的細節。

注意,Windows 的實現和 Unix/Linux 的實現稍有不同,不過對本文內容影響不大。本文囊括了 event loop 最重要的部分,不同平臺可能有七個或八個階段,但是上面的幾個階段是我們真正關心的階段,而且是 Node.js 真正用到的階段。

各階段概覽

  • timers 階段:這個階段執行 setTimeout 和 setInterval 的回撥函式。
  • I/O callbacks 階段:不在 timers 階段、close callbacks 階段和 check 階段這三個階段執行的回撥,都由此階段負責,這幾乎包含了所有回撥函式。
  • idle, prepare 階段(譯註:看起來是兩個階段,不過這不重要):event loop 內部使用的階段(譯註:我們不用關心這個階段)
  • poll 階段:獲取新的 I/O 事件。在某些場景下 Node.js 會阻塞在這個階段。
  • check 階段:執行 setImmediate() 的回撥函式。
  • close callbacks 階段:執行關閉事件的回撥函式,如 socket.on('close', fn) 裡的 fn。

一個 Node.js 程式結束時,Node.js 會檢查 event loop 是否在等待非同步 I/O 操作結束,是否在等待計時器觸發,如果沒有,就會關掉 event loop。

各階段詳解

timers 階段

計時器實際上是在指定多久以後可以執行某個回撥函式,而不是指定某個函式的確切執行時間。當指定的時間達到後,計時器的回撥函式會盡早被執行。如果作業系統很忙,或者 Node.js 正在執行一個耗時的函式,那麼計時器的回撥函式就會被推遲執行。

注意,從原理上來說,poll 階段能控制計時器的回撥函式什麼時候被執行。

舉例來說,你設定了一個計時器在 100 毫秒後執行,然後你的指令碼用了 95 毫秒來非同步讀取了一個檔案:

const fs = require('fs');

function someAsyncOperation(callback) {
  // 假設讀取這個檔案一共花費 95 毫秒
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}毫秒後執行了 setTimeout 的回撥`);
}, 100);


// 執行一個耗時 95 毫秒的非同步操作
someAsyncOperation(() => {
  const startCallback = Date.now();

  // 執行一個耗時 10 毫秒的同步操作
  while (Date.now() - startCallback < 10) {
    // 什麼也不做
  }
});
複製程式碼

當 event loop 進入 poll 階段,發現 poll 佇列為空(因為檔案還沒讀完),event loop 檢查了一下最近的計時器,大概還有 100 毫秒時間,於是 event loop 決定這段時間就停在 poll 階段。在 poll 階段停了 95 毫秒之後,fs.readFile 操作完成,一個耗時 10 毫秒的回撥函式被系統放入 poll 佇列,於是 event loop 執行了這個回撥函式。執行完畢後,poll 佇列為空,於是 event loop 去看了一眼最近的計時器(譯註:event loop 發現臥槽,已經超時 95 + 10 - 100 = 5 毫秒了),於是經由 check 階段、close callbacks 階段繞回到 timers 階段,執行 timers 佇列裡的那個回撥函式。這個例子中,100 毫秒的計時器實際上是在 105 毫秒後才執行的。

注意:為了防止 poll 階段佔用了 event loop 的所有時間,libuv(Node.js 用來實現 event loop 和所有非同步行為的 C 語言寫成的庫)對 poll 階段的最長停留時間做出了限制,具體時間因作業系統而異。

I/O callbacks 階段

這個階段會執行一些系統操作的回撥函式,比如 TCP 報錯,如果一個 TCP socket 開始連線時出現了 ECONNREFUSED 錯誤,一些 *nix 系統就會(向 Node.js)通知這個錯誤。這個通知就會被放入 I/O callbacks 佇列。

poll 階段(輪詢階段)

poll 階段有兩個功能:

  1. 如果發現計時器的時間到了,就繞回到 timers 階段執行計時器的回撥。
  2. 然後再,執行 poll 佇列裡的回撥。

當 event loop 進入 poll 階段,如果發現沒有計時器,就會:

  1. 如果 poll 佇列不是空的,event loop 就會依次執行佇列裡的回撥函式,直到佇列被清空或者到達 poll 階段的時間上限。
  2. 如果 poll 佇列是空的,就會:
    1. 如果有 setImmediate() 任務,event loop 就結束 poll 階段去往 check 階段。
    2. 如果沒有 setImmediate() 任務,event loop 就會等待新的回撥函式進入 poll 佇列,並立即執行它。

一旦 poll 佇列為空,event loop 就會檢查計時器有沒有到期,如果有計時器到期了,event loop 就會回到 timers 階段執行計時器的回撥。

check 階段

這個階段允許開發者在 poll 階段結束後立即執行一些函式。如果 poll 階段空閒了,同時存在 setImmediate() 任務,event loop 就會進入 check 階段。

setImmediate() 實際上是一種特殊的計時器,有自己特有的階段。它是通過 libuv 裡一個能將回撥安排在 poll 階段之後執行的 API 實現的。

一般來說,當程式碼執行後,event loop 最終會達到 poll 階段,等待新的連線、新的請求等。但是如果一個回撥是由 setImmediate() 發出的,同時 poll 階段空閒下來了,event loop就會結束 poll 階段進入 check 階段,不再等待新的 poll 事件。

(譯註:感覺同樣的話說了三遍)

close callbacks 階段

如果一個 socket 或者 handle 被突然關閉(比如 socket.destroy()),那麼就會有一個 close 事件進入這個階段。否則(譯註:我沒看到這個否則在否定什麼,是在否定「突然」嗎?),這個 close 事件就會進入 process.nextTick()。

setImmediate() vs setTimeout()

setImmediate 和 setTimeout 很相似,但是其回撥函式的呼叫時機卻不一樣。

setImmediate() 的作用是在當前 poll 階段結束後呼叫一個函式。 setTimeout() 的作用是在一段時間後呼叫一個函式。 這兩者的回撥的執行順序取決於 setTimeout 和 setImmediate 被呼叫時的環境。

如果 setTimeout 和 setImmediate 都是在主模組(main module)中被呼叫的,那麼回撥的執行順序取決於當前程式的效能,這個效能受其他應用程式程式的影響。

舉例來說,如果在主模組中執行下面的指令碼,那麼兩個回撥的執行順序是無法判斷的:

// 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 的回撥就總是優先於 setTimeout 的回撥:

// 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 操作的回撥裡,setImmediate 的回撥總是比 setTimeout 的回撥先執行。(譯者注:怎麼總是把一個道理翻來覆去地說)

process.nextTick()

你可能發現 process.nextTick() 這個重要的非同步 API 沒有出現在任何一個階段裡,那是因為從技術上來講 process.nextTick() 並不是 event loop 的一部分。實際上,不管 event loop 當前處於哪個階段,nextTick 佇列都是在當前階段後就被執行了。

回過頭來看我們的階段圖,你在任何一個階段呼叫 process.nextTick(回撥),回撥都會在當前階段繼續執行前被呼叫。這種行為有的時候會造成不好的結果,因為你可以遞迴地呼叫 process.nextTick(),這樣 event loop 就會一直停在當前階段不走……無法進入 poll 階段。

為什麼 Node.js 要這樣設計 process.nextTick 呢?

因為有些非同步 API 需要保證一致性,即使可以同步完成,也要保證非同步操作的順序,看下面程式碼:

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(callback, new TypeError('argument should be string'));
}
複製程式碼

這段程式碼檢查了引數的型別,如果型別不是 string,就會將 error 傳遞給 callback。

這段程式碼保證 apiCall 呼叫之後的同步程式碼能在 callback 之前執行。用於用到了 process.nextTick(),所以 callback 會在 event loop 進入下一個階段前執行。為了做到這一點,JS 的呼叫棧可以先 unwind 再執行 nextTick 的回撥,這樣無論你遞迴呼叫多少次 process.nextTick() 都不會造成呼叫棧溢位(V8 裡對應 RangeError: Maximum call stack size exceeded)。

如果不這樣設計,會造成一些潛在的問題,比如下面的程式碼:

let bar;

// 這是一個非同步 API,但是卻同步地呼叫了 callback
function someAsyncApiCall(callback) { callback(); }

//`someAsyncApiCall` 在執行過程中就呼叫了回撥
someAsyncApiCall(() => {
  // 此時 bar 還沒有被賦值為 1
  console.log('bar', bar); // undefined
});

bar = 1;
複製程式碼

開發者雖然把 someAsyncApiCall 命名得像一個非同步函式,但是實際上這個函式是同步執行的。當 someAsyncApiCall 被呼叫時,回撥也在同一個 event loop 階段被呼叫了。結果回撥中就無法得到 bar 的值。因為賦值語句還沒被執行。

如果把回撥放在 process.nextTick() 中執行,後面的賦值語句就可以先執行了。而且 process.nextTick() 的回撥會在 eventLoop 進入下一個階段前呼叫。(譯註:又是把一個道理翻來覆去地講)

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', () => {});
複製程式碼

.listen(8080) 這句話是同步執行的。問題在於 listening 回撥無法被觸發,因為 listening 的監聽程式碼在 .listen(8080) 的後面。

為了解決這個問題,.listen() 函式可以使用 process.nextTick() 來執行 listening 事件的回撥。

process.nextTick() vs setImmediate()

這兩個函式功能很像,而且名字也很令人疑惑。

process.nextTick() 的回撥會在當前 event loop 階段「立即」執行。 setImmediate() 的回撥會在後續的 event loop 週期(tick)執行。

(譯註:看起來名字叫反了)

二者的名字應該互換才對。process.nextTick() 比 setImmediate() 更 immediate(立即)一些。

這是一個歷史遺留問題,而且為了保證向後相容性,也不太可能得到改善。所以就算這兩個名字聽起來讓人很疑惑,也不會在未來有任何變化。

我們推薦開發者在任何情況下都使用 setImmediate(),因為它的相容性更好,而且它更容易理解。

什麼時候用 process.nextTick()?

There are two main reasons: 使用的理由有兩個:

  1. 讓開發者處理錯誤、清除無用的資源,或者在 event loop 當前階段結束前嘗試重新請求資源
  2. 有時候有必要讓一個回撥在呼叫棧 unwind 之後,event loop 進入下階段之前執行

為了讓程式碼更合理,我們可能會寫這樣的程式碼:

const server = net.createServer();
server.on('connection', (conn) => { });

server.listen(8080);
server.on('listening', () => { });
複製程式碼

假設 listen() 在 event loop 一啟動的時候就執行了,而 listening 事件的回撥被放在了 setImmediate() 裡,listen 動作是立即發生的,如果想要 event loop 執行 listening 回撥,就必須先經過 poll 階段,當時 poll 階段有可能會停留,以等待連線,這樣一來就有可能出現 connect 事件的回撥比 listening 事件的回撥先執行。(譯註:這顯然不合理,所以我們需要用 process.nextTick)

再舉一個例子,一個類繼承了 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'),因為這樣的話後面的回撥就永遠無法執行。把 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!');
});
複製程式碼

相關文章