剖析nodejs的事件迴圈

lucefer發表於2018-06-17

本文首發在github,感興趣請點選此處

nodejs是單執行緒執行的,同時它又是基於事件驅動的非阻塞IO程式設計模型。這就使得我們不用等待非同步操作結果返回,就可以繼續往下執行程式碼。當非同步事件觸發之後,就會通知主執行緒,主執行緒執行相應事件的回撥。

以上是眾所周知的內容。今天我們從原始碼入手,分析一下nodejs的事件迴圈機制。

nodejs架構

首先,我們先看下nodejs架構,下圖所示:

剖析nodejs的事件迴圈
如上圖所示,nodejs自上而下分為

  • 使用者程式碼 ( js 程式碼 )

使用者程式碼即我們編寫的應用程式程式碼、npm包、nodejs內建的js模組等,我們日常工作中的大部分時間都是編寫這個層面的程式碼。

  • binding程式碼或者三方外掛(js 或 C/C++ 程式碼)

膠水程式碼,能夠讓js呼叫C/C++的程式碼。可以將其理解為一個橋,橋這頭是js,橋那頭是C/C++,通過這個橋可以讓js呼叫C/C++。
在nodejs裡,膠水程式碼的主要作用是把nodejs底層實現的C/C++庫暴露給js環境。
三方外掛是我們自己實現的C/C++庫,同時需要我們自己實現膠水程式碼,將js和C/C++進行橋接。

  • 底層庫

nodejs的依賴庫,包括大名鼎鼎的V8、libuv。
V8: 我們都知道,是google開發的一套高效javascript執行時,nodejs能夠高效執行 js 程式碼的很大原因主要在它。
libuv:是用C語言實現的一套非同步功能庫,nodejs高效的非同步程式設計模型很大程度上歸功於libuv的實現,而libuv則是我們今天重點要分析的。
還有一些其他的依賴庫
http-parser:負責解析http響應
openssl:加解密
c-ares:dns解析
npm:nodejs包管理器
...

關於nodejs不再過多介紹,大家可以自行查閱學習,接下來我們重點要分析的就是libuv。

libuv 架構

我們知道,nodejs實現非同步機制的核心便是libuv,libuv承擔著nodejs與檔案、網路等非同步任務的溝通橋樑,下面這張圖讓我們對libuv有個大概的印象:

剖析nodejs的事件迴圈

這是libuv官網的一張圖,很明顯,nodejs的網路I/O、檔案I/O、DNS操作、還有一些使用者程式碼都是在 libuv 工作的。 既然談到了非同步,那麼我們首先歸納下nodejs裡的非同步事件:

  • 非I/O:
    • 定時器(setTimeout,setInterval)
    • microtask(promise)
    • process.nextTick
    • setImmediate
    • DNS.lookup
  • I/O:
    • 網路I/O
    • 檔案I/O
    • 一些DNS操作
  • ...

網路I/O

對於網路I/O,各個平臺的實現機制不一樣,linux 是 epoll 模型,類 unix 是 kquene 、windows 下是高效的 IOCP 完成埠、SunOs 是 event ports,libuv 對這幾種網路I/O模型進行了封裝。

檔案I/O、非同步DNS操作

libuv內部還維護著一個預設4個執行緒的執行緒池,這些執行緒負責執行檔案I/O操作、DNS操作、使用者非同步程式碼。當 js 層傳遞給 libuv 一個操作任務時,libuv 會把這個任務加到佇列中。之後分兩種情況:

  • 1、執行緒池中的執行緒都被佔用的時候,佇列中任務就要進行排隊等待空閒執行緒。
  • 2、執行緒池中有可用執行緒時,從佇列中取出這個任務執行,執行完畢後,執行緒歸還到執行緒池,等待下個任務。同時以事件的方式通知event-loop,event-loop接收到事件執行該事件註冊的回撥函式。

當然,如果覺得4個執行緒不夠用,可以在nodejs啟動時,設定環境變數UV_THREADPOOL_SIZE來調整,出於系統效能考慮,libuv 規定可設定執行緒數不能超過128個。

nodejs原始碼

先簡要介紹下nodejs的啟動過程:

  • 1、呼叫platformInit方法 ,初始化 nodejs 的執行環境。
  • 2、呼叫 performance_node_start 方法,對 nodejs 進行效能統計。
  • 3、openssl設定的判斷。
  • 4、呼叫v8_platform.Initialize,初始化 libuv 執行緒池。
  • 5、呼叫 V8::Initialize,初始化 V8 環境。
  • 6、建立一個nodejs執行例項。
  • 7、啟動上一步建立好的例項。
  • 8、開始執行js檔案,同步程式碼執行完畢後,進入事件迴圈。
  • 9、在沒有任何可監聽的事件時,銷燬 nodejs 例項,程式執行完畢。

