理解event loop(瀏覽器環境與nodejs環境)

騰訊IMWeb團隊發表於2018-09-29

轉自IMWeb社群,作者:sugerpocket,原文連結

眾所周知,javascript 是單執行緒的,其通過使用非同步而不阻塞主程式執行。那麼,他是如何實現的呢?本文就瀏覽器與nodejs環境下非同步實現與event loop進行相關解釋。

瀏覽器環境

瀏覽器環境下,會維護一個任務佇列,當非同步任務到達的時候加入佇列,等待事件迴圈到合適的時機執行。

實際上,js 引擎並不只維護一個任務佇列,總共有兩種任務

  1. Task(macroTask): setTimeout, setInterval, setImmediate,I/O, UI rendering
  2. microTask: Promise, process.nextTick, Object.observe, MutationObserver, MutaionObserver

那麼兩種任務的行為有何不同呢?

實驗一下,請看下段程式碼

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

var promise = new Promise(function executor(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve();
  }
  console.log(2);
}).then(function() {
  console.log(5);
});

console.log(3);
複製程式碼

輸出:

1 2 3 5 4
複製程式碼

這說明 Promise.then 註冊的任務先執行了。

我們再來看一下之前說的 Promise 註冊的任務屬於microTask,setTimeout 屬於 Task,兩者有何差別?

實際上,microTasksTasks 並不在同一個佇列裡面,他們的排程機制也不相同。比較具體的是這樣:

  1. event-loop start
  2. microTasks 佇列開始清空(執行)
  3. 檢查 Tasks 是否清空,有則跳到 4,無則跳到 6
  4. 從 Tasks 佇列抽取一個任務,執行
  5. 檢查 microTasks 是否清空,若有則跳到 2,無則跳到 3
  6. 結束 event-loop

也就是說,microTasks 佇列在一次事件迴圈裡面不止檢查一次,我們做個實驗

// 新增三個 Task
// Task 1
setTimeout(function() {
  console.log(4);
}, 0);

// Task 2
setTimeout(function() {
  console.log(6);
  // 新增 microTask
  promise.then(function() {
    console.log(8);
  });
}, 0);

// Task 3
setTimeout(function() {
  console.log(7);
}, 0);

var promise = new Promise(function executor(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve();
  }
  console.log(2);
}).then(function() {
  console.log(5);
});

console.log(3);
複製程式碼

輸出為

1 2 3 5 4 6 8 7
複製程式碼

microTasks 會在每個 Task 執行完畢之後檢查清空,而這次 event-loop 的新 task 會在下次 event-loop 檢測。

Node 環境

實際上,node.js環境下,非同步的實現根據作業系統的不同而有所差異。而不同的非同步方式處理肯定也是不相同的,其並沒有嚴格按照js單執行緒的原則,執行環境有可能會通過其他執行緒完成非同步,當然,js引擎還是單執行緒的。

node.js使用了Google的V8解析引擎和Marc Lehmann的libev。Node.js將事件驅動的I/O模型與適合該模型的程式語言(Javascript)融合在了一起。隨著node.js的日益流行,node.js需要同時支援windows, 但是libev只能在Unix環境下執行。Windows 平臺上與kqueue(FreeBSD)或者(e)poll(Linux)等核心事件通知相應的機制是IOCP。libuv提供了一個跨平臺的抽象,由平臺決定使用libev或IOCP。

關於event loop,node.js 環境下與瀏覽器環境有著巨大差異。

先來一張圖

理解event loop(瀏覽器環境與nodejs環境)

先解釋一下各個階段

  1. timers: 這個階段執行setTimeout()和setInterval()設定的回撥。
  2. I/O callbacks: 執行幾乎所有的回撥,除了close回撥,timer的回撥,和setImmediate()的回撥。
  3. idle, prepare: 僅內部使用。
  4. poll: 獲取新的I/O事件;node會在適當條件下阻塞在這裡。
  5. check: 執行setImmediate()設定的回撥。
  6. close callbacks: 執行比如socket.on('close', ...)的回撥。

每個階段的詳情

timer

