從 libuv 看 nodejs 事件循環

本文同步發佈於 個人博客javascript

首先全部平臺,不管是瀏覽器仍是 nodejsJS 事件循環都不是由 ECMA 262 規範定義。事件循環並非 ECMA 262 規範的一部分。瀏覽器端的事件循環由 Web API 中定義,並由 W3CHTML living standard 來維護。而 nodejs 是基於 libuv 的事件循環,其並無一個事件循環規範標準,那麼瞭解 nodejs 事件循環的最好方式就是 nodejs 的源碼和官方文檔和 libuv 的源碼和官方文檔。html

文章中引用的參考儘量選取官方文檔、nodejs/libuv 倉庫,nodejs/libuv 貢獻者解答,google/microsoft 工程師,高贊 stackoverflow 回答等來源。java


事件循環概述

根據 nodejs 官方文檔,在一般狀況下,nodejs 中的事件循環根據不一樣的操做系統可能存在特殊的階段,但整體是能夠分爲如下 6 個階段:node

┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
複製代碼
  1. timer 階段,用於執行全部經過計時器函數(即 setTimeoutsetInterval)註冊的回調函數。linux

  2. pending callbacks 階段。雖然大部分 I/O 回調都是在 poll 階段被當即執行,可是會存在一些被延遲調用的 I/O 回調函數。那麼此階段就是爲了調用以前事件循環延遲執行的 I/O 回調函數。git

    引自在 libuv 的設計文檔 the I/O loop - step.4github

  3. idle prepare 階段,僅用於 nodejs 內部模塊使用。web

  4. poll(輪詢)階段,此階段有兩個主要職責:1. 計算當前輪詢須要阻塞後續階段的時間;2. 處理事件回調函數。c#

    nodejs 中事件循環中存在一種維持在此階段的趨勢,後文會作詳細說明瀏覽器

  5. check 階段,用於在 poll 階段的回調函數隊列爲空時,使用 setImmediate 實現調度執行特定代碼片斷。

  6. close 回調函數階段,執行全部註冊 close 事件的回調函數。

每個 nodejs 事件循環 tick 老是要經歷以上階段,由 timer 階段開始,由 close 回調函數階段結束。每個階段都會循環執行當前階段的回調函數隊列,直至隊列爲空或到達最大可執行回調函數次數。

事件循環實現

nodejs 官方文檔,nodejs 中的事件循環是依賴於名爲 libuvC 語言庫實現。本質上 libuv 的執行方式決定了 nodejs 中的事件循環的執行方式。

至本文發佈之際,最新 libuv 的版本爲 v1.35.0.

Q: libuv 是什麼?

A: libuv 是使用 C 語言實現的單線程非阻塞異步 I/O 解決方案,本質上它是對常見操做系統底層異步 I/O 操做的封裝,並對外暴露功能一致的 API, 首要目的是儘量的爲 nodejs 在不一樣系統平臺上提供統一的事件循環模型。

nodejs 的事件循環核心對應 libuv 中的 uv_run 函數,核心邏輯以下:

// http://docs.libuv.org/en/v1.x/loop.html#c.uv_loop_alive
r = uv__loop_alive(loop);
if (!r)
  uv__update_time(loop);

// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
  // http://docs.libuv.org/en/v1.x/loop.html#c.uv_update_time
  uv__update_time(loop);
  // timer 階段
  uv__run_timers(loop);
  // pending callbacks 階段
  ran_pending = uv__run_pending(loop);
  uv__run_idle(loop);
  uv__run_prepare(loop);

  timeout = 0;
  if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
    timeout = uv_backend_timeout(loop);

  // poll 階段
  uv__io_poll(loop, timeout);
  // check 階段
  uv__run_check(loop);
  // close callbacks 階段
  uv__run_closing_handles(loop);

  if (mode == UV_RUN_ONCE) {
    /* UV_RUN_ONCE implies forward progress: at least one callback must have * been invoked when it returns. uv__io_poll() can return without doing * I/O (meaning: no callbacks) when its timeout expires - which means we * have pending timers that satisfy the forward progress constraint. * * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from * the check. */
    uv__update_time(loop);
    uv__run_timers(loop);
  }

  r = uv__loop_alive(loop);
  if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
    break;
}
複製代碼