以上就是 nodejs 執行一個js檔案的全過程。接下來著重介紹第八個步驟,事件迴圈。

我們看幾處關鍵原始碼:

  • 1、core.c,事件迴圈執行的核心檔案。
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);
    //執行 timers 佇列
    uv__run_timers(loop);
    //執行由於上個迴圈未執行完,並被延遲到這個迴圈的I/O 回撥。
    ran_pending = uv__run_pending(loop); 
    //內部呼叫,使用者不care,忽略
    uv__run_idle(loop); 
    //內部呼叫,使用者不care,忽略
    uv__run_prepare(loop); 
    
    timeout = 0; 
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
    //計算距離下一個timer到來的時間差。
      timeout = uv_backend_timeout(loop);
   //進入 輪詢 階段,該階段輪詢I/O事件,有則執行,無則阻塞,直到超出timeout的時間。
    uv__io_poll(loop, timeout);
    //進入check階段,主要執行 setImmediate 回撥。
    uv__run_check(loop);
    //進行close階段,主要執行 **關閉** 事件
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      
      //更新當前時間戳
      uv__update_time(loop);
      //再次執行timers回撥。
      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;
}
複製程式碼
  • 2、timers 階段,原始碼檔案:timers.c
void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) {
  //取出定時器堆中超時時間最近的定時器控制程式碼
    heap_node = heap_min((struct heap*) &loop->timer_heap);
    if (heap_node == NULL)
      break;
    
    handle = container_of(heap_node, uv_timer_t, heap_node);
    // 判斷最近的一個定時器控制程式碼的超時時間是否大於當前時間,如果大於當前時間,說明還未超時,跳出迴圈。
    if (handle->timeout > loop->time)
      break;
    // 停止最近的定時器控制程式碼
    uv_timer_stop(handle);
    // 判斷定時器控制程式碼型別是否是repeat型別,如果是,重新建立一個定時器控制程式碼。
    uv_timer_again(handle);
    //執行定時器控制程式碼繫結的回撥函式
    handle->timer_cb(handle);
  }
}
複製程式碼
  • 3、 輪詢階段 原始碼,原始碼檔案:kquene.c
