淺談 NodeJS 的事件循環和 Timers

什麼是事件循環

事件循環,即 Event Loop,其實就是 JS 管理事件執行的一個流程,具體的管理方法由 JS 運行的環境決定,目前 JS 的主要運行環境有瀏覽器和 Node。javascript

瀏覽器和 Node 的事件循環,都是先初始化一個循環,執行同步代碼,遇到異步操做時,會將其交給對應的線程處理,主線程則繼續往下執行,異步操做執行完畢後,對應的 callback 回調會被推入事件隊列,並在合適的時機執行。每執行一次循環體的過程,咱們稱之爲一個 Tick。html

與瀏覽器不一樣的是,Node 的循環分爲幾個階段,每一個階段分別處理不一樣的事件,而瀏覽器的循環不存在這樣的階段劃分。下面咱們介紹一下 Node 事件循環的幾個階段。java

Node 事件循環的流程

事件循環的六個階段

Node 的事件循環分爲幾個階段,以下圖(除了 incoming,每個方框表明一個階段):node

Node 事件循環.png

咱們先簡單看看每一個階段的做用,也就是在一個 Tick 中,Node 是按照怎樣的順序工做的:npm

  • timers:執行 setTimeoutsetInterval 的回調;
  • pending callbacks:上個 Tick 中延遲到這個 Tick 的回調,就會在這個階段執行;
  • idle,prepare:Node 內部使用;
  • poll:執行大多數異步操做的回調;
  • check:執行 setImmediate 的回調;
  • close callbacks:執行 close 相關的回調,如 socket.on('close', ...)

每一個階段都對應一個 FIFO 的隊列,循環進入某個階段後,只有在兩種狀況下會跳出該階段進入下一個階段,一是將其隊列中的回調所有執行完,二是執行數達到了該階段的上限。api

poll 階段

接下來咱們重點看一下 poll 階段,poll 階段主要作兩件事情:瀏覽器

  • 計算輪詢時間 maxPollTime
  • 執行 poll 隊列中的回調

如下爲 poll 階段的流程:markdown

  1. 事件循環進入 poll 階段後,會先檢查 poll 隊列是否爲空;
  2. 若 poll 隊列不爲空,則遍歷並同步執行隊列裏的回調(直到所有執行完或達到執行數的上限),若 poll 隊列爲空,則檢查是否有待執行的 setImmediate 回調;
  3. 若是有,則進入 check 階段,若是沒有,則原地等待新的回調被推入 poll 隊列,並當即執行這些新推入的回調(等待時間的上限爲前面計算出來的 maxPollTime)。

注意:poll 階段空閒,即 poll 隊列爲空的時候,一旦有新的 setImmediate 回調,循環就會結束 poll 進入 check 階段;或者一旦有新的 timer 計時結束,循環就會繞回 Timers 階段;若是同時出現二者的回調,setImmediate 的優先級更高併發

setImmediate 和 setTimeout(fn, 0) 的執行順序

首先明確 setImmediatesetTimeout 各自的執行時機:setImmediate 是一個特殊的定時器,它的回調會在 check 階段執行,也就是緊跟在 poll 階段後面執行;setTimeout 的回調會在達到 delay 時間後,儘快執行,前提是計時結束並把回調推入了 Timers 的隊列。 另外還需明確一點,事件循環除了可以正常進入 Timers 階段外,poll 階段一旦空閒而且沒有待執行的 setImmediate 回調,就會去檢查是否有計時結束的 timer,若是有的話,就會繞回到 Timers 階段,因此 setTimeout 回調的執行時機實際上有兩個。less

好了,咱們如今來看 setImmediatesetTimeout(fn, 0) 回調的執行順序,能夠分爲如下兩種狀況:

  1. 二者都在主模塊(main module)調用,則兩個回調的執行順序不肯定;
  2. 二者都不在主模塊調用,而是在一個異步操做的回調裏被調用,那麼 setImmediate 的回調會先於 setTimeout(fn, 0) 執行

如下示例中,咱們都假定 setImmediate 的回調爲 cb_immediatesetTimeout(fn, 0) 的回調爲 cb_timeout

二者都在 I/O 操做的回調內,cb_immediate 先執行

代碼以下:

// test_timer.js
const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});
複製代碼

咱們屢次運行會發現,「immediate」始終會在「timeout」以前打印:

$ node test_timer.js
immediate
timeout
複製代碼

下面咱們分析一下 cb_immediate 始終先執行的緣由。 首先,I/O 操做的回調在 poll 隊列裏,是在 poll 階段執行的。前面咱們提到,poll 階段一旦空閒,就會檢查是否有待執行的 setImmediate 回調,若是有,就會結束等待進入 check 階段,因此即便這時有新的 timer 計時結束,也要等到 check、close 都結束並進入新的 Tick 才能執行。poll 檢查發現 cb_immediate 待執行,因此循環直接進入 check 階段執行 cb_immediate,而 cb_timeout 最快也要在下一個 Tick 纔會被執行。

二者都在 setTimeout 的回調內,cb_immediate 先執行

代碼以下:

setTimeout(() => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {1
    console.log('immediate');
  });
}, 0);
複製代碼