libuv 文檔中對 IO loop 的描述,原則上,一個線程中至多僅有一個事件循環,在多個線程中能夠存在多個並行的事件循環。事件循環遵循常規的單線程異步 I/O 方案。全部的 (網絡)I/O 都是在非阻塞的 socket 上執行。這些 socket 使用了以下表給定平臺的最佳輪詢機制。

mechanism platform
epoll Linux
kqueue OSX, BSD
IOCP Windows
event ports SunOS

單個事件循環 loop 做爲整個事件循環迭代 loop iteration 的一部分,它會 阻塞 等待已經添加到 poller 中的 sockets 上的 IO 活動,並間觸發對應的回調函數以指示 socket 的條件(便可讀,可寫,掛起),以便句柄能夠讀取,寫入,或執行所指望的 IO 操做。

libuv loop iteration

r = uv__loop_alive(loop);
if (!r)
  uv__update_time(loop);
複製代碼

根據源代碼和 libuv 官方文檔,事件循環首先會緩存當前事件循環 tick 的開始時間,用於減小時間相關的系統調用。

緩存時間的作法是由於系統內的時間調用會受到系統內其餘應用的影響,因此爲了儘量避免其餘應用對 nodejs 的影響而在事件循環的 tick 開始之時緩存時間。

若是事件循環是活動的,那麼開始當前事件循環,不然當即退出整個事件循環迭代。那麼如何界定一個事件循環迭代是活動的?若是一個事件循環擁有活動的句柄或引用句柄,活動的請求或 closing 句柄,那麼該事件循環被認爲是活動的。

// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
  // ...
}
複製代碼

從以上示例代碼不難看出,整個事件循環迭代就是一個 while 無限循環,正是這個 while 語句在不斷地推進事件循環的迭代。在每一次循環迭代開始時,都會不斷驗證當前事件循環 tick 是不是活動的,且沒有 stop 標識。在進入循環以後首先會更新當前事件循環的開始時間並繼續執行事件循環的各個階段的回調函數隊列。

結合前文對 nodejs 中事件循環的生命週期抽象概括,不難依據 uv_run()doc 的核心邏輯得出:

  1. timer 階段: uv__run_timers(loop)

  2. pending callbacks 階段:uv__run_pending(loop)

  3. idle 階段:uv__run_idle(loop)

  4. poll 階段:uv__io_poll(loop, timeout)

  5. check 階段:uv__run_check(loop)

  6. close callbacks 階段:uv__run_closing_handles(loop)函數定義

timer 階段

nodejs 事件循環的一個 tick 始終以 timer 階段開始,其中包含一個由全部 setTimeoutsetInterval 註冊的待執行回調函數隊列。此階段的 核心職責 是執行由全部到達時間閾值的計時器註冊的回調函數。

待執行,表示在已經到達計時器的時間閾值時,被加入到 timer 階段的回調函數隊列中等待執行的由計時器註冊的回調函數。

值得聲明的一點是,不管是在 nodejs 仍是 web 瀏覽器中,全部的計時器實現都 不能保證 在到達時間閾值後回調函數必定會被當即執行,它們只能保證在到達時間閾值後,儘快 執行由計時器註冊的回調函數。

const NS_PER_SEC = 1e9
const time = process.hrtime()
// [ 1800216, 25 ]

setTimeout(() => {
  const diff = process.hrtime(time)
  // [ 1, 552 ]

  console.log(`Benchmark took ${diff[0] * NS_PER_SEC + diff[1]} nanoseconds`)
  // Benchmark took 1000000552 nanoseconds
}, 1000)
複製代碼

