淺析 event-loop 事件輪詢

Shen發表於2018-10-11

在這裡插入圖片描述


閱讀原文


瀏覽器中的事件輪詢

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 不再佔用全域性作用域的執行環境,最後釋放全域性作用域,這就是在棧中執行同步程式碼時的先進後出原則,更像是一個杯子,先放進去的在最下面,需要最後取出。

而非同步佇列更像時一個管道,有兩個口,從入口進,從出口出,所以是先進先出,在宏任務佇列中代表的有 setTimeoutsetIntervalsetImmediateMessageChannel,微任務的代表為 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

同為宏任務,setImmediatesetTimeout 延遲時間為 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");
});

預設情況下 setTimeoutsetImmediate 是不知道哪一個先執行的,順序不固定,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

上面程式碼的執行過程是,先執行棧,棧執行時列印 1Promise.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

setImmediatesetTimeout 執行順序不固定,假設 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

上面案例的 setTimeoutsetImmediate 的執行順序是固定的,前面都是不固定的,這是為什麼?

因為前面的不固定是在棧中執行同步程式碼時就遇到了 setTimeoutsetImmediate,因為無法判斷 Node 的準備時間,不確定準備結束定時器是否到時並加入 timer 佇列。

而上面程式碼明顯可以看出 Node 準備結束後會直接執行 poll 佇列進行檔案的讀取,在回撥中將 setTimeoutsetImmediate 分別加入 timer 佇列和 check 佇列,Node 佇列的輪詢是有順序的,在 poll 佇列後應該先切換到 check 佇列,然後再重新輪詢到 timer 佇列,所以得到上面的結果。

案例 7

Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("nextTick"));

// nextTick
// Promise

在 Node 中有兩個微任務,Promisethen 方法和 process.nextTick,從上面案例的結果我們可以看出,在微任務佇列中 process.nextTick 是優先執行的。

上面內容就是瀏覽器與 Node 在事件輪詢的規則,相信在讀完以後應該已經徹底弄清了瀏覽器的事件輪詢機制和 Node 的事件輪詢機制,並深刻的體會到了他們之間的相同和不同。


相關文章