月度文章——Event Loop

laihuamin發表於2018-10-31

前言

JS是一門單執行緒的語言,如果沒有非同步操作的話,一個很耗時的操作,就可以堵塞整個程式。而出現非同步操作之後,就會有資料通訊之間的問題,而event loop很好的解決了這個問題。

Event Loop

什麼是Event loop?這是我們第一個需要知道的問題。 在html官方標準中是這麼介紹的。

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

為了協調事件,使用者互動,指令碼執行,頁面渲染,網路請求等,使用者代理必須使用本節描述event loop。有兩種event loop,一種是browsing contexts,另一種是workers.

  • browsing contexts:基於瀏覽器上下文的event loop
  • workers:基於Web Worker中的event loop

Event Loop中的兩種任務

在標準文件中可以看到兩種task,一種就叫task,還有一種叫Microtask。

一、task

An event loop has one or more task queues. A task queue is an ordered list of tasks

規範中指出一個事件迴圈有一個或者多個任務,任務被有序的排列在佇列中。這裡我們列舉幾個典型的任務源:

  • DOM操作
  • 使用者互動(點選事件等操作)
  • 網路請求(ajax)
  • script程式碼(指令碼任務)
  • setTimeout/setInterval
  • I/O(node中)
  • setImmediate(nodejs中)

二、microtask

Each event loop has a microtask queue. A microtask is a task that is originally to be queued on the microtask queue rather than a task queue. There are two kinds of microtasks: solitary callback microtasks, and compound microtasks.

規範中也指出,每一個event loop只有一個微任務佇列,微任務通常只排列在微任務佇列上,而不是任務佇列。這裡有兩種微任務:回撥微任務和複合微任務。舉幾個典型的微任務:

  • promise
  • promise的回撥(catch和then)
  • process.nextTick(node中)
  • MutationObserver(新特性,自己沒用過)

三、event loop執行機制

在寫這個之前,先寫幾條總結出來的規律:

  • 執行順序:task > microtask
  • task一次只執行一個
  • microtask佇列清空之後才會執行下一個task

用虛擬碼表示為:

一個任務,清空微任務棧,一個任務,清空微任務棧,...

關於整個執行過程,可以參見規範第8章

四、example

// 簡稱set1
setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
// 簡稱set2
setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
    // 簡稱set3
    setTimeout(() => {
    	console.log('timer3')
    }, 0)
}, 0)

Promise.resolve().then(function() {
    console.log('promise3')
})

console.log('start')
複製程式碼
  • 執行的過程
    • 迴圈一

      1、將指令碼任務放入到task佇列。

      2、從task中取出一個任務執行,執行的結果是將set1和set2放入到task中,將promise.then放入到microtask中,輸出start。

      3、檢查microtask checkpoint,看microtask佇列中是否有任務。

      4、執行microtask中所有的任務,輸出promise3。

      5、清空microtask佇列之後,進入下一個迴圈。

    • 迴圈二

      1、從task中在取出一個set1任務,執行的結果是輸出timer1,將promise.then放入到microtask佇列中。

      2、檢查microtask checkpoint,看microtask佇列中是否有任務。

      3、執行microtask中所有的任務,輸出promise1。

      4、清空microtask佇列之後,進入下一個迴圈。

    • 迴圈三

      1、從task中在取出一個set2任務,執行的結果是輸出timer2,將promise.then放入到microtask佇列中,將set3放入到task佇列中。

      2、檢查microtask checkpoint,看microtask佇列中是否有任務。

      3、執行microtask中所有的任務,輸出promise2。

      4、清空microtask佇列之後,進入下一個迴圈。

    • 迴圈四

      1、從task中在取出一個set3任務,執行的結果是輸出timer3

      2、檢查microtask checkpoint,看microtask佇列中沒有任務,進入下一個迴圈。

    • 迴圈五

      檢測task佇列和microtask佇列都為空,WorkerGlobalScope物件中closing標誌位為true,銷燬event loop。

  • 輸出的結果
start
promise3
timer1
promise1
timer2
promise2
timer3
複製程式碼

node中的Event Loop

我們先來看一下node的架構。

nodejs架構

node的非同步是通過底層的libuv來實現的。

一、libuv是什麼