另外,從技術上講,poll 階段決定了 timer 回調函數的執行時機。詳情可見後文關於 poll 對 timer 的影響 的說明。

libuv 如何調度計時器

如前文所述,timer 階段對應 libuvC 函數爲 uv__run_timers(loop);。且在 uv_run 函數體中對應的核心調用邏輯以下:

int timeout;
int r;
int ran_pending;

r = uv__loop_alive(loop);
if (!r)
  uv__update_time(loop);

// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
  uv__update_time(loop);
  uv__run_timers(loop);
  // ...
}
複製代碼

在開始事件循環的一個 tick 時,老是會首先調用 uv__update_time(loop); 來更新當前事件循環 tick 的開始時間。

UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
  /* Use a fast time source if available. We only need millisecond precision. */
  loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}
複製代碼

此處 uv__hrtime 函數內部包含當前操做系統的暴露的時間相關係統調用。在此處對系統的時間調用時,可能會受到其餘其餘應用的影響。一旦更新 loop 結構體的 time 後,接着會開始執行 timer 階段的回調函數隊列。以下:

void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) {
    heap_node = heap_min(timer_heap(loop));
    if (heap_node == NULL)
      break;

    // container_of 由 preprocesser 來實現編譯前文本替換
    // https://github.com/libuv/libuv/blob/v1.35.0/src/uv-common.h#L57-L58
    handle = container_of(heap_node, uv_timer_t, heap_node);

    if (handle->timeout > loop->time)
      break;

    // http://docs.libuv.org/en/v1.x/timer.html#c.uv_timer_stop
    uv_timer_stop(handle);
    // http://docs.libuv.org/en/v1.x/timer.html#c.uv_timer_again
    uv_timer_again(handle);
    handle->timer_cb(handle);
  }
}
複製代碼

這裏值得注意的時,全部計時器在 libuv 中是以計時器回調函數的 執行時間節點(即 time + timeout,而不是計時器時間閾值) 構成的 二叉最小堆 結構來存儲。經過 二叉最小堆 的根節點來獲取時間線上最近的 timer 對應的回調函數的句柄,再經過該句柄對應的 timeout 值獲取最近的計時器的執行時間節點:

  • 當該值大於當前事件循環 tick 的開始時間時,即表示尚未到執行時機,回調函數還不該該被執行。那麼根據二叉最小堆的性質,父節點始終比子節點小,那麼根節點的時間節點都不知足執行時機的話,其餘的 timer 時間節點確定也沒有過時。此時,退出 timer 階段的回調函數執行,進入事件循環 tick 的下一階段。

  • 當該值小於當前事件循環 tick 的開始時間時,表示至少存在一個過時的計時器,那麼循環迭代計時器最小堆的根節點,並調用該計時器所對應的回調函數。每次循環迭代時都會更新最小堆的根節點爲最近時間節點的計時器。

nodejs 內置計時器

在現行 nodejs 中,有且僅有兩種計時器,其中之一就是是 setTimeout/setInterval。 在使用 setTimeout/setInterval 時,值得注意的一點是:

時間閾值的取值範圍是 1 ~ 231-1 ms,且爲整數。

nodejs/node 源碼中不管是 setTimeout 源碼實現 仍是 setInterval 源碼實現本質上都是內置類 Timeout 的實例,以下:

// Timeout values > TIMEOUT_MAX are set to 1.
const TIMEOUT_MAX = 2 ** 31 - 1

