從源碼解讀 Node 事件循環

Node 是爲構建實時 Web 應用而誕生的,可讓 JavaScript 運行在服務端的平臺。它具備事件驅動、單線程、異步 I/O 等特性。這些特性不只帶來了巨大的性能提高,有效的解決了高併發問題,還避免了多線程程序設計的複雜性。javascript

本文主要討論的是 Node 中實現異步 I/O 和事件驅動程序設計的基礎——事件循環。html

事件循環是 Node 的執行模型

事件循環是 Node 自身的執行模型,Node 經過事件循環的方式運行 JavaScript 代碼(初始化和回調),並提供了一個線程池處理諸如文件 I/O 等高成本任務。前端

在 Node 中,有兩種類型的線程:一個事件循環線程(也稱爲主循環、主線程、事件線程等),它負責任務的編排;另外一個是工做線程池中的 K 個工做線程(也被稱爲線程池),它專門處理繁重的任務。java


看到這裏,可能有同窗會有疑問,文章開頭說 Node 是單線程的,爲何又存在兩種類型的線程呢? 事實上,Node 的單線程指的是自身 JavaScript 運行環境的單線程,Node 並無給 JavaScript 執行時建立新線程的能力,最終的操做,是經過底層的 libuv 及其帶來的事件循環來執行的。這也是爲何 JavaScript 做爲單線程語言,能在 Node 中實現異步操做的緣由。二者並不衝突。 下圖展現了異步 I/O 中線程的調用模型,能夠看到,對於主線程來講,一直都是單線程執行的。 node

image
【參考文章:Node.js 探祕:初識單線程的 Node.js — 凌恆


Node 的工做線程池是在 libuv 中實現的,它對外提供了通用的任務處理 API — uv_queue_worklinux

image
工做線程池被用於處理一些高成本任務。包括一些操做系統並無提供非阻塞版本的 I/O 操做,以及一些 CPU 密集型任務,如:

  1. I/O 密集型任務
    • DNS:用於 DNS 解析的模塊,dns.lookup(), dns.lookupService()
    • 文件系統:全部文件系統 API,除了 fs.FSWatcher() 和顯式調用 API 如 fs.readFileSync() 以外
  2. CPU 密集型任務
    • Crypto:用於加密的模塊
    • Zlib:用於壓縮的模塊,除了那些顯式同步調用的 API 以外

當調用這些 API 時,會進入對應 API 與 C++ 橋接通信的 Node C++ binding 中,從而向工做線程池提交一個任務。爲了達到較高性能,處理這些任務的模塊一般都由 C/C++ 編寫。git

image

上圖描述了 Node 的運行原理,從左到右,從上到下,Node 被分爲了四層:github

  • 應用層。JavaScript 交互層,常見的就是 Node 的模塊,如 http,fs
  • V8 引擎層。利用 V8 來解析 JavaScript 語法,進而和下層 API 交互
  • Node API 層。爲上層模塊提供系統調用,和操做系統進行交互
  • libuv 層。跨平臺的底層封裝,實現了事件循環、文件操做等,是 Node 實現異步的核心。它將不一樣的任務分配給不一樣的線程,造成一個事件循環(event loop),以異步的方式將任務的執行結果返回給 V8 引擎

基於事件循環能夠構造高性能服務器

經典的服務器模型有如下幾種:web

  • 同步式。一次只能處理一個請求,其餘請求都處於等待狀態。
  • 每進程/每請求。會爲每一個請求啓動一個進程,這樣就能夠同時處理多個請求,但因爲系統資源有限,不具有擴展性。
  • 每線程/每請求。會爲每一個請求啓動一個線程,雖然線程比進程輕量,可是對於大型站點而言,依然不夠。由於每一個線程都要佔用必定內存,當大併發請求到來時,內存將會很快耗光。
  • 事件驅動。經過事件驅動的方式處理請求,無需爲每一個請求建立額外的線程,能夠省去建立和銷燬線程的開銷,同時操做系統在調度任務時由於線程較少,上下文切換的代價較低。這種模式被不少平臺所採用,如 Nginx(C)、Event Machine(Ruby)、AnyEvent(Perl)、Twisted(Python),以及本文討論的 Node。