libuv enforces an asynchronous, event-driven style of programming. Its core job is to provide an event loop and callback based notifications of I/O and other activities. libuv offers core utilities like timers, non-blocking networking support, asynchronous file system access, child processes and more.

libuv使用非同步和事件驅動的程式設計風格。它的核心工作是提供一個event-loop,還有基於I/O和其它事件通知的回撥函式。libuv還提供了一些核心工具,例如定時器,非阻塞的網路支援,非同步檔案系統訪問,子程式等。

二、libuv中的event loop

在node的官方doc中,將El分成了六個階段,我們可以看一下下面的圖:

月度文章——Event Loop

當node開始執行的時候,它會初始化一個event loop,而每個event loop都包含以下六個階段:

  • timers:這個階段執行setTimeout和setInterval的回撥。
  • pending callbacks:執行被推遲到下一個iteration的 I/O 回撥。
  • idle,prepare:僅供內部使用。
  • poll:這個過程比較複雜,留到下面講。
  • check:執行setimmediation()回撥函式。
  • close callback:一些close回撥,比如socket.on('close', ...)。

每一個階段都有一個回撥的FIFO佇列,當EL執行到一個指定階段的時候,node將會執行這個佇列,當佇列中所有的回撥都執行完或者執行的回撥數上限的時候,EL會跳到下一個階段。以上所有階段不包含process.nextTick()。

整個的EL執行過程原始碼註釋版:

//deps/uv/src/unix/core.c
int uv_run(uv_loop_t *loop, uv_run_mode mode) {
	int timeout;
	int r;
	int ran_pending;
	//uv__loop_alive返回的是event loop中是否還有待處理的handle或者request
	//以及closing_handles是否為NULL,如果均沒有,則返回0
	r = uv__loop_alive(loop);
	//更新當前event loop的時間戳,單位是ms
	if (!r)
    	uv__update_time(loop);
	while (r != 0 && loop->stop_flag == 0) {
    	//使用Linux下的高精度Timer hrtime更新loop->time,即event loop的時間戳
    	uv__update_time(loop);
    	//執行判斷當前loop->time下有無到期的Timer,顯然在同一個loop裡面timer擁有最高的優先順序
    	uv__run_timers(loop);
    	//判斷當前的pending_queue是否有事件待處理,並且一次將&loop->pending_queue中的uv__io_t對應的cb全部拿出來執行
    	ran_pending = uv__run_pending(loop);
    	//實現在loop-watcher.c檔案中,一次將&loop->idle_handles中的idle_cd全部執行完畢(如果存在的話)
    	uv__run_idle(loop);
    	//實現在loop-watcher.c檔案中,一次將&loop->prepare_handles中的prepare_cb全部執行完畢(如果存在的話)
    	uv__run_prepare(loop);

    	timeout = 0;
    	//如果是UV_RUN_ONCE的模式,並且pending_queue佇列為空,或者採用UV_RUN_DEFAULT(在一個loop中處理所有事件),則將timeout引數置為
    	//最近的一個定時器的超時時間,防止在uv_io_poll中阻塞住無法進入超時的timer中
    	if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
        	timeout = uv_backend_timeout(loop);
    	//進入I/O處理的函式(重點分析的部分),此處掛載timeout是為了防止在uv_io_poll中陷入阻塞無法執行timers;並且對於mode為
    	//UV_RUN_NOWAIT型別的uv_run執行,timeout為0可以保證其立即跳出uv__io_poll,達到了非阻塞呼叫的效果
    	uv__io_poll(loop, timeout);
    	//實現在loop-watcher.c檔案中,一次將&loop->check_handles中的check_cb全部執行完畢(如果存在的話)
    	uv__run_check(loop);
    	//執行結束時的資源釋放,loop->closing_handles指標指向NULL
    	uv__run_closing_handles(loop);

    	if (mode == UV_RUN_ONCE) {
        	//如果是UV_RUN_ONCE模式,繼續更新當前event loop的時間戳
        	uv__update_time(loop);
        	//執行timers,判斷是否有已經到期的timer
        	uv__run_timers(loop);
    	}
    	r = uv__loop_alive(loop);
    	//在UV_RUN_ONCE和UV_RUN_NOWAIT模式中,跳出當前的迴圈
    	if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
        	break;
		}
		
	//標記當前的stop_flag為0,表示當前的loop執行完畢
	if (loop->stop_flag != 0)
    	loop->stop_flag = 0;
	//返回r的值
	return r;
}
複製程式碼