// Timer constructor function.
// The entire prototype is defined in lib/timers.js
function Timeout(callback, after, args, isRepeat, isRefed) {
  after *= 1 // Coalesce to number or NaN
  if (!(after >= 1 && after <= TIMEOUT_MAX)) {
    if (after > TIMEOUT_MAX) {
      process.emitWarning(
        `${after} does not fit into` +
          ' a 32-bit signed integer.' +
          '\nTimeout duration was set to 1.',
        'TimeoutOverflowWarning'
      )
    }
    after = 1 // Schedule on next tick, follows browser behavior
  }

  this._idleTimeout = after
  this._idlePrev = this
  this._idleNext = this
  this._idleStart = null
  // This must be set to null first to avoid function tracking
  // on the hidden class, revisit in V8 versions after 6.2
  this._onTimeout = nullv
  this._onTimeout = callback
  this._timerArgs = args
  this._repeat = isRepeat ? after : null
  this._destroyed = false

  if (isRefed) incRefCount()
  this[kRefed] = isRefed

  initAsyncResource(this, 'Timeout')
}
複製代碼

從構造函數的函數體可見,nodejs 中全部計時器是經過一個 雙向鏈表 實現關聯,而且全部超出時間閾值範圍的時間閾值都會被 重置爲 1ms,且全部非整數值會被轉換爲 整數值

那麼一種常見的寫法 setTimeout(callback, 0) 會被 nodejs 內部模塊轉換爲 setTimeout(callback, 1) 來執行。

pending callbacks

pending callbacks 階段用於執行先前事件循環 tick 中延遲執行的 I/O 回調函數。

poll 階段

poll 階段的首要職責是:

  1. 計算因處理 I/O 須要阻塞當前事件循環 tick 的時間;該阻塞表示當前事件循環 tick 應該在當前 poll 階段停留多久,這個時間通常是根據最小的 setTimeout/setInterval 的時間閾值等多個因素(見下文)來肯定。在到達阻塞時間後,會經歷當前事件循環 tick 的後續階段,並最終進入下一個事件循環 ticktimer 階段,此時,過時的計時器的回調函數得以執行。

  2. 處理事件回調。

如前文概述,nodejspoll 階段對應 libuv 中的 核心邏輯 以下:

timeout = 0;
/** * uv_backend_timeout 用於獲取 poll 階段的超時(阻塞)時間 * http://docs.libuv.org/en/v1.x/loop.html#c.uv_backend_timeout */
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
  timeout = uv_backend_timeout(loop);

uv__io_poll(loop, timeout);
複製代碼

在調用 uv__io_poll 以前,首先初始化一個 timeout 變量,該變量在 loop 爲常規模式下,將經過 uv_backend_timeout(loop)定義 來肯定 poll 階段的超時時間,該超時時間也就是 nodejs 文檔 中提到的 poll 階段應該阻塞的時間,那麼肯定該阻塞時間的具體依據是什麼呢?

int uv_backend_timeout(const uv_loop_t* loop) {
  // https://github.com/libuv/libuv/blob/v1.35.0/src/uv-common.c#L521-L523
  // http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
  if (loop->stop_flag != 0)
    return 0;

  if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
    return 0;

  if (!QUEUE_EMPTY(&loop->idle_handles))
    return 0;

  if (!QUEUE_EMPTY(&loop->pending_queue))
    return 0;

  if (loop->closing_handles)
    return 0;

  return uv__next_timeout(loop);
}
複製代碼

uv_backend_timeout 函數體不難看出,該函數根據當前事件循環 tick 的部分屬性來肯定 poll 階段的阻塞時間:

  1. 當事件循環 tickuv_stop()doc 函數標記爲中止#時,返回 0,即不阻塞。

  2. 當事件循環 tick 不處於活動狀態時且不存在活動的 request 時返回 0,即不阻塞。

  3. idle 句柄隊列不爲空時,返回 0,即不阻塞。

  4. pending callbacks 的回調隊列不爲空時,返回 0,即不阻塞。

  5. 當存在 closing 句柄,即存在 close 事件回調時,返回 0,即不阻塞。

爲何返回 0 表示不阻塞,而 -1 表示無限制阻塞?

由於從 uv__io_poll 函數體可見 poll 階段實現輪詢的關鍵點在於各個系統平臺的輪詢機制。上文中 0-1 分別對應 linux 系統底層輪詢機制的輪詢參數。

