淺析 event-loop 事件輪詢

在這裏插入圖片描述


原文出自:https://www.pandashen.com


瀏覽器中的事件輪詢

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 的事件輪詢機制,並深刻的體會到了他們之間的相同和不同。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章