事件驅動的實質,是經過主循環加事件觸發的方式來運行程序,這種執行模型被稱爲事件循環。經過事件驅動,能夠構建高性能服務器。數據庫

既然不少平臺都採用了事件驅動的模式,爲何 Ryan Dahl 恰恰選了 JavaScript 呢?在開發 Node 時,Ryan Dahl 曾經評估過多種語言。最終結論爲:C 的開發門檻高,能夠預見不會有太多開發者將其做爲平常的業務開發;Lua 自身已經包含不少阻塞 I/O 庫,爲其構建非阻塞 I/O 庫也沒法改變人們繼續使用阻塞 I/O 庫的習慣;Ruby 的虛擬機性能不夠高。相比之下,JavaScript 比 C 開發門檻低,比 Lua 歷史包袱少,在瀏覽器中已經有普遍的事件驅動應用,V8 引擎又具備超高性能,因而,Javascript 就成爲了 Node 的開發語言。

例如,使用 Node 進行數據庫查詢:

db.query('SELECT * from some_table', function(res) {
  res.output();
});
複製代碼

進程在執行到db.query 的時候,不會等待結果返回,而是直接繼續執行後面的語句,直到進入事件循環。當數據庫查詢結果返回時,會將事件發送到事件隊列,等到線程進入事件循環之後,纔會調用以前的回調函數繼續執行後面的邏輯。

固然,這種事件驅動開發模式的弊端也是顯而易見的,它不符合常規的線性思路,須要把一個完整的邏輯拆分爲一個個事件,增長了開發和調試的難度。

下面是多線程阻塞式 I/O 和單線程事件驅動的異步式 I/O 的對比:

image

【表格來自於 《Node.js 開發指南》 — byvoid】

基於事件循環能夠實現異步任務調度

事件循環的使用場景能夠分爲異步 I/O 和非 I/O 的異步操做兩種。

異步 I/O 的主旨是使 I/O 操做與 CPU 操做分離,從而非阻塞的調用底層接口。如前所述,常見的使用場景有網絡通訊、磁盤 I/O、數據庫訪問等。固然,Node 也提供了部分同步 I/O 方式,如fs.readFileSync,但 Node 並不推薦用戶使用它們。

非 I/O 的異步操做有定時器,如 setTimeoutsetInterval,以及 process.nextTicksetImmediatepromise

setTimeoutsetIntervalpromise 與瀏覽器中的 API 一致,在此再也不贅述。

process.nextTick 的功能是爲事件循環設置一項任務, Node 會在下一輪事件循環時調用 callback。

爲何不能在當前循環執行完這項任務,而要交給下次事件循環呢?咱們知道,一個 Node 進程只有一個主線程,在任什麼時候刻都只有一個事件在執行。若是這個事件佔用大量 CPU 時間,事件循環中的下一個事件就要等待好久。使用 process.nextTick() 能夠把複雜的工做拆散,變成一個個較小的事件。例如:

function doSomething(args, callback) {
  somethingComplicated(args);
  process.nextTick(callback);
}
doSomething(function onEnd() {
  compute();
});
複製代碼

假設 compute()somethingComplicated() 是兩個較爲耗時的函數,調用 doSomething() 時會先執行 somethingComplicated(),若是不使用 process.nextTick,會當即調用回調函數,在 onEnd() 中會執行 compute(),從而會佔用較長 CPU 時間,阻塞其餘事件的處理。而經過 process.nextTick 會把上面耗時的操做拆分至兩次事件循環,減小了每一個事件的執行時間,避免阻塞其餘事件。

另外,須要注意的是,雖然定時器也能將任務拆分至下一次事件循環處理,但並不建議用其代替 process.nextTick(fn),由於定時器的處理涉及到最小堆操做,時間複雜度爲 O(lg(n)),而 process.nextTick 只是把回調函數放入隊列之中,時間複雜度爲 O(1),更加高效。