linuxepoll 輪詢機制爲例,在 uv__io_poll 函數體中調用了系統底層 epoll_wait 函數來實現 libuv 的輪詢核心功能:

nfds = epoll_wait(loop->backend_fd,
                        events,
                        ARRAY_SIZE(events),
                        timeout);
複製代碼

The parameter timeout shall specify the maximum number of milliseconds that epoll_wait() shall wait for events. If the value of this parameter is 0, then epoll_wait() shall return immediately, even if no events are available, in which case the return code shall be 0. If the value of timeout is -1, then epoll_wait() shall block until either a requested event occurs or the call is interrupted.

epoll_wait 文檔可見,當 timeout 傳參爲 0 時,將當即返回,當 timeout 傳參爲 -1 時,將無限制阻塞,直到某個事件觸發或無限阻塞狀態被主動打斷。

回到 timeout 的主題上,在不知足以上不阻塞當前事件循環 tick 的前提下,由 uv__next_timeout 函數來計算最終的 poll 階段阻塞時間:

int uv__next_timeout(const uv_loop_t* loop) {
  const struct heap_node* heap_node;
  const uv_timer_t* handle;
  uint64_t diff;

  // libuv 計時器二叉最小堆的根節點爲全部計時器中距離當前時間節點最近的計時器
  heap_node = heap_min(timer_heap(loop));

  // 此處 true 條件爲無限制的阻塞當前 poll 階段
  if (heap_node == NULL)
    return -1; /* block indefinitely */

  handle = container_of(heap_node, uv_timer_t, heap_node);

  // 若最近時間節點的計時器小於等於當前事件循環 `tick` 開始的時間節點
  // 那麼不阻塞,並進入下一階段,直至進入下一 `tick` 的 `timer` 階段執行回調函數
  if (handle->timeout <= loop->time)
    return 0;

  // 如 nodejs 文檔中對 poll 階段計算阻塞時間的描述
  // 如下語句用於計算當前 poll 階段應該阻塞的時間
  diff = handle->timeout - loop->time;
  // INT_MAX 在 limits.h 頭文件中聲明
  if (diff > INT_MAX)
    diff = INT_MAX;

  return (int) diff;
}
複製代碼

從以上函數體並結合前文 對計時器的分析 不難看出,經過獲取計時器最小堆的根節點獲得距離如今最近的計時器執行節點。將該節點與當前事件循環 tick 的開始時間 loop->time 作對比:

  1. 若不存在任何計時器,那麼當前事件循環 tick 中的 poll 階段將 無限制阻塞。以實現一旦存在 I/O 回調函數加入到 poll queue 中便可當即獲得執行。

  2. 若最近計時器時間節點小於等於開始時間,則代表在計時器二叉最小堆中 至少存在一個 過時的計時器,那麼當前 poll 階段的超時時間將被設置爲 0,即表示 poll 階段不發生阻塞。這是爲了儘量快的進入下一階段,即儘量快地結束當前事件循環 tick。在進入下一事件循環 tick 時,在 timer 階段,上一 tick 中過時的計時器回調函數得以執行。

  3. 若最近計時器時間節點大於開始時間,則計算兩個計時器以前的差值,且不大於 int 類型最大值。poll 將根據此差值來阻塞當前階段,這麼作是爲了在輪詢階段,儘量快的處理異步 I/O 事件。此時咱們也能夠理解爲 事件循環 tick 始終有一種維持在 poll 階段的傾向

由以上源碼分析,不可貴出 poll 階段的本質:

  1. 爲了儘量快的處理異步 I/O 事件,那麼事件循環 tick 總有一種維持 poll 狀態的傾向;

  2. 當前 poll 階段應該維持(阻塞)多長時間是由 後續 tick 各個階段是否存在不爲空的回調函數隊列最近的計時器時間節點 決定。若全部隊列爲空且不存在任何計時器,那麼事件循環將 無限制地維持在 poll 階段