一個timer指定一個下限時間而不是準確時間,在達到這個下限時間後執行回撥。在指定時間過後,timers會盡可能早地執行回撥,但系統排程或者其它回撥的執行可能會延遲它們。

注意:技術上來說,poll 階段控制 timers 什麼時候執行。

I/O callbacks 這個階段執行一些系統操作的回撥。比如TCP錯誤,如一個TCP socket在想要連線時收到ECONNREFUSED, 類unix系統會等待以報告錯誤,這就會放到 I/O callbacks 階段的佇列執行。

poll

poll 階段的功能有兩個

  • 執行 timer 階段到達時間上限的任務。
  • 執行 poll 階段的任務佇列。

如果進入 poll 階段,並且沒有 timer 階段加入的任務,將會發生以下情況

  • 如果 poll 佇列不為空的話,會執行 poll 佇列直到清空或者系統回撥數達到上限
  • 如果 poll 佇列為空
    如果設定了 setImmediate 回撥,會直接跳到 check 階段。 如果沒有設定 setImmediate 回撥,會阻塞住程式,並等待新的 poll 任務加入並立即執行。
check

這個階段在 poll 結束後立即執行,setImmediate 的回撥會在這裡執行。

一般來說,event loop 肯定會進入 poll 階段,當沒有 poll 任務時,會等待新的任務出現,但如果設定了 setImmediate,會直接執行進入下個階段而不是繼續等。

close

close 事件在這裡觸發,否則將通過 process.nextTick 觸發。

一個例子
var fs = require('fs');

function someAsyncOperation (callback) {
  // 假設這個任務要消耗 95ms
  fs.readFile('/path/to/file', callback);
}

var timeoutScheduled = Date.now();

setTimeout(function () {

  var delay = Date.now() - timeoutScheduled;

  console.log(delay + "ms have passed since I was scheduled");
}, 100);


// someAsyncOperation要消耗 95 ms 才能完成
someAsyncOperation(function () {

  var startCallback = Date.now();

  // 消耗 10ms...
  while (Date.now() - startCallback < 10) {
    ; // do nothing
  }

});
複製程式碼

當event loop進入 poll 階段,它有個空佇列(fs.readFile()尚未結束)。所以它會等待剩下的毫秒, 直到最近的timer的下限時間到了。當它等了95ms,fs.readFile()首先結束了,然後它的回撥被加到 poll 的佇列並執行——這個回撥耗時10ms。之後由於沒有其它回撥在佇列裡,所以event loop會檢視最近達到的timer的 下限時間,然後回到 timers 階段,執行timer的回撥。

所以在示例裡,回撥被設定 和 回撥執行間的間隔是105ms。

setImmediate() vs setTimeout()

現在我們應該知道兩者的不同,他們的執行階段不同,setImmediate() 在 check 階段,而settimeout 在 poll 階段執行。但,還不夠。來看一下例子。

// timeout_vs_immediate.js
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
複製程式碼

結果居然是不確定的,why?

還是直接給出解釋吧。

  1. 首先進入timer階段,如果我們的機器效能一般,那麼進入timer階段時,1毫秒可能已經過去了(setTimeout(fn, 0) 等價於setTimeout(fn, 1)),那麼setTimeout的回撥會首先執行。
  2. 如果沒到一毫秒,那麼我們可以知道,在check階段,setImmediate的回撥會先執行。

那我們再來一個

// timeout_vs_immediate.js
var fs = require('fs')

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

輸出始終為

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

這個就很好解釋了吧。 fs.readFile 的回撥執行是在 poll 階段。當 fs.readFile 回撥執行完畢之後,會直接到 check 階段,先執行 setImmediate 的回撥。

process.nextTick()

nextTick 比較特殊,它有自己的佇列,並且,獨立於event loop。 它的執行也非常特殊,無論 event loop 處於何種階段,都會在階段結束的時候清空 nextTick 佇列。

參考

juejin.im/entry/58332… jakearchibald.com/2015/tasks-… flyyang.github.io/2017/03/07/… hao5743.github.io/2017/02/27/… github.com/ccforward/c… github.com/creeperyang… developer.mozilla.org/zh-CN/docs/…

相關文章