[NodeJs系列]Q&A之理解NodeJs中的Event Loop、Timers以及process.nextTick()

superTerrorist發表於2019-01-19

在上一篇文章理解NodeJs中的Event Loop、Timers以及process.nextTick中筆者提了幾個問題,現在針對這些問題給出我的理解,如有錯漏煩請指正。

如果你對NodeJs系列感興趣,歡迎關注前端神盾局或筆者微信(w979436427)交流討論node學習心得

poll階段什麼時候會被阻塞?

在上一篇文章中提到在poll階段會“接收新的I/O事件並且在適當時node會阻塞在這裡”,那什麼情況下會阻塞呢?阻塞多久呢?

對於這個問題,我們必須深入到libuv的原始碼,看看poll階段是怎麼實現的:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

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

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    uv__run_timers(loop);
    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);
    uv__run_check(loop);
    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;
  }

  /* The if statement lets gcc compile it to a conditional store. Avoids
   * dirtying a cache line.
   */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}
複製程式碼

從原始碼我們可以看到uv__io_poll傳入了timeout作為引數,而這個timeout就決定了poll階段阻塞的時長,明白這一點我們就可以把問題轉化成:是什麼決定的timeout的值?

再回到原始碼中,timeout的初始值為0,也就意味著poll階段之後會直接轉入check階段而不會發生阻塞。但是當(mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT這些條件成立時,timeout就由uv_backend_timeout的返回值決定。

這裡需要插播一下關於mode值的問題,根據官方文件 mode一共有三種情況:

  • UV_RUN_DEFAULT
  • UV_RUN_ONCE
  • UV_RUN_NOWAIT

這裡我們只關心UV_RUN_DEFAULT,因為Node event loop使用的是這種模式.

OK~回到問題,我們再看一下uv_backend_timeout會返回什麼?

int uv_backend_timeout(const uv_loop_t* 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);
}
複製程式碼

這是一個多步條件判斷函式,我們一個個分析:

  1. 如果event loop已(或正在)結束(呼叫了uv_stop()stop_flag != 0),timeout為0
  2. 如果沒有非同步任務需要處理,timeout為0
  3. 如果還有未處理的idle_handlespending_queuetimeout為0(對於idle_handlespending_queue分別代表什麼,筆者還沒有概念,如果後面有相應資料會及時更新)
  4. 如果還有存在未清理的資源,timeout為0
  5. 如果以上條件都不滿足,則使用uv__next_timeout處理
int uv__next_timeout(const uv_loop_t* loop) {
  const struct heap_node* heap_node;
  const uv_timer_t* handle;
  uint64_t diff;

  heap_node = heap_min((const struct heap*) &loop->timer_heap);
  if (heap_node == NULL)
    return -1; /* block indefinitely */

  handle = container_of(heap_node, uv_timer_t, heap_node);
  if (handle->timeout <= loop->time)
    return 0;

  // 這句程式碼給出了關鍵性的指導
  // 對比當前loop的時間戳
  diff = handle->timeout - loop->time;

  //不能大於最大的INT_MAX
  if (diff > INT_MAX)
    diff = INT_MAX;

  return diff;
}
複製程式碼

總結一下,event loop 滿足以下條件時,poll階段會進行阻塞:

  1. event loop 並未觸發關閉動作
  2. 還有非同步佇列沒有處理
  3. 資源已全部關閉

而阻塞的時間最長不超過給定定時器的最小閥值

為什麼在非I/O迴圈中,setTimeoutsetImmediate的執行順序是不一定的?

上文提到setTimeoutsetImmediate在非I/O迴圈中,執行順序是不一定的,比如:

setTimeout(function timeout() {
  console.log('timeout');
}, 0);

setImmediate(function immediate() {
  console.log('immediate');
});
複製程式碼
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
複製程式碼

相同程式碼,兩次執行結果卻是相反的,這是為什麼呢?

在node中,setTimeout(cb, 0) === setTimeout(cb, 1)

在event loop的第一個階段(timers階段),node都會從一堆定時器中取出一個最小閥值的定時器來與loop->time進行比較,如果閥值小於等於loop->time表示定時器已超時,相應的回撥便會執行(隨後會檢查下一個定時器),如果沒有則會進入下一個階段。

所以setTimeout是否在第一階段執行取決於loop->time的大小,這裡可能出現兩種情況:

  1. 由於第一次loop前的準備耗時超過1ms,當前的loop->time >=1 ,則uv_run_timer生效,timeout先執行

  2. 由於第一次loop前的準備耗時小於1ms,當前的loop->time < 1,則本次loop中的第一次uv_run_timer不生效,那麼io_poll後先執行uv_run_check,即immediate先執行,然後等close cb執行完後,繼續執行uv_run_timer

這就是為什麼同一段程式碼,執行結果隨機的緣故。那為什麼說在I/O回撥中,一定是先immediate執行呢,其實也很容易理解,考慮以下場景:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
複製程式碼

由於timeoutimmediate的事件註冊是在readFile的回撥執行時觸發的,所以必然的,在readFile的回撥執行前的每一次event loop進來的uv_run_timer都不會有超時事件觸發 那麼當readFile執行完畢,poll階段收到監聽的fd事件完成後,執行了該回撥,此時

  1. timeout事件註冊
  2. immediate事件註冊
  3. 由於readFile的回撥執行完畢,那麼就會從uv_io_poll中出來,此時立即執行uv_run_check,所以immediate事件被執行掉
  4. 最後的uv_run_timer檢查timeout事件,執行timeout事件

所以你會發現,在I/O回撥中註冊的兩者,永遠都是immediately先執行

JS呼叫棧被展開是什麼意思?

棧展開主要是指在丟擲異常後逐層匹配catch語句的過程,舉個例子:

function a(){
  b();
}
function b(){
  c();
}
function c(){
  throw new Error('from function c');
}

a();
複製程式碼

這個例子中,函式c丟擲異常,這是首先會在c函式本身檢查是否存在try相關的catch語句,如果沒有就退出當前函式,並且釋放當前函式的記憶體並銷燬區域性物件,繼續到b函式中查詢,這個過程就稱之為棧展開。

參考

  1. zhuanlan.zhihu.com/p/35039878
  2. cnodejs.org/topic/57d68…
  3. gngshn.github.io/2017/09/01/…
  4. docs.libuv.org/en/v1.x/des…

image

相關文章