Event Loop、計時器、nextTick

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

如下是譯文:api

什麼是事件循環(Event Loop,注意空格)

JavaScript 是單線程的,有了 event loop 的加持,Node.js 才能夠非阻塞地執行 I/O 操做,把這些操做盡可能轉移給操做系統來執行。bash

咱們知道大部分現代操做系統都是多線程的,這些操做系統能夠在後臺執行多個操做。當某個操做結束了,操做系統就會通知 Node.js,而後 Node.js 就(可能)會把對應的回調函數添加到 poll(輪詢)隊列,最終這些回調函數會被執行。下文中咱們會闡述其細節。多線程

Event Loop 詳解

當 Node.js 啓動時,會作這幾件事異步

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

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

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

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

每一個階段都有一個「先入先出隊列」,這個隊列存有要執行的回調函數(譯註:存的是函數地址)。不過每一個階段都有其特有的使命。通常來講,當 event loop 達到某個階段時,會在這個階段進行一些特殊的操做,而後執行這個階段的隊列裏的全部回調。 何時中止執行這些回調呢?下列兩種狀況之一會中止:函數

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

一方面,上面這些操做都有可能添加計時器;另外一方面,操做系統會向 poll 隊列中添加新的事件,當 poll 隊列中的事件被處理時可能會有新的 poll 事件進入 poll 隊列。結果,耗時較長的回調函數可讓 event loop 在 poll 階段停留好久,久到錯過了計時器的觸發時機。你能夠在下文的 timers 章節和 poll 章節詳細瞭解這其中的細節。oop

注意,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!');
});
複製代碼
相關文章
相關標籤/搜索