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

本文作者:IMWeb sugerpocket 原文出處:IMWeb社區 未經同意,禁止轉載

眾所周知,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 環境下與瀏覽器環境有著巨大差異。

先來一張圖

先解釋一下各個階段

  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 隊列。


推薦閱讀:

TAG:JavaScript | 事件驅動(Eventdriven) | Node.js |