深入分析Node.js事件迴圈與訊息佇列

小平果118發表於2019-03-04

多數的網站不需要大量計算,程式花費的時間主要集中在磁碟 I/O 和網路 I/O 上面

SSD讀取很快,但和CPU處理指令的速度比起來也不在一個數量級上,而且網路上一個資料包來回的時間更慢:

深入分析Node.js事件迴圈與訊息佇列

一個資料包來回的延遲平均320ms(我網速慢,ping國內網站會更快),這段時間內一個普通 cpu 執行幾千萬個週期應該沒問題

因此非同步IO就要發揮作用了,比如用多執行緒,如果用 Java 去讀一個檔案,這是一個阻塞的操作,在等待資料返回的過程中什麼也幹不了,因此就開一個新的執行緒來處理檔案讀取,讀取操作結束後再去通知主執行緒。

這樣雖然行得通,但是程式碼寫起來比較麻煩。像 Node.js V8 這種無法開一個執行緒的怎麼辦?

1. 什麼是Node.js程式

我們可以先默默地回答下下面的9個問題,是否都清楚呢?

深入分析Node.js事件迴圈與訊息佇列

1.1 非同步IO

非同步IO是指作業系統提供的IO(資料進出)的能力,比如鍵盤輸入,對應到顯示器上會有專門的資料輸出介面,這就是我們生活中可見的IO能力;這個介面在向下會進入到作業系統這個層面,在作業系統中,會提供諸多的能力,比如:磁碟的讀寫,DNS的查詢,資料庫的連線啊,網路請求的處理,等等;

在不同的作業系統層面,表現的不一致。有的是非同步非阻塞的;有的是同步的阻塞的,無論如何,我們都可以看做是上層應用於下層系統之間的資料互動;上層依賴於下層,但是反過來,上層也可以對下層提供的這些能力進行改造;如果這種操作是非同步的,非阻塞的,那麼這種就是非同步非阻塞的非同步IO模型;如果是同步的阻塞的,那麼就是同步IO模型;

koa就是一個上層的web服務框架,全部由js實現,他有作業系統之間的互動,全部通過nodejs來實現;如nodejsreadFile就是一個非同步非阻塞的介面,readFileSync就是一個同步阻塞介面;到這裡上面三個問題基本回答完畢;

1.2 事件迴圈

事件迴圈是指Node.js執行非阻塞I/O操作,儘管JavaScript是單執行緒的,但由於大多數核心都是多執行緒的,node.js會盡可能將操作裝載到系統核心。因此它們可以處理在後臺執行的多個操作。當其中一個操作完成時,核心會告訴Node.js,以便node.js可以將相應的回撥新增到輪詢佇列中以最終執行。

1.3 總結

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

2. Nodejs 架構分析

說道 Nodejs 架構, 首先要直到 Nodejs 與 V8 和 libUV 的關係和作用:

  • V8: 執行 JS 的引擎. 也就是翻譯 JS. 包括我們熟悉的編譯優化, 垃圾回收等等.
  • libUV: 提供 async I/O, 提供訊息迴圈. 可見, 是作業系統 API 層的一個抽象層.

那麼 Nodejs 如何組織它們呢?

深入分析Node.js事件迴圈與訊息佇列

2.1 Application Code(JS)

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

2.2 binding程式碼

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

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

Nodejs 通過一層 C++ Binding, 把 JS 傳入 V8, V8 解析後交給 libUV 發起 asnyc I/O, 並等待訊息迴圈排程.

2.3 底層庫

nodejs的依賴庫,包括大名鼎鼎的V8、libuv。

  • V8: 我們都知道,是google開發的一套高效javascript執行時,nodejs能夠高效執行 js 程式碼的很大原因主要在它。
  • libuv:是用C語言實現的一套非同步功能庫,nodejs高效的非同步程式設計模型很大程度上歸功於libuv的實現,而libuv則是我們今天重點要分析的。

還有一些其他的依賴庫

  • http-parser:負責解析http響應
  • openssl:加解密
  • c-ares:dns解析
  • npm:nodejs包管理器

3. libuv 架構

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

深入分析Node.js事件迴圈與訊息佇列

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

3.1 非 I/O操作:

  • 定時器(setTimeout,setInterval)
  • microtask(promise)
  • process.nextTick
  • setImmediate
  • DNS.lookup

3.2 I/O操作:

  • 網路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個。

4 Nodejs 執行緒模型