setImmediate()process.nextTick() 相似,也是將回調函數延遲執行。不過 process.nextTick 會先於 setImmediate 執行。由於 process.nextTick 屬於 microtask,會在事件循環之初就執行;而 setImmediate 在事件循環的 check 階段纔會執行。這部分將在下一小節詳述。

事件循環的執行機制

Node 中的事件循環是在 libuv 中實現的,libuv 在 Node 中的地位以下圖:

image

【圖片來自《深刻淺出 Node.js》 — 樸靈】

Node 不是一個從零開始開發的 JavaScript 運行時,它是「站在巨人肩膀上」進行一系列拼湊和封裝獲得的結果。V8(Chrome V8)是 Node 的 JavaScript 引擎,由谷歌開源,以 C++ 編寫,具備高性能和跨平臺的特性,同時也用於 Chrome 瀏覽器。libuv 是專一於異步 I/O 的跨平臺類庫,實際上它主要就是爲 Node 開發的。基於不一樣平臺的異步機制,如 epoll / kqueue / IOCP / event ports,libuv 實現了跨平臺的事件循環。做爲一個在操做系統之上的中間層,libuv 使開發者不用本身管理線程就能輕鬆的實現異步。

下圖是官方文檔中給出的 libuv 結構圖:

image

能夠看出,除了事件循環外,libuv 還提供了計時器、網絡操做、文件操做、子進程等功能。

在 Node 中,就是直接使用 libuv 中的事件循環:

github.com/nodejs/node…

image

下面是 libuv 中事件循環的詳細流程:

image

如上圖所示,libuv 中的事件循環主要有 7 個階段,它們按照執行順序依次爲:

  • timers 階段:這個階段執行 setTimeoutsetInterval 預約的回調函數;
  • pending callbacks 階段:這個階段會執行除了 close 事件回調、被 timers 設定的回調、setImmediate 設定的回調以外的回調函數;
  • idle、prepare 階段:供 node 內部使用;
  • poll 階段:獲取新的 I/O 事件,在某些條件下 node 將阻塞在這裏;
  • check 階段:執行 setImmediate 設定的回調函數;
  • close callbacks 階段:執行 socket.on('close', ...) 之類的回調函數

除了 libuv 中的七個階段外,Node 中還有一個特殊的階段,它通常被稱爲 microtask,它由 V8 實現,被 Node 調用。包括了 process.nextTickPromise.resolve 等微任務,它們會在 libuv 的七個階段以前執行,並且 process.nextTick 的優先級高於 Promise.resolve。值得注意的是,在瀏覽器環境下,咱們常說事件循環中包括宏任務(macrotask 或 task)和微任務(microtask),這兩個概念是在 HTML 規範中制定,由瀏覽器廠商各自實現的。而在 Node 環境中,是沒有宏任務這個概念的,至於前面所說的微任務,則是由 V8 實現,被 Node 調用的;雖然名字相同,但瀏覽器中的微任務和 Node 中的微任務實際上不是一個東西,固然,不排除它們間有相互借鑑的成分。

讓咱們經過 libuv 中控制事件循環的核心代碼,近距離觀察這幾個階段。在 libuv v1.x 版本中,事件循環的核心函數 uv_run() 分別在 src/unix/core.csrc/win/core.c 中:

github.com/libuv/libuv…

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);  // timers 階段
    ran_pending = uv__run_pending(loop);  // pending 階段
    uv__run_idle(loop);  // idle 階段
    uv__run_prepare(loop);  // prepare 階段

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

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

    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;
}
複製代碼

從上述代碼中能夠清楚的看到 timers、pending、idle、prepare、poll、check、close 這七個階段的調用。下面,讓咱們詳細看看這幾個階段。

timers 階段