注:由於 poll 階段的超時時間在進入 poll 階段以前計算,故當前 poll 階段中回調函數隊列中的計時器並不影響當前 poll 階段的超時時間。

poll 對 timer 的影響

Nodejs doc:

Note: Technically, the poll phase controls when timers are executed.

從技術上來講,poll 階段控制了計時器的執行時機。爲何這麼說?

首先,libuv 的事件循環是沒法再入的,而且事件循環老是有一種維持在 poll 階段的傾向,那麼在沒有知足 poll 階段的結束條件時,就沒法進入到下一個事件循環 ticktimer 階段,就沒法執行 timer queue 中到期計時器的回調函數。因此纔會存在 「poll 階段控制了計時器回調函數的執行時機」 的說法。

另外,無限制的輪詢事件和調用回調函數,會致使徹底不會清空 poll 的回調函數隊列,進而永遠都不會發生計時器的閾值檢測致使拖垮整個事件循環迭代。libuv 在其內部設定了一個依賴於系統的最大執行數。結合前文對 nodejs 內置計時器 的描述,這也是計時器沒法保證準確的執行回調函數,而是儘快的執行回調函數的緣由之一。

check 階段

該階段的設計目的是可在 poll 階段結束之時,當即調用指定代碼片斷(即函數)。若是 poll 階段進入 idle 狀態而且 setImmediate 函數存在回調函數時,那麼 poll 階段將打破無限制的等待狀態,並進入 check 階段執行 check 階段的回調函數。

check 階段的回調函數隊列中全部的回調函數都是來自 poll 階段的 setImmediate 函數。

setTimeout vs setImmediate

由前文 nodejs 內置計時器 章節可知,在現行的 nodejs 環境中,有且僅有兩種計時器,一種是 setTimeout/setInterval,另外一種是 setImmediate

setTimeout/setInterval 設計目的在於經歷一段最小時間閾值後儘快調用指定的回調函數。而 setImmediate 是做爲特殊的計時器而存在,其設計目的是給予用戶能在 poll 階段結束後(即 check 階段)可以當即執行代碼的機會,而不用在 timer 階段執行。

實踐

結合以上簡短介紹,若同時在 user code 的模塊詞法環境中直接調用 setTimeoutsetImmediate 會出現什麼樣的結果?

爲何上文提到在 nodejsuser script 是模塊詞法環境而不是全局詞法環境?

可簡單經過 console.log(this === module.exports)(而不是 global) 爲 true 值判斷。

// index.js
setTimeout(
  /* setTimeoutCallback */ () => {
    console.log('from setTimeout')
  },
  0
)

setImmediate(
  /* setImmediateCallback */ () => {
    console.log('from setImmediate')
  }
)
複製代碼

以上代碼經過 node index.js 命令調用後會出現 沒法預測的隨機 結果:

from setTimeout
from setImmediate
複製代碼

from setImmediate
from setTimeout
複製代碼

爲何會出現這樣的現象?

nodejs 腳本初始編譯運行時,nodejs 會首先以入口 JS 文件爲執行入口,那麼此時 運行中執行上下文 爲當前入口 JS 文件對應的 Script 執行上下文。

nodejs-event-loop-cycle