node.js啟動過程可以分為以下步驟:

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檔案的全過程。接下來著重介紹第八個步驟,事件迴圈。

深入分析Node.js事件迴圈與訊息佇列

Nodejs 完全是單執行緒的. 從程式啟動後, 由主執行緒載入我們的 js 檔案(下圖中 main.js), 然後進入訊息迴圈. 可見對於 js 程式而言, 完整執行在單執行緒之中.

深入分析Node.js事件迴圈與訊息佇列

但並不是說 Node 程式只有一個執行緒. 正如 Node.js event loop workflow & lifecycle in low level 中所說:在 libUV 這一層實際上是有個執行緒池輔助完成一些工作的.

4.1 細說訊息迴圈

再來看一下 JS 中的訊息迴圈部分:

深入分析Node.js事件迴圈與訊息佇列

Nodejs 將訊息迴圈又細分為 6 個階段(官方叫做 Phase), 每個階段都會有一個類似於佇列的結構, 儲存著該階段需要處理的回撥函式. 我們來看一下這 6 個 Phase 的作用,這六個階段的核心程式碼如下:

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;
}

複製程式碼

4.2 Timer Phase

這是訊息迴圈的第一個階段, 用一個 for 迴圈處理所有 setTimeoutsetInterval 的回撥. 核心程式碼如下:

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);
  }
}

複製程式碼

這些回撥被儲存在一個最小堆(min heap) 中. 這樣引擎只需要每次判斷頭元素, 如果符合條件就拿出來執行, 直到遇到一個不符合條件或者佇列空了, 才結束 Timer Phase.

Timer Phase 中判斷某個回撥是否符合條件的方法也很簡單. 訊息迴圈每次進入 Timer Phase 的時候都會儲存一下當時的系統時間,然後只要看上述最小堆中的回撥函式設定的啟動時間是否超過進入 Timer Phase 時儲存的時間, 如果超過就拿出來執行.

此外, Nodejs 為了防止某個 Phase 任務太多, 導致後續的 Phase 發生飢餓的現象, 所以訊息迴圈的每一個迭代(iterate) 中, 每個 Phase 執行回撥都有個最大數量. 如果超過數量的話也會強行結束當前 Phase 而進入下一個 Phase. 這一條規則適用於訊息迴圈中的每一個 Phase.

4.3 Pending I/O Callback Phase

這一階段是執行你的 fs.read, socket 等 IO 操作的回撥函式, 同時也包括各種 error 的回撥.

4.4 Idle, Prepare Phase

據說是內部使用, 所以我們也不在這裡過多討論.

4.5 Poll Phase

這是整個訊息迴圈中最重要的一個 Phase, 作用是等待非同步請求和資料(原文: accepts new incoming connections (new socket establishment etc) and data (file read etc)). 說它最重要是因為它支撐了整個訊息迴圈機制.

Poll Phase 首先會執行 watch_queue 佇列中的 IO 請求, 一旦 watch_queue 佇列空, 則整個訊息迴圈就會進入 sleep , 從而等待被核心事件喚醒. 原始碼在這裡:

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階段。看到這裡,你可能會想了,會永遠阻塞在此處嗎?當然 Poll Phase 不能一直等下去.

它有著精妙的設計. 簡單來說,

  1. 它首先會判斷後面的 Check Phase 以及 Close Phase 是否還有等待處理的回撥. 如果有, 則不等待, 直接進入下一個 Phase.

  2. 如果沒有其他回撥等待執行, 它會給 epoll 這樣的方法設定一個 timeout.

可以猜一下, 這個 timeout 設定為多少合適呢? 答案就是 Timer Phase 中最近要執行的回撥啟動時間到現在的差值, 假設這個差值是 detal. 因為 Poll Phase 後面沒有等待執行的回撥了. 所以這裡最多等待 delta 時長, 如果期間有事件喚醒了訊息迴圈, 那麼就繼續下一個 Phase 的工作; 如果期間什麼都沒發生, 那麼到了 timeout 後, 訊息迴圈依然要進入後面的 Phase, 讓下一個迭代的 Timer Phase 也能夠得到執行. Nodejs 就是通過 Poll Phase, 對 IO 事件的等待和核心非同步事件的到達來驅動整個訊息迴圈的.

4.6 Check Phase