github.com/libuv/libuv…

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;

    handle = container_of(heap_node, uv_timer_t, heap_node);
    if (handle->timeout > loop->time)  // 若是遇到第一個還未到觸發時間的事件回調,退出循環
      break;

    uv_timer_stop(handle);
    uv_timer_again(handle);
    handle->timer_cb(handle);
  }
}
複製代碼

能夠看出,timers 階段使用的數據結構是最小堆。這個階段會在事件循環的一個 tick 中不斷循環,把超時時間和當前的循環時間(loop -> time)進行比較,執行全部到期回調;若是遇到第一個還未到期的回調,則退出循環,再也不執行 timers queue 後面的回調。

這裏爲何用最小堆而不用隊列?由於 timeout 回調須要按照超時時間的順序來調用,而不是先進先出的隊列邏輯。因此這裏用了最小堆。

pending 階段

github.com/libuv/libuv…

static int uv__run_pending(uv_loop_t* loop) {
  QUEUE* q;
  QUEUE pq;
  uv__io_t* w;

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

  QUEUE_MOVE(&loop->pending_queue, &pq);

  while (!QUEUE_EMPTY(&pq)) {
    q = QUEUE_HEAD(&pq);
    QUEUE_REMOVE(q);
    QUEUE_INIT(q);
    w = QUEUE_DATA(q, uv__io_t, pending_queue);
    w->cb(loop, w, POLLOUT);
  }

  return 1;
}
複製代碼

這裏使用的是隊列。一些應該在上輪循環 poll 階段執行的回調,若是由於某些緣由不能執行,就會被延遲到這一輪循環的 pending 階段執行。也就是說,這個階段執行的回調都是上一輪殘留的。

idle、prepare、check 階段

這三個階段都由同一個函數定義

github.com/libuv/libuv…

void uv__run_##name(uv_loop_t* loop) { \ uv_##name##_t* h; \ QUEUE queue; \ QUEUE* q; \ QUEUE_MOVE(&loop->name##_handles, &queue); \ while (!QUEUE_EMPTY(&queue)) { \ q = QUEUE_HEAD(&queue); \ h = QUEUE_DATA(q, uv_##name##_t, queue); \ QUEUE_REMOVE(q); \ QUEUE_INSERT_TAIL(&loop->name##_handles, q); \ h->name##_cb(h); \ } \ }
複製代碼

這裏用了宏以實現代碼的複用,但同時也下降了可讀性。這部分的邏輯和 pending 階段很像,遍歷隊列,執行回調,直至隊列爲空。

poll 階段

github.com/libuv/libuv…

poll 階段較爲複雜,一共有 400+ 行代碼,這裏只截取部分,完整邏輯請自行查看源碼。