可以結合上面的六個過程看一下。

三、poll階段

在進入poll階段之前,會先對timeout進行處理

      timeout = 0;
    	if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
        	timeout = uv_backend_timeout(loop);
    	uv__io_poll(loop, timeout);
複製程式碼

timeout作為uv__io_poll的第二個引數,當timeout等於0的時候會跳過poll階段。

我們可以看一下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);
}
複製程式碼

以上五種情況(退出事件迴圈,沒有任何非同步任務,idle_handles和pending_queue不為空,迴圈進入到closing_handles)返回的timeout都為0。

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;

//這句程式碼給出了關鍵性的指導
  diff = handle->timeout - loop->time;

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

  return diff;
}
複製程式碼

diff代表的是,距離最近的一個非同步回撥的時間。最大是32767微秒。然後將diff作為timeout的值,傳遞給poll階段。

poll階段主要有兩個功能: 1、計算poll階段堵塞和輪詢還有多長時間。 2、處理poll階段中的事件。

當EL進入到poll階段的時候,如果程式碼中沒有設定的timers,那麼會發生以下兩種情況:

  • 如果poll佇列不是空的,將執行poll階段裡面的cb,直到cb為空,或者執行的cb達到上限。

  • 如果poll為空的情況,又會有兩種情況發生:

    • 如果程式碼中已經有setImmediate()的回撥,那麼會進入check階段,執行check階段的回撥
    • 如果程式碼中沒有setImmediate()的回撥,那麼將會阻塞在這個階段。

一旦poll階段是空的,EL會檢查是否有到期的timers,如果有一個或者多個已經到達,那麼會直接跳到timers階段執行timers的回撥。

用一張圖表示:

node-EL-poll.png

四、setImmediate() vs setTimeout()

兩者的用法是相似的,而setImmediate進入的是check階段,而setTimeout進入的是timer的階段。

而在node的docs中舉了個例子,如下:

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

setImmediate(() => {
  console.log('immediate');
});
複製程式碼

而在多次執行中,兩者的觸發順序不一定相同:

$ node timeout_vs_immediate.js
timeout
immediate

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

而將其放在i/o中執行,兩者的順序是固定的:

const fs = require('fs');

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

輸出的結果:

$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout
複製程式碼
  • 具體原因:

在node中,計時器的時間是精確到毫秒級別的,所以setTimeout(cb, 0) === setTimeout(cb, 1)。 EL初始化是需要耗時的,但是hrtime這個值精確到納秒級別,所以整個指令碼執行會發生以下兩種情況:

1、loop準備時間超過1ms,那麼loop->time >=1,就會發生uv_run_timers。 2、loop準備時間小於1ms,那麼loop->time<1,uv_run_timers不生效,就會直接到後面的check階段去。

而如果有fs的情況下,直接走的是uv__io_poll,觸發回撥之後,直接走check,在走timer階段。

五、process.nextTick()

process.nextTick()在node中不參與任何階段,但是每當切換階段的時候,需要清空process.nextTick()佇列中的回撥。

看一個例子:

var fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
    process.nextTick(()=>{
      console.log('nextTick3');
    })
  });
  process.nextTick(()=>{
    console.log('nextTick1');
  })
  process.nextTick(()=>{
    console.log('nextTick2');
  })
});
複製程式碼

輸出結果:

nextTick1
nextTick2
setImmediate
nextTick3
setTimeout
複製程式碼

整個迴圈過程: 迴圈一:

1、進來的時候,直接進入poll階段,執行回撥。

2、掛載setTimeout,掛載setImmediate,將process.nextTick推進nextTick佇列中

3、先執行nextTick佇列,輸出nextTick1和nextTick2。

4、進入check階段,執行setImmediate回撥,輸出setImmediate。

5、在執行nextTick佇列,輸出nextTick3。

迴圈二:

1、進入timer階段,有到期的定時器,輸出setTimeout。

參考博文

The Node.js Event Loop, Timers, and process.nextTick()

html官方標準

Node.js Event Loop 的理解 Timers,process.nextTick()

相關文章