瀏覽器和Node 中的Event Loop
前言
js與生俱來的就是單執行緒無阻塞的指令碼語言。 作為單執行緒語言,js程式碼執行時都只有一個主執行緒執行任務。
無阻塞的實現依賴於我們要談的事件迴圈。eventloop的規範是真的苦澀難懂,僅僅要理解的話,不推薦去硬啃。
程序與執行緒
一直在說js是單執行緒語言。那麼什麼是執行緒呢,對於大部分前端同學來說,可能並不是那麼清晰。推薦阮大佬的這篇文章,形象生動
首先,計算機的核心是CPU,它承擔了所有的計算任務。它就像一座工廠,時刻在執行。
程序
程序就好比工廠的車間,它代表CPU所能處理的單個任務。
任一時刻,CPU總是執行一個程序,其他程序處於非執行狀態。
即資源分配的最小單位,擁有獨立的堆疊空間和資料儲存空間
執行緒
執行緒就好比車間裡的工人。車間的空間是工人們共享的,這象徵一個程序的記憶體空間是共享的,每個執行緒都可以使用這些共享記憶體。
即程式執行的最小單位,一個程序可以包括多個執行緒。
相對於程序來說,執行緒不涉及資料空間的操作,所以切換更高效,開銷小。
js單執行緒的起源
顯然多程序可以並行處理,提升cpu的利用率。
但是js初期是作為指令碼出現的,其要與DOM進行互動,以完成對使用者的展示。
如果多程序,同時操作DOM,那麼後果就不可控了。
例如:對於同一個按鈕,不同的程序賦予了不同的顏色,到底該怎麼展示。
作為一個指令碼語言,如果使用多執行緒+鎖的話太多複雜了,所以js就是單執行緒了。
不過隨著js的發展,承載的能力越來越多,侷限於單執行緒使得js的效率等有所限制。
因此增加了web worker來執行非dom的操作。
不過該執行緒非主執行緒有一些限制、例如不能操作DOM等,也就是為了保證DOM操作的一致性,這裡就先不關注了。
我們主要關注的還是非阻塞的能力基礎,即事件迴圈。
瀏覽器中的事件迴圈
說道事件迴圈就要先說事件佇列。
在主執行緒執行時,會產生堆(heap)和棧(stack)。
堆中存的是我們宣告的object型別的資料,棧中存的是基本資料型別以及函式執行時的執行空間。
主執行緒從任務佇列中讀取事件,這個過程是迴圈不斷的,所以這種執行機制即Event Loop。
而執行非同步方法時同樣會加入事件佇列中,但是非同步事件是有差別的,差別在於執行的優先順序不同。
事件分類
因為非同步任務之間並不相同,因此他們的執行優先順序也有區別。不同的非同步任務被分為兩類:微任務(micro task)和巨集任務(macro task)。
- 以下事件屬於巨集任務:
setTimeout, setInterval, setImmediate,I/O, UI rendering
- 以下事件屬於微任務
Promise,Object.observe(已廢棄),MutationObserver(html5新特性),process.nextTick
執行棧中的程式碼(同步任務),總是在讀取"任務佇列"(非同步任務)之前執行
當前執行棧執行完畢時會立刻先處理所有微任務佇列中的事件,然後再去巨集任務佇列中取出一個事件。同一次事件迴圈中,微任務永遠在巨集任務之前執行
對於不同型別的任務執行順序如下:
- 同步程式碼執行
- event-loop start
- microTasks 佇列開始清空(執行)
- 檢查 Tasks 是否清空,有則跳到 4,無則跳到 6
- 從 Tasks 佇列抽取一個任務,執行
- 檢查 microTasks 是否清空,若有則跳到 2,無則跳到 3
- 結束 event-loop
大概流程圖如下:
不如直接看個栗子:
setTimeout(function () {
console.log(1);
});
new Promise(function(resolve,reject){
console.log(2)
resolve(3)
}).then(function(val){
console.log(val);
})
// 2 3 1
- 區分事件型別:巨集任務setTimeout,微任務.then
- 同步程式碼執行 輸出2
- 微任務佇列清空 輸出 3
- 巨集任務執行 輸出 1
下面來個稍微複雜的:
setTimeout(()=>{
console.log('A');
},0);
var obj={
func:function () {
setTimeout(function () {
console.log('B')
},0);
return new Promise(function (resolve) {
console.log('C');
resolve();
})
}
};
obj.func().then(function () {
console.log('D')
});
console.log('E');
// c,e,d,b,a
大家可以結合例子自己試下。
node中的事件迴圈機制
在node中,事件迴圈表現出的狀態與瀏覽器中大致相同。不同的是node中有一套自己的模型。
node中事件迴圈的實現是依靠的libuv引擎。
我們知道node選擇chrome v8引擎作為js直譯器,v8引擎將js程式碼分析後去呼叫對應的node api,
而這些api最後則由libuv引擎驅動,執行對應的任務,並把不同的事件放在不同的佇列中等待主執行緒執行。
因此實際上node中的事件迴圈存在於libuv引擎中。
而node 事件分為下面幾大階段:
- timers: 這個階段執行setTimeout()和setInterval()設定的回撥。
- I/O callbacks: 執行幾乎所有的回撥,除了close回撥,timer的回撥,和setImmediate()的回撥。
- idle, prepare: 僅內部使用。
- poll: 獲取新的I/O事件;node會在適當條件下阻塞在這裡,等待新的I/O。
- check: pool階段之後,執行setImmediate()設定的回撥。
- close callbacks: 執行比如socket.on('close', ...)的回撥
poll階段
值得額外關注的是poll階段
該階段有如下功能:
- 執行 timer 階段到達時間上限的的任務。
- 執行 poll 階段的任務佇列。
如果進入 poll 階段,並且沒有 timer 階段加入的任務,將會發生以下情況
- 如果 poll 佇列不為空的話,會執行 poll 佇列直到清空或者系統回撥數達到上限
- 如果 poll 佇列為空 如果設定了 setImmediate 回撥,會直接跳到 check 階段。 如果沒有設定 setImmediate 回撥,會阻塞住程序,並等待新的 poll 任務加入並立即執行。
process.nextTick()
nextTick 比較特殊,它有自己的佇列,並且,獨立於event loop。 它的執行也非常特殊,無論 event loop 處於何種階段,都會在階段結束的時候清空 nextTick 佇列。
直接看例子吧:
process.nextTick
process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED
大概順序如下:
- 因為nextTick的特殊性,當前階段執行完畢,就執行。所以直接,輸出1 2
- 執行到timer 輸出 TIMEOUT FIRED
setImmediate
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);
這個結果不固定,同一臺機器測試結果也有兩種:
// TIMEOUT FIRED =>1 =>2
或者
// 1=>TIMEOUT FIRED=>2
- 事件佇列進入timer,效能好的 小於1ms,則不執行回撥繼續往下。若此時大於1ms, 則輸出 TIMEOUT FIRED 就不輸出步驟3了。
- poll階段任務為空,存在setImmediate 直接進入setImmediate 輸出1
- 然後再次到達timer 輸出 TIMEOUT FIRED
- 再次進入check 階段 輸出 2
原因在於setTimeout 0 node 中至少為1ms,也就是取決於機器執行至timer時是否到了可執行的時機。
做個對比就比較清楚了:
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});
setImmediate(function B(){console.log(4);});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 20);
// 1=>2=>TIMEOUT FIRED
此時間隔時間較長,timer階段最後才會執行,所以會先執行兩次check,出處1,2
下面再看個例子
poll階段任務佇列
var fs = require('fs')
fs.readFile('./yarn.lock', () => {
setImmediate(() => {
console.log('1')
setImmediate(() => {
console.log('2')
})
})
setTimeout(() => {
console.log('TIMEOUT FIRED')
}, 0)
})
// 結果確定:
// 輸出始終為1=>TIMEOUT FIRED=>2
- 讀取檔案,回撥進入poll階段
- 當前無任務佇列,直接check 輸出1 將setImmediate2加入事件佇列
- 接著timer階段,輸出TIMEOUT FIRED
- 再次check階段,輸出2
小結
瀏覽器的事件迴圈
瀏覽器比較清晰一些,就是固定的流程,當前巨集任務結束,就是執行所有微任務(不一定是全部,可能基於系統能力,會有所剩下),然後再下一個巨集任務,微任務這樣交替進行。
node中的事件迴圈
主要是把握不同階段和特殊情況的處理,特別是poll階段和 process.nextTick任務。
結束語
參考文章:
https://zhuanlan.zhihu.com/p/47152694
https://html.spec.whatwg.org/multipage/webappapis.html#event-loop
http://www.ruanyifeng.com/blog/2014/10/event-loop.html
https://hackernoon.com/understanding-js-the-event-loop-959beae3ac40
https://juejin.im/post/5bac87b6f265da0a906f78d8
感謝上述參考文章,關於事件迴圈這裡就總結完畢了,作為自己的一個學習心得。希望能幫助到有需求的同學,一起進步