接下來是 Check Phase. 這個階段只處理 setImmediate 的回撥函式. 那麼為什麼這裡要有專門一個處理 setImmediate 的 Phase 呢? 簡單來說, 是因為 Poll Phase 階段可能設定一些回撥, 希望在 Poll Phase 後執行. 所以在 Poll Phase 後面增加了這個 Check Phase.

4.7 Close Callbacks Phase

專門處理一些 close 型別的回撥. 比如 socket.on('close', ...). 用於資源清理.

5 Node.js事件迴圈小節


  • node 的初始化

    • 初始化 node 環境。
    • 執行輸入程式碼。
    • 執行 process.nextTick 回撥。
    • 執行 microtasks(Promise)。
  • 進入 event-loop

    • 1. 進入 timers 階段

      • 檢查 timer 佇列是否有到期的 timer 回撥,如果有,將到期的 timer 回撥按照 timerId 升序執行。
      • 檢查是否有 process.nextTick 任務,如果有,全部執行。
      • 檢查是否有microtask,如果有,全部執行。
      • 退出該階段。
    • 2. 進入IO callbacks階段。

      • 檢查是否有 pending 的 I/O 回撥。如果有,執行回撥。如果沒有,退出該階段。
      • 檢查是否有 process.nextTick 任務,如果有,全部執行。
      • 檢查是否有microtask,如果有,全部執行。
      • 退出該階段。
    • 3. 進入 idle,prepare 階段:

      這兩個階段與我們程式設計關係不大,暫且按下不表。

    • 4. 進入 poll 階段

      • 首先檢查是否存在尚未完成的回撥,如果存在,那麼分兩種情況。

      第一種情況:

      • 如果有可用回撥(可用回撥包含到期的定時器還有一些IO事件等),執行所有可用回撥。
      • 檢查是否有 process.nextTick 回撥,如果有,全部執行。
      • 檢查是否有 microtaks,如果有,全部執行。
      • 退出該階段。

      第二種情況:

      • 如果沒有可用回撥。

      • 檢查是否有 immediate 回撥,如果有,退出 poll 階段。如果沒有,阻塞在此階段,等待新的事件通知。

      • 如果不存在尚未完成的回撥,退出poll階段。

    • 5. 進入 check 階段

      • 如果有immediate回撥,則執行所有immediate回撥。
      • 檢查是否有 process.nextTick 回撥,如果有,全部執行。
      • 檢查是否有 microtaks,如果有,全部執行。
      • 退出 check 階段
    • 6. 進入 closing 階段。

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

    檢查是否有活躍的 handles(定時器、IO等事件控制程式碼)

    • 如果有,繼續下一輪迴圈。
    • 如果沒有,結束事件迴圈,退出程式。

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

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

6 常見問題

6.1 process.nextTick 和 Promise

可以看到, 訊息迴圈佇列圖中並沒有涉及到 process.nextTick 以及 Promise 的回撥. 那麼這兩個回撥有什麼特殊性呢?

這個佇列先保證所有的 process.nextTick 回撥, 然後將所有的 Promise 回撥追加在後面. 最終在每個 Phase 結束的時候一次性拿出來執行.

此外, 在不同的 Phase, process.nextTick 以及 Promise 回撥的數量是受限制的. 也就是說, 如果一直往這個佇列中加入回撥, 那麼整個訊息迴圈就會被 "卡住". 我們用一張圖來看看 process.nextTick 以及 Promise:

深入分析Node.js事件迴圈與訊息佇列

6.2 setTimeout(..., 0) vs. setImmediate

setTimeout(..., 0)vs. setImmediate 到底誰快?

我們來舉個例子直觀的感受一下.這是一道經典的 FE 面試題.請問如下程式碼的輸出:

// index.js

setImmediate(() => console.log(2))
setTimeout(() => console.log(1),0)
複製程式碼

答案: 可能是 1 2, 也可能是 2 1

我們從原理的角度看看這道訊息迴圈的基礎問題.首先,Nodejs啟動,初始化環境後載入我們的JS程式碼(index.js).發生了兩件事(此時尚未進入訊息迴圈環節):setImmediate 向 Check Phase 中新增了回撥 console.log(2); setTimeout 向 Timer Phase 中新增了回撥 console.log(1)這時候, 要初始化階段完畢, 要進入 Nodejs 訊息迴圈了, 如下圖:

深入分析Node.js事件迴圈與訊息佇列

為什麼會有兩種輸出呢? 接下來一步很關鍵:

當執行到 Timer Phase 時, 會發生兩種可能. 因為每一輪迭代剛剛進入 Timer Phase 時會取系統時間儲存起來, 以 ms(毫秒) 為最小單位.

  • 如果 Timer Phase 中回撥預設的時間 > 訊息迴圈所儲存的時間, 則執行 Timer Phase 中的該回撥. 這種情況下先輸出 1, 直到 Check Phase 執行後,輸出2.總的來說, 結果是 1 2.

  • 如果執行比較快, Timer Phase 中回撥預設的時間可能剛好等於訊息迴圈所儲存的時間, 這種情況下, Timer Phase 中的回撥得不到執行, 則繼續下一個 Phase. 直到 Check Phase, 輸出 2. 然後等下一輪迭代的 Timer Phase, 這時的時間一定是滿足 Timer Phase 中回撥預設的時間 > 訊息迴圈所儲存的時間 , 所以 console.log(1) 得到執行, 輸出 1. 總的來說, 結果就是 2 1.

所以, 輸出不穩定的原因就取決於進入 Timer Phase 的時間是否和執行 setTimeout 的時間在 1ms 內. 如果把程式碼改成如下, 則一定會得到穩定的輸出:

require('fs').readFile('my-file-path.txt', () => {
 setImmediate(() => console.log(2))
 setTimeout(() => console.log(1))
});
// 2 1
複製程式碼

這是因為訊息迴圈在 Pneding I/O Phase 才向 Timer 和 Check 佇列插入回撥. 這時按照訊息迴圈的執行順序, Check 一定在 Timer 之前執行

最後,讓我們來再看一道面試題加深對Node事件環的理解:

setImmediate(() => {
    console.log('setImmediate1');
    setTimeout(() => {
        console.log('setTimeout1')
    }, 0);
});
Promise.resolve().then(res=>{
    console.log('then');
})
setTimeout(() => {
    process.nextTick(() => {
        console.log('nextTick');
    });
    console.log('setTimeout2');
    setImmediate(() => {
        console.log('setImmediate2');
    });
}, 0);
複製程式碼

這道題的輸出順序是:thensetTimeout2nextTicksetImmediate1setImmediate2setTimeout1,為什麼是這樣的順序呢?微任務nextTick的輸出是因為timers佇列切換到check佇列,setImmediate1setImmediate2連續輸出是因只有當前佇列執行完畢後才能進去下一對列。

6.3 setTimeout(..., 0) 是否可以代替 setImmediate

從效能角度講, setTimeout 的處理是在 Timer Phase, 其中 min heap 儲存了 timer 的回撥, 因此每執行一個回撥的同時都會涉及到堆調整. 而 setImmediate 僅僅是清空一個佇列. 效率自然會高很多.

再從執行時機上講. setTimeout(..., 0)setImmediate 完全屬於兩個 Phase.

7. 瀏覽器中的事件迴圈

瀏覽器中,事件環的執行機制是,先會執行棧中的內容(同步程式碼),棧中的內容執行後執行微任務,微任務清空後再執行巨集任務,先取出一個巨集任務,再去執行微任務,然後在取巨集任務清微任務這樣不停的迴圈,我們可以看下面這張圖理解一下:

深入分析Node.js事件迴圈與訊息佇列

從圖中可以看出,同步任務會進入執行棧,而非同步任務會進入任務佇列(callback queue)等待執行。一旦執行棧中的內容執行完畢,就會讀取任務佇列中等待的任務放入執行棧開始執行。(圖中缺少微任務)

那麼,我們來道面試題檢驗一下,當我們在瀏覽器中執行下面的程式碼,輸出的結果是什麼呢?

setTimeout(() => {
    console.log('setTimeout1');
    Promise.resolve().then(data => {
        console.log('then3');
    });
},1000);
Promise.resolve().then(data => {
    console.log('then1');
});
Promise.resolve().then(data => {
    console.log('then2');
    setTimeout(() => {
        console.log('setTimeout2');
    },1000);
});
console.log(2);
// 輸出結果:2 then1 then2 setTimeout1  then3  setTimeout2
複製程式碼

先執行棧中的內容,也就是同步程式碼,所以2被輸出出來; 然後清空微任務,所以依次輸出的是 then1 then2; 因程式碼是從上到下執行的,所以1s後 setTimeout1 被執行輸出; 接著再次清空微任務,then3被輸出; 最後執行輸出setTimeout2

參考:

相關文章