void uv__io_poll(uv_loop_t* loop, int timeout) {
	// ...
	// 處理觀察者隊列
  while (!QUEUE_EMPTY(&loop->watcher_queue)) {
    // ...
    if (w->events == 0)
      op = EPOLL_CTL_ADD;  // 新增監聽事件
    else
      op = EPOLL_CTL_MOD;  // 修改事件
	
	// ...
  for (;;) {
    /* See the comment for max_safe_timeout for an explanation of why * this is necessary. Executive summary: kernel bug workaround. */
		// 計算好 timeout 以防 uv_loop 一直阻塞
    if (sizeof(int32_t) == sizeof(long) && timeout >= max_safe_timeout)
      timeout = max_safe_timeout;

    nfds = epoll_pwait(loop->backend_fd,
                       events,
                       ARRAY_SIZE(events),
                       timeout,
                       psigset);

    /* 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);

      if (timeout == 0)
        return;

      /* We may have been inside the system call for longer than |timeout| * milliseconds so we need to update the timestamp to avoid drift. */
      goto update_timeout;
    }

    if (nfds == -1) {
      if (errno != EINTR)
        abort();

      if (timeout == -1)
        continue;

      if (timeout == 0)
        return;

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

    have_signals = 0;
    nevents = 0;

    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++) {
      pe = events + i;
      fd = pe->data.fd;

      /* Skip invalidated events, see uv__platform_invalidate_fd */
      if (fd == -1)
        continue;

      assert(fd >= 0);
      assert((unsigned) fd < loop->nwatchers);

      w = loop->watchers[fd];

      if (w == NULL) {
        epoll_ctl(loop->backend_fd, EPOLL_CTL_DEL, fd, pe);
        continue;
      }
      pe->events &= w->pevents | POLLERR | POLLHUP;
      if (pe->events == POLLERR || pe->events == POLLHUP)
        pe->events |=
          w->pevents & (POLLIN | POLLOUT | UV__POLLRDHUP | UV__POLLPRI);

      if (pe->events != 0) {
        /* Run signal watchers last. This also affects child process watchers * because those are implemented in terms of signal watchers. */
        if (w == &loop->signal_io_watcher)
          have_signals = 1;
        else
          w->cb(loop, w, pe->events);

        nevents++;
      }
    }
		// ...
}
複製代碼

poll 階段的任務是阻塞以等待監聽事件的來臨,而後執行對應的回調。其中阻塞是有超時時間,在某些條件下超時時間會被置爲 0。此時,就會進入下一階段,而本 poll 階段未執行的回調會在下一循環的 pending 階段執行。

close 階段

github.com/libuv/libuv…

static void uv__run_closing_handles(uv_loop_t* loop) {
  uv_handle_t* p;
  uv_handle_t* q;

  p = loop->closing_handles;
  loop->closing_handles = NULL;

  while (p) {
    q = p->next_closing;
    uv__finish_close(p);
    p = q;
  }
}
複製代碼

close 階段的邏輯很是簡單,就是循環關閉全部的 closing handles,其中的回調被 uv__finish_close 調用。

上面即是 libuv 關於事件循環七個階段的簡單源碼解讀。以前咱們還提到,在 microtask 中,存在着 process.nextTickPromise.resolve 等階段。這部分已經超出了 libuv 的範圍,咱們能夠在 node 源碼中找到它們的調用路徑。

process.nextTick

github.com/nodejs/node…

const {
  // For easy access to the nextTick state in the C++ land,
  // and to avoid unnecessary calls into JS land.
  tickInfo,
  // Used to run V8's micro task queue.
  runMicrotasks,
  setTickCallback,
  enqueueMicrotask
} = internalBinding('task_queue');
// ...
// Should be in sync with RunNextTicksNative in node_task_queue.cc
function runNextTicks() {
  if (!hasTickScheduled() && !hasRejectionToWarn())
    runMicrotasks();
  if (!hasTickScheduled() && !hasRejectionToWarn())
    return;

  processTicksAndRejections();
}
// ...
function nextTick(callback) {
  if (typeof callback !== 'function')
    throw new ERR_INVALID_CALLBACK(callback);

  if (process._exiting)
    return;

  var args;
  switch (arguments.length) {
    case 1: break;
    case 2: args = [arguments[1]]; break;
    case 3: args = [arguments[1], arguments[2]]; break;
    case 4: args = [arguments[1], arguments[2], arguments[3]]; break;
    default:
      args = new Array(arguments.length - 1);
      for (var i = 1; i < arguments.length; i++)
        args[i - 1] = arguments[i];
  }

  if (queue.isEmpty())
    setHasTickScheduled(true);
  queue.push(new TickObject(callback, args));
}
複製代碼

能夠看到,nextTick 就是向 queue 隊列壓入 callback,而後 nextTick 調用後獲得的隊列被 runNextTick 使用,觸發 runMicrotasks 函數,這個函數經過 internalBiding 綁定至 node_task_queue.cc 中的同名函數。最終,會觸發 V8 中的 microtask 隊列處理。

同理,promise 的相應調用路徑能夠從 github.com/nodejs/node… 中追蹤獲得,篇幅有限,就再也不贅述了。

瀏覽器事件循環 vs Node 事件循環

瀏覽器中的事件循環是 HTML 規範中制定的,由不一樣瀏覽器廠商自行實現;而 Node 中則由 libuv 庫實現。所以,瀏覽器和 Node 中的事件循環在實現原理和執行流程上都存在差別。

瀏覽器環境

在瀏覽器中,JavaScript 執行爲單線程(不考慮 web worker),全部代碼均在主線程調用棧完成執行。當主線程任務清空後纔會去輪循任務隊列中的任務。

異步任務分爲 task(宏任務,也能夠被稱爲 macrotask)和 microtask(微任務)兩類。關於事件循環的權威定義能夠在 HTML 規範文檔中查到:html.spec.whatwg.org/multipage/w…

當知足執行條件時,task 和 microtask 會被放入各自的隊列中,等待進入主線程執行,這兩個隊列被稱爲 task queue(或 macrotask queue)和 microtask queue。

  • task:包括 script 中的代碼、setTimeoutsetIntervalI/O、UI render
  • microtask:包括 promiseObject.observeMutationObserver

不過,正如規範強調的,這裏的 task queue 並不是是隊列,而是集合(sets),由於事件循環的執行規則是執行第一個可執行的任務,而不是將第一個任務出隊並執行。

詳細的執行規則能夠在 html.spec.whatwg.org/multipage/w… 查詢,一共有 15 個步驟。

能夠將執行步驟不嚴謹的概括爲:

  1. 執行完主線程中的任務
  2. 清空 microtask queue 中的任務並執行完畢
  3. 取出 macrotask queue 中的一個任務執行
  4. 清空 microtask queue 中的任務並執行完畢
  5. 重複 三、4

進一步概括,就是:一個宏任務,全部微任務;一個宏任務,全部微任務...

Node 環境

Node 中的事件循環流程已經在前面詳述(參見_事件循環的執行機制_一節),這裏就再也不贅述了。一圖以蔽之:

image

下面,讓咱們看一些容易產生誤解的狀況:

process.nextTick 形成的 starve 現象

const fs = require('fs');

function addNextTickRecurs(count) {
  let self = this;
  if (self.id === undefined) {
    self.id = 0;
  }

  if (self.id === count) return;

  process.nextTick(() => {
    console.log(`process.nextTick call ${++self.id}`);
    addNextTickRecurs.call(self, count);
  });
}

addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
  console.log('omg! file read complete callback was called!');
});