屢次運行後咱們會發現,「immediate」始終比「timeout」先打印。 外層 setTimeout 的回調是在 Timers 階段執行的,執行完回調後,事件循環繼續日後面走,走到 poll 階段後,一旦 poll 空閒,就會檢查並發現有待執行的 setImmediate 回調,發現存在 cb_immediate,因而循環直接進入 check 階段,率先執行 cb_immediate

綜合上面兩個示例,咱們能夠看出,在同一個異步操做的回調中同時調用 setImmediatesetTimeout(fn, 0)setImmediate 之因此始終會先執行,正是因爲計時器一旦錯過了 Timers 階段,下一個執行回調的時機在 poll 階段,而 poll 階段檢查過程當中,setImmediate 的優先級高於 setTimeout,一旦發現有待執行的 setImmediate 回調,循環就會繼續往下走,因此 setImmediate 回調的執行永遠先於 setTimeout 回調。

咱們接着看一下在主模塊同時調用 setImmediatesetTimeout(fn, 0) 的狀況。

二者的調用都在主模塊,執行順序不肯定

代碼以下:

setTimeout(() => {
    console.log('timeout');
}, 0);
setImmediate(() => {1
    console.log('immediate');
});
複製代碼

運行以上代碼,咱們會發現,時而先打印出「timeout」,時而先打印出「immediate」,即二者的執行順序是不肯定的。

咱們先理一下主要流程:

  1. 主線程執行同步代碼;
  2. setTimeout 交給定時器線程處理;
  3. 執行 setImmediate,即調用 libuv 提供的一個 API,該 API 會將 cb_immediate 放入 check 階段的隊列;
  4. 同步代碼執行完畢後,事件循環進入 Timers 階段;
  5. 檢查 Timers 隊列,若不爲空,執行回調(即 cb_timeout),若爲空,繼續往下執行;
  6. 進入 pending callbacks 階段;
  7. 進入 idle,prepare 階段;
  8. 進入 poll 階段;
  9. poll 空閒後,發現有待執行的 setImmediate 回調(即 cb_immediate),進入 check 階段;
  10. 執行 cb_Immediate

根據以上流程能夠看出,cb_immediatecb_timeout 的執行順序,就取決於第 e 步中 Timers 的隊列是否已經存在 cb_timeout,若是存在,則 cb_timeout 先執行,不然 cb_immediate 先執行。

這裏補充一點,在 Node 中,setTimeout(fn, 0) 會被強制改成 setTimeout(fn, 1),這一點在官方文檔中有相關說明:

When delay is larger than 2147483647 or less than 1, the delay will be set to 1.

若是循環進入 Timers 階段的時候,距離 setTimeout 執行已通過去了 1ms,而且 cb_timeout 已經被推入 Timers 的隊列,那麼循環就會取出 cb_timeout 並執行;反之,若是循環進入 Timers 階段的時候,cb_timeout 尚未在隊列內,那麼 cb_timeout 就不會在這個 Tick 被執行,cb_immediate 會先執行。

而循環進入 Timers 階段的時候,是否已經通過了 1ms,會受到多方面的影響,包括同步代碼執行所花費的時間,以及系統的性能,機器的狀態差別也會致使每次運行的結果不一樣。因此同時在主模塊調用 setImmediatesetTimeout(fn, 0),兩個回調的執行順序是不肯定的。

process.nextTick()

process.nextTick() 回調的執行時機

你們可能會發現,咱們在介紹事件循環的階段時,process.nextTick() 沒有出如今任何一個階段。這是由於 process.nextTick() 不屬於任何一個階段,事實上,它是在 「階段之間」 執行的。 在任何一個階段調用了 process.nextTick(),nextTick 的回調都會在當前階段結束後當即執行,全部回調執行完後,事件循環才進入下一個階段,因此過多的或長時間執行的 nextTick 回調,實際上是能夠阻塞整個事件循環的,因此得當心使用 nextTick。

process.nextTick() VS setImmediate()

咱們回顧一下二者的回調的執行時機,process.nextTick() 的回調是在當前階段結束後就當即執行,setImmediate() 的回調是在每一個循環的 check 階段執行。細心的讀者就會發現,process.nextTick() 彷佛比 setImmediate() 更「immediate」,更即時。 事實上官方也提到了這一點,二者的名字應該倒過來才比較合理,但修更名字的話,影響範圍太大了,npm 上全部用到這兩個方法的包都會受到影響,因此即便二者的名字存在必定的迷惑性,可是目前看來,更名是不現實也是不可能的。

Node EventLoop VS 瀏覽器的 EventLoop

二者有如下兩點區別:

  • Node 的事件循環是分階段的,每一個階段執行特定的事件回調,而瀏覽器不分階段;
  • Node 的微任務會在階段之間執行,而瀏覽器的微任務是在每一個宏任務結束後執行

和瀏覽器同樣,Node 也會維護一個微任務列表。和瀏覽器不一樣的是,瀏覽器是在宏任務即將結束的時候,檢查宏任務對應的微任務列表是否爲空,若不爲空,則執行全部微任務後,再進入下一個 Tick。而 Node 的微任務執行時機是在各個階段之間,一個階段結束後,事件循環會去檢查微任務列表是否爲空,若不爲空,則執行完全部微任務後,才進入循環的下一個階段。 注意,setImmediate 是 Node 特有的宏任務,process.nextTick 是 Node 特有的微任務。

相關文章
相關標籤/搜索