1. 程式人生 > >瀏覽器和Node 中的Event Loop

瀏覽器和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

執行棧中的程式碼(同步任務),總是在讀取"任務佇列"(非同步任務)之前執行
當前執行棧執行完畢時會立刻先處理所有微任務佇列中的事件,然後再去巨集任務佇列中取出一個事件。同一次事件迴圈中,微任務永遠在巨集任務之前執行

對於不同型別的任務執行順序如下:

  1. 同步程式碼執行
  2. event-loop start
  3. microTasks 佇列開始清空(執行)
  4. 檢查 Tasks 是否清空,有則跳到 4,無則跳到 6
  5. 從 Tasks 佇列抽取一個任務,執行
  6. 檢查 microTasks 是否清空,若有則跳到 2,無則跳到 3
  7. 結束 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 
  1. 區分事件型別:巨集任務setTimeout,微任務.then
  2. 同步程式碼執行 輸出2
  3. 微任務佇列清空 輸出 3
  4. 巨集任務執行 輸出 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階段

該階段有如下功能:

  1. 執行 timer 階段到達時間上限的的任務。
  2. 執行 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

大概順序如下:

  1. 因為nextTick的特殊性,當前階段執行完畢,就執行。所以直接,輸出1 2
  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
  1. 事件佇列進入timer,效能好的 小於1ms,則不執行回撥繼續往下。若此時大於1ms, 則輸出 TIMEOUT FIRED 就不輸出步驟3了。
  2. poll階段任務為空,存在setImmediate 直接進入setImmediate 輸出1
  3. 然後再次到達timer 輸出 TIMEOUT FIRED
  4. 再次進入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
  1. 讀取檔案,回撥進入poll階段
  2. 當前無任務佇列,直接check 輸出1 將setImmediate2加入事件佇列
  3. 接著timer階段,輸出TIMEOUT FIRED
  4. 再次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
感謝上述參考文章,關於事件迴圈這裡就總結完畢了,作為自己的一個學習心得。希望能幫助到有需求的同學,一起進步