console.log('started');
複製代碼

在這段代碼中,因爲遞歸調用了 process.nextTicksetTimeoutsetImmediate 以及文件 I/O 的回調將永遠不會獲得執行。由於執行 libuv 中七個階段前,會清空 microtask 中的任務。所謂的清空是指,執行完 microtask 隊列中已有的任務以後,準備執行 libuv 中的任務以前,會再次確認 microtask 中的任務是否爲空,若還有任務,會繼續執行。因爲遞歸調用了 process.nextTick,會不斷往 microtask 中添加任務,從而形成了這種其餘隊列的飢餓(starve)現象。

固然,在 Node v0.12 以前,存在 process.maxTickDepth 屬性,用於限制 process.nextTick 的執行深度。可是在 v0.12 以後,出於某些緣由,這個屬性被移除了。此後只能建議開發者避免寫出這種代碼。

執行結果爲:

started
process.nextTick call 1
process.nextTick call 2
process.nextTick call 3
...
複製代碼

setTimeout vs setImmediate

setTimeoutsetImmediate 的回調哪一個會先執行呢?有同窗可能會說,我知道啊,setTimeout 屬於 timers 階段,setImmediate 屬於 check 階段,因此會先執行 setTimeout。錯~,正確答案是,咱們沒法保證它們的前後順序。

setTimeout(function() {
  console.log('setTimeout')
}, 0);
setImmediate(function() {
  console.log('setImmediate')
});
複製代碼

屢次執行這段代碼,能夠看到,咱們會獲得兩種不一樣的輸出結果。