前文所述setTimeout(callback, 0) 實際上是被重置爲 setTimeout(callback, 1)了。那麼在首次 user script 代碼執行後,即 Script 執行上下文退出執行上下文棧後,並 開始首次 事件循環 tick[nodejs 貢獻者],在第一次進入 timer 階段時,會抽取 timer 最小堆中的節點對比當前事件循環 tick 的開始時間是否已通過了閾值 1ms

  • 若在前文 uv__run_timer(loop) 中,系統時間調用和時間比較的過程總耗時沒有超過 1ms 的話,在 timer 階段會發現沒有過時的計時器,setTimeoutCallbacks 同時也並不存在於 timer queue 中。那麼此時,將繼續執行至 poll 階段,而在 poll 階段 poll queue 隊列爲空時,檢查 check queue 隊列並不爲空。那麼繼續進入事件循環 tick 的下一階段,並清空 check queue 中由 setImmediate 註冊的 setImmediateCallback 回調函數。在經歷後續的事件循環 tick 並從新開始時,會發現先前的閾值爲 1ms 的過時計時器,此時的 setTimeoutCallback 才得以加入 timer queue 並得以在當前 timer 階段執行。

    控制檯的輸出以下:

    from setImmediate
    from setTimeout
    複製代碼
  • 若在上文源碼中,系統時間調用和時間比較的過程總耗時超過 1ms 的話,那麼會將過時計時器的 setTimeoutCallback 加入到 timer queue 中,並進入 timer queue 的調用階段。後續控制檯輸出以下:

    from setTimeout
    from setImmediate
    複製代碼

那麼從上文針對 libuvuv__run_timers 函數的分析可見,在 user script 的模塊詞法環境中直接同時調用 setTimeout(callback, 0)setImmediate(callback) 時沒法預判回調函數的調用順序的緣由總結以下相關 issue

  1. 在初始的事件循環 tick 執行時,會 首先執行第一次時間檢查

  2. timer 句柄中 timeout 存儲的是當次事件循環 tick 的開始時間加上 時間閾值(示例代碼中爲 1ms)後的時間節點。

  3. 這一次初始 timer 的時間檢查距當前事件循環 tick 的間隔可能小於 1ms 也可能大於 1ms 的閾值,這取決於時間的系統調用的耗時,而時間的系統調用又會受到操做系統的其餘應用的影響。當間隔小於 1ms 時,將在 timer 階段忽略示例代碼中的 setTimeoutCallback 執行,並先執行 setImmediateCallback 函數;反之,首先執行 setTimeoutCallback 執行。

nodejs 官網另外 描述I/O cycle 中,示例代碼的調用是可預測的,爲何?

const fs = require('fs')

fs.readFile(__dirname, () => {
  setTimeout(() => {
    console.log('from setTimeout')
  }, 1)

  setImmediate(() => {
    console.log('from setImmediate')
  })
})
複製代碼

上述示例代碼將始終輸出:

from setImmediate
from setTimeout
複製代碼

The main advantage to using setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present.

基於先前的分析,在經歷初次事件循環 tick 後,後續全部的 setTimeout/setInterval 計時器閾值檢查和調用都被先前事件循環 tickpoll 階段所阻塞。而不論根據 nodejs 仍是 libuv 的事件循環抽象結構圖仍是 uv_run 函數的源碼,而且基於事件循環 沒法再入 的前提,poll 階段的下一階段始終是 check 階段,那麼在 I/O cycle 中,全部的 timer 在當前事件循環 tick 中註冊,並首先經過包含 setImmediate 回調函數的 check 階段及其後續階段,纔會進入到下一事件循環 ticktimer 階段。以致於在執行順序上在 I/O cycle 中註冊的 setTimeout/setInterval 回調函數始終在 setImmediate 的回調函數以後執行。以上一樣說明了爲何在 nodejs 官網上 描述I/O cyclesetImmediate 的優先級高於 setTimeout

close callbacks

此階段用於執行全部的 close 事件的回調函數。如忽然經過 socket.destroy() 關閉 socket 鏈接時,close 事件將在此階段觸發。

與瀏覽器實現對比

nodejs 與瀏覽器端的 Web API 版本的事件循環最大的不一樣的是:

nodejs 中事件循環再也不是由單一個 task queuemicro-task queue 組成,而是由多個 階段 phase 的多個回調函數隊列 callbacks queues 組成一次事件循環 tick。 而且在每個單獨的階段都存在一個單獨的 回調函數 FIFO 隊列

References

相關文章
相關標籤/搜索