閱讀原文
瀏覽器中的事件輪詢
JavaScript 是一門單執行緒語言,之所以說是單執行緒,是因為在瀏覽器中,如果是多執行緒,並且兩個執行緒同時操作了同一個 Dom 元素,那最後的結果會出現問題。所以,JavaScript 是單執行緒的,但是如果完全由上至下的一行一行執行程式碼,假如一個程式碼塊執行了很長的時間,後面必須要等待當前執行完畢,這樣的效率是非常低的,所以有了非同步的概念,確切的說,JavaScript 的主執行緒是單執行緒的,但是也有其他的執行緒去幫我們實現非同步操作,比如定時器執行緒、事件執行緒、Ajax 執行緒。
在瀏覽器中執行 JavaScript 有兩個區域,一個是我們平時所說的同步程式碼執行,是在棧中執行,原則是先進後出,而在執行非同步程式碼的時候分為兩個佇列,macro-task
(宏任務)和 micro-task
(微任務),遵循先進先出的原則。
// 作用域鏈
function one() {
console.log(1);
function two() {
console.log(2);
function three() {
console.log(3);
}
three();
}
two();
}
one();
// 1
// 2
// 3
上面的程式碼都是同步的程式碼,在執行的時候先將全域性作用域放入棧中,執行全域性作用域中的程式碼,解析了函式 one
,當執行函式呼叫 one()
的時候將 one
的作用域放入棧中,執行 one
中的程式碼,列印了 1
,解析了 two
,執行 two()
,將 two
放入棧中,執行 two
,列印了 2
,解析了 three
,執行了 three()
,將 three
放入棧中,執行 three
,列印了 3
。
在函式執行完釋放的過程中,因為全域性作用域中有 one
正在執行,one
中有 two
正在執行,two
中有 three
正在執行,所以釋放記憶體時必須由內層向外層釋放,three
執行後釋放,此時 three
不再佔用 two
的執行環境,將 two
釋放,two
不再佔用 one
的執行環境,將 one
釋放,one
不再佔用全域性作用域的執行環境,最後釋放全域性作用域,這就是在棧中執行同步程式碼時的先進後出原則,更像是一個杯子,先放進去的在最下面,需要最後取出。
而非同步佇列更像時一個管道,有兩個口,從入口進,從出口出,所以是先進先出,在宏任務佇列中代表的有 setTimeout
、setInterval
、setImmediate
、MessageChannel
,微任務的代表為 Promise 的 then
方法、MutationObserve
(已廢棄)。
案例 1
let messageChannel = new MessageChannel();
let prot2 = messageChannel.port2;
messageChannel.port1.postMessage("I love you");
console.log(1);
prot2.onmessage = function(e) {
console.log(e.data);
};
console.log(2);
// 1
// 2
// I love you
從上面案例中可以看出,MessageChannel
是宏任務,晚於同步程式碼執行。
案例 2
setTimeout(() => console.log(1), 2000);
setTimeout(() => console.log(2), 1000);
console.log(3);
// 3
// 2
// 1
上面程式碼可以看出其實 setTimeout
並不是在同步程式碼執行的時候就放入了非同步佇列,而是等待時間到達時才會放入非同步佇列,所以才會有了上面的結果。
案例 3
setImmediate(function() {
console.log("setImmediate");
});
setTimeout(function() {
console.log("setTimeout");
}, 0);
console.log(1);
// 1
// setTimeout
// setImmediate
同為宏任務,setImmediate
在 setTimeout
延遲時間為 0
時是晚於 setTimeout
被放入非同步佇列的,這裡需要注意的是 setImmediate
在瀏覽器端,到目前為止只有 IE 實現了。
上面的案例都是關於宏任務,下面我們舉一個有微任務的案例來看一看微任務和宏任務的執行機制,在瀏覽器端微任務的代表其實就是 Promise 的 then
方法。
案例 4
setTimeout(() => {
console.log("setTimeout1");
Promise.resolve().then(data => {
console.log("Promise1");
});
}, 0);
Promise.resolve().then(data => {
console.log("Promise2");
setTimeout(() => {
console.log("setTimeout2");
}, 0);
});
// Promise2
// setTimeout1
// Promise1
// setTimeout2
從上面的執行結果其實可以看出,同步程式碼在棧中執行完畢後會先去執行微任務佇列,將微任務佇列執行完畢後,會去執行宏任務佇列,宏任務佇列執行一個宏任務以後,會去看看有沒有產生新的微任務,如果有則清空微任務佇列後再執行下一個宏任務,依次輪詢,直到清空整個非同步佇列。
Node 中的事件輪詢
在 Node 中的事件輪詢機制與瀏覽器相似又不同,相似的是,同樣先在棧中執行同步程式碼,同樣是先進後出,不同的是 Node 有自己的多個處理不同問題的階段和對應的佇列,也有自己內部實現的微任務 process.nextTick
,Node 的整個事件輪詢機制是 Libuv 庫實現的。
Node 中事件輪詢的流程如下圖:
從圖中可以看出,在 Node 中有多個佇列,分別執行不同的操作,而每次在佇列切換的時候都去執行一次微任務佇列,反覆的輪詢。
案例 1
setTimeout(function() {
console.log("setTimeout");
}, 0);
setImmediate(function() {
console.log("setInmediate");
});
預設情況下 setTimeout
和 setImmediate
是不知道哪一個先執行的,順序不固定,Node 執行的時候有準備的時間,setTimeout
延遲時間設定為 0
其實是大概 4ms
,假設 Node 準備時間在 4ms
之內,開始執行輪詢,定時器沒到時間,所以輪詢到下一佇列,此時要等再次迴圈到 timer
佇列後執行定時器,所以會先執行 check
佇列的 setImmediate
。
如果 Node 執行的準備時間大於了 4ms
,因為執行同步程式碼後,定時器的回撥已經被放入 timer
佇列,所以會先執行 timer
佇列。
案例 2
setTimeout(() => {
console.log("setTimeout1");
Promise.resolve().then(() => {
console.log("Promise1");
});
}, 0);
setTimeout(() => {
console.log("setTimeout2");
}, 0);
console.log(1);
// 1
// setTimeout1
// setTimeout2
// Promise1
Node 事件輪詢中,輪詢到每一個佇列時,都會將當前佇列任務清空後,在切換下一佇列之前清空一次微任務佇列,這是與瀏覽器端不一樣的。
瀏覽器端會在宏任務佇列當中執行一個任務後插入執行微任務佇列,清空微任務佇列後,再回到宏任務佇列執行下一個宏任務。
上面案例在 Node 事件輪詢中,會將 timer
佇列清空後,在輪詢下一個佇列之前執行微任務佇列。
案例 3
setTimeout(() => {
console.log("setTimeout1");
}, 0);
setTimeout(() => {
console.log("setTimeout2");
}, 0);
Promise.resolve().then(() => {
console.log("Promise1");
});
console.log(1);
// 1
// Promise1
// setTimeout1
// setTimeout2
上面程式碼的執行過程是,先執行棧,棧執行時列印 1
,Promise.resolve()
產生微任務,棧執行完畢,從棧切換到 timer
佇列之前,執行微任務佇列,再去執行 timer
佇列。
案例 4
setImmediate(() => {
console.log("setImmediate1");
setTimeout(() => {
console.log("setTimeout1");
}, 0);
});
setTimeout(() => {
console.log("setTimeout2");
setImmediate(() => {
console.log("setImmediate2");
});
}, 0);
//結果1
// setImmediate1
// setTimeout2
// setTimeout1
// setImmediate2
// 結果2
// setTimeout2
// setImmediate1
// setImmediate2
// setTimeout1
setImmediate
和 setTimeout
執行順序不固定,假設 check
佇列先執行,會執行 setImmediate
列印 setImmediate1
,將遇到的定時器放入 timer
佇列,輪詢到 timer
佇列,因為在棧中執行同步程式碼已經在 timer
佇列放入了一個定時器,所以按先後順序執行兩個 setTimeout
,執行第一個定時器列印 setTimeout2
,將遇到的 setImmediate
放入 check
佇列,執行第二個定時器列印 setTimeout1
,再次輪詢到 check
佇列執行新加入的 setImmediate
,列印 setImmediate2
,產生結果 1
。
假設 timer
佇列先執行,會執行 setTimeout
列印 setTimeout2
,將遇到的 setImmediate
放入 check
佇列,輪詢到 check
佇列,因為在棧中執行同步程式碼已經在 check
佇列放入了一個 setImmediate
,所以按先後順序執行兩個 setImmediate
,執行第一個 setImmediate
列印 setImmediate1
,將遇到的 setTimeout
放入 timer
佇列,執行第二個 setImmediate
列印 setImmediate2
,再次輪詢到 timer
佇列執行新加入的 setTimeout
,列印 setTimeout1
,產生結果 2
。
案例 5
setImmediate(() => {
console.log("setImmediate1");
setTimeout(() => {
console.log("setTimeout1");
}, 0);
});
setTimeout(() => {
process.nextTick(() => console.log("nextTick"));
console.log("setTimeout2");
setImmediate(() => {
console.log("setImmediate2");
});
}, 0);
//結果1
// setImmediate1
// setTimeout2
// setTimeout1
// nextTick
// setImmediate2
// 結果2
// setTimeout2
// nextTick
// setImmediate1
// setImmediate2
// setTimeout1
這與上面一個案例類似,不同的是在 setTimeout
執行的時候產生了一個微任務 nextTick
,我們只要知道,在 Node 事件輪詢中,在切換佇列時要先去執行微任務佇列,無論是 check
佇列先執行,還是 timer
佇列先執行,都會很容易分析出上面的兩個結果。
案例 6
const fs = require("fs");
fs.readFile("./.gitignore", "utf8", function() {
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(function() {
console.log("setImmediate");
});
});
// setImmediate
// timeout
上面案例的 setTimeout
和 setImmediate
的執行順序是固定的,前面都是不固定的,這是為什麼?
因為前面的不固定是在棧中執行同步程式碼時就遇到了 setTimeout
和 setImmediate
,因為無法判斷 Node 的準備時間,不確定準備結束定時器是否到時並加入 timer
佇列。
而上面程式碼明顯可以看出 Node 準備結束後會直接執行 poll
佇列進行檔案的讀取,在回撥中將 setTimeout
和 setImmediate
分別加入 timer
佇列和 check
佇列,Node 佇列的輪詢是有順序的,在 poll
佇列後應該先切換到 check
佇列,然後再重新輪詢到 timer
佇列,所以得到上面的結果。
案例 7
Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("nextTick"));
// nextTick
// Promise
在 Node 中有兩個微任務,Promise
的 then
方法和 process.nextTick
,從上面案例的結果我們可以看出,在微任務佇列中 process.nextTick
是優先執行的。
上面內容就是瀏覽器與 Node 在事件輪詢的規則,相信在讀完以後應該已經徹底弄清了瀏覽器的事件輪詢機制和 Node 的事件輪詢機制,並深刻的體會到了他們之間的相同和不同。