void uv__io_poll(uv_loop_t* loop, int timeout) {
  /*一連串的變數初始化*/
  //判斷是否有事件發生    
  if (loop->nfds == 0) {
    //判斷觀察者佇列是否為空,如果為空,則返回
    assert(QUEUE_EMPTY(&loop->watcher_queue));
    return;
  }
  
  nevents = 0;
  // 觀察者佇列不為空
  while (!QUEUE_EMPTY(&loop->watcher_queue)) {
    /*
    取出佇列頭的觀察者物件
    取出觀察者物件感興趣的事件並監聽。
    */
    ....省略一些程式碼
    w->events = w->pevents;
  }

  
  assert(timeout >= -1);
  //如果有超時時間,將當前時間賦給base變數
  base = loop->time;
  // 本輪執行監聽事件的最大數量
  count = 48; /* Benchmarks suggest this gives the best throughput. */
  //進入監聽迴圈
  for (;; nevents = 0) {
  // 有超時時間的話,初始化spec
    if (timeout != -1) {
      spec.tv_sec = timeout / 1000;
      spec.tv_nsec = (timeout % 1000) * 1000000;
    }
    
    if (pset != NULL)
      pthread_sigmask(SIG_BLOCK, pset, NULL);
    // 監聽核心事件,當有事件到來時,即返回事件的數量。
    // timeout 為監聽的超時時間,超時時間一到即返回。
    // 我們知道,timeout是傳進來得下一個timers到來的時間差,所以,在timeout時間內,event-loop會一直阻塞在此處,直到超時時間到來或者有核心事件觸發。
    nfds = kevent(loop->backend_fd,
                  events,
                  nevents,
                  events,
                  ARRAY_SIZE(events),
                  timeout == -1 ? NULL : &spec);

    if (pset != NULL)
      pthread_sigmask(SIG_UNBLOCK, pset, NULL);

    /* Update loop->time unconditionally. It's tempting to skip the update when
     * timeout == 0 (i.e. non-blocking poll) but there is no guarantee that the
     * operating system didn't reschedule our process while in the syscall.
     */
    SAVE_ERRNO(uv__update_time(loop));
    //如果核心沒有監聽到可用事件,且本次監聽有超時時間,則返回。
    if (nfds == 0) {
      assert(timeout != -1);
      return;
    }
    
    if (nfds == -1) {
      if (errno != EINTR)
        abort();

      if (timeout == 0)
        return;

      if (timeout == -1)
        continue;

      /* Interrupted by a signal. Update timeout and poll again. */
      goto update_timeout;
    }

    。。。
    //判斷事件迴圈的觀察者佇列是否為空
    assert(loop->watchers != NULL);
    loop->watchers[loop->nwatchers] = (void*) events;
    loop->watchers[loop->nwatchers + 1] = (void*) (uintptr_t) nfds;
    // 迴圈處理核心返回的事件,執行事件繫結的回撥函式
    for (i = 0; i < nfds; i++) {
        。。。。
    }
    
}
複製程式碼

uv__io_poll階段原始碼最長,邏輯最為複雜,可以做個概括,如下: 當js層程式碼註冊的事件回撥都沒有返回的時候,事件迴圈會阻塞在poll階段。看到這裡,你可能會想了,會永遠阻塞在此處嗎?

1、首先呢,在poll階段執行的時候,會傳入一個timeout超時時間,該超時時間就是poll階段的最大阻塞時間。
2、其次呢,在poll階段,timeout時間未到的時候,如果有事件返回,就執行該事件註冊的回撥函式。timeout超時時間到了,則退出poll階段,執行下一個階段。

所以,我們不用擔心事件迴圈會永遠阻塞在poll階段。

以上就是事件迴圈的兩個核心階段。限於篇幅,timers階段的其他原始碼和setImmediateprocess.nextTick的涉及到的原始碼就不羅列了,感興趣的童鞋可以看下原始碼。

最後,總結出事件迴圈的原理如下,以上你可以不care,記住下面的總結就好了。

事件迴圈原理

  • node 的初始化
    • 初始化 node 環境。
    • 執行輸入程式碼。
    • 執行 process.nextTick 回撥。
    • 執行 microtasks。
  • 進入 event-loop
    • 進入 timers 階段
      • 檢查 timer 佇列是否有到期的 timer 回撥,如果有,將到期的 timer 回撥按照 timerId 升序執行。
      • 檢查是否有 process.nextTick 任務,如果有,全部執行。
      • 檢查是否有microtask,如果有,全部執行。
      • 退出該階段。
    • 進入IO callbacks階段。
      • 檢查是否有 pending 的 I/O 回撥。如果有,執行回撥。如果沒有,退出該階段。
      • 檢查是否有 process.nextTick 任務,如果有,全部執行。
      • 檢查是否有microtask,如果有,全部執行。
      • 退出該階段。
    • 進入 idle,prepare 階段:
      • 這兩個階段與我們程式設計關係不大,暫且按下不表。
    • 進入 poll 階段
      • 首先檢查是否存在尚未完成的回撥,如果存在,那麼分兩種情況。
        • 第一種情況:
          • 如果有可用回撥(可用回撥包含到期的定時器還有一些IO事件等),執行所有可用回撥。
          • 檢查是否有 process.nextTick 回撥,如果有,全部執行。
          • 檢查是否有 microtaks,如果有,全部執行。
          • 退出該階段。
        • 第二種情況:
          • 如果沒有可用回撥。
          • 檢查是否有 immediate 回撥,如果有,退出 poll 階段。如果沒有,阻塞在此階段,等待新的事件通知。
      • 如果不存在尚未完成的回撥,退出poll階段。
    • 進入 check 階段。
      • 如果有immediate回撥,則執行所有immediate回撥。
      • 檢查是否有 process.nextTick 回撥,如果有,全部執行。
      • 檢查是否有 microtaks,如果有,全部執行。
      • 退出 check 階段
    • 進入 closing 階段。
      • 如果有immediate回撥,則執行所有immediate回撥。
      • 檢查是否有 process.nextTick 回撥,如果有,全部執行。
      • 檢查是否有 microtaks,如果有,全部執行。
      • 退出 closing 階段
    • 檢查是否有活躍的 handles(定時器、IO等事件控制程式碼)。
      • 如果有,繼續下一輪迴圈。
      • 如果沒有,結束事件迴圈,退出程式。

細心的童鞋可以發現,在事件迴圈的每一個子階段退出之前都會按順序執行如下過程:

  • 檢查是否有 process.nextTick 回撥,如果有,全部執行。
  • 檢查是否有 microtaks,如果有,全部執行。
  • 退出當前階段。

記住這個規律哦。

那麼,按照以上公式,代入網上各種有關 nodejs 事件迴圈的測試程式碼,相信你已經能夠解釋為什麼會輸出那樣的結果了。如果不能,那就私信我吧~~

相關文章