這是由 setTimeout 的執行特性致使的,setTimeout 中的回調會在超時時間後被執行,可是具體的執行時間卻不是肯定的,即便設置的超時時間爲 0。因此,當事件循環啓動時,定時任務可能還沒有進入隊列,因而,setTimeout 被跳過,轉而執行了 check 階段的任務。

換句話說,這種狀況下,setTimeousetImmediate 不必定處於同一個循環內,因此它們的執行順序是不肯定的。

事情到這裏並無結束:

const fs = require('fs');

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

對於這種狀況,immediate 將會永遠先於 timeout 輸出。

讓咱們捋一遍這段代碼的執行過程:

  1. 執行 fs.readFile,開始文件 I/O
  2. 事件循環啓動
  3. 文件讀取完畢,相應的回調會被加入事件循環中的 I/O 隊列
  4. 事件循環執行到 pending 階段,執行 I/O 隊列中的任務
  5. 回調函數執行過程當中,定時器被加入 timers 最小堆中,setImmediate 的回調被加入 immediates 隊列中
  6. 當前事件循環處於 pending 階段,接下來會繼續執行,到達 check 階段。這是,發現 immediates 隊列中存在任務,從而執行 setImmediate 註冊的回調函數
  7. 本輪事件循環執行完畢,進入下一輪,在 timers 階段執行 setTimeout 註冊的回調函數

promise vs process.nextTick

promiseprocess.nextTick 組合使用的狀況比較好理解,nextTick 會優於 promise 執行,microtask 會優於 7 個階段執行,在執行 7 個階段前,會進一步確認 microtask 隊列是否爲空。例如:

Promise.resolve().then(() => console.log('promise1 resolved'));
Promise.resolve().then(() => console.log('promise2 resolved'));
Promise.resolve().then(() => {
    console.log('promise3 resolved');
    process.nextTick(() => console.log('next tick inside promise resolve handler'));
});
Promise.resolve().then(() => console.log('promise4 resolved'));
Promise.resolve().then(() => console.log('promise5 resolved'));
setImmediate(() => console.log('set immediate1'));
setImmediate(() => console.log('set immediate2'));

process.nextTick(() => console.log('next tick1'));
process.nextTick(() => console.log('next tick2'));
process.nextTick(() => console.log('next tick3'));

setTimeout(() => console.log('set timeout'), 0);
setImmediate(() => console.log('set immediate3'));
setImmediate(() => console.log('set immediate4'));
複製代碼

執行結果將爲:

next tick1
next tick2
next tick3
promise1 resolved
promise2 resolved
promise3 resolved
promise4 resolved
promise5 resolved
next tick inside promise resolve handler
set timeout
set immediate1
set immediate2
set immediate3
set immediate4
複製代碼

總結

本文介紹了 Node 中事件循環的做用、執行機制以及與瀏覽器中事件循環的區別。事件循環是事件驅動編程模式的基礎,經過事件驅動模式,能夠構建異步非阻塞的高性能服務器,很是適合 I/O 密集型 web 應用。在 Node 中,事件循環是由 libuv 實現的,uv_run() 函數中定義了事件循環的七個階段。在 HTML 規範中,一樣也對事件循環作了定義,並由各個瀏覽器廠商各自實現,實現原理和運行機制都與 Node 中的事件循環有必定的區別。同時,因爲 Node 是在不斷迭代的,目前最新已經到了 v12.6.0 版本,不一樣版本間也會存在必定差別,因此本文也沒法涵蓋關於事件循環的全部內容。當咱們討論關於事件循環的具體問題時,可能會發現許多與以前經驗不符的現象。對於這些問題,首先要肯定 Node 版本;而後,多動手實驗、多看源碼、多讀規範,造成本身的正確認識。

關注我

因爲本人水平有限,若有紕漏或建議,歡迎留言。若是以爲不錯,歡迎點贊和關注「海致前端」公衆號。我會保持一週一篇乾貨分享,歡迎你來一塊兒交流。感謝你的閱讀,讓咱們一塊兒進步。

相關文章
相關標籤/搜索