當前位置:
首頁 > 知識 > 瀏覽器與Node的事件循環(Event Loop)有何區別?

瀏覽器與Node的事件循環(Event Loop)有何區別?

前言

本文我們將會介紹 JS 實現非同步的原理,並且了解了在瀏覽器和 Node 中 Event Loop 其實是不相同的。

一、線程與進程

1. 概念

我們經常說 JS 是單線程執行的,指的是一個進程里只有一個主線程,那到底什麼是線程?什麼是進程?

官方的說法是:進程是 CPU 資源分配的最小單位;線程是 CPU 調度的最小單位。這兩句話並不好理解,我們先來看張圖:

瀏覽器與Node的事件循環(Event Loop)有何區別?

  • 進程好比圖中的工廠,有單獨的專屬自己的工廠資源。
  • 線程好比圖中的工人,多個工人在一個工廠中協作工作,工廠與工人是 1:n 的關係。也就是說

    一個進程由一個或多個線程組成,線程是一個進程中代碼的不同執行路線

  • 工廠的空間是工人們共享的,這象徵

    一個進程的內存空間是共享的,每個線程都可用這些共享內存

  • 多個工廠之間獨立存在。

2. 多進程與多線程

  • 多進程:在同一個時間裡,同一個計算機系統中如果允許兩個或兩個以上的進程處於運行狀態。多進程帶來的好處是明顯的,比如你可以聽歌的同時,打開編輯器敲代碼,編輯器和聽歌軟體的進程之間絲毫不會相互干擾。
  • 多線程:程序中包含多個執行流,即在一個程序中可以同時運行多個不同的線程來執行不同的任務,也就是說允許單個程序創建多個並行執行的線程來完成各自的任務。

以 Chrome 瀏覽器中為例,當你打開一個 Tab 頁時,其實就是創建了一個進程,一個進程中可以有多個線程(下文會詳細介紹),比如渲染線程、JS 引擎線程、HTTP 請求線程等等。當你發起一個請求時,其實就是創建了一個線程,當請求結束後,該線程可能就會被銷毀。

二、瀏覽器內核

簡單來說瀏覽器內核是通過取得頁面內容、整理信息(應用 CSS)、計算和組合最終輸出可視化的圖像結果,通常也被稱為渲染引擎。

瀏覽器內核是多線程,在內核控制下各線程相互配合以保持同步,一個瀏覽器通常由以下常駐線程組成:

  • GUI 渲染線程
  • JavaScript 引擎線程
  • 定時觸發器線程
  • 事件觸發線程
  • 非同步 http 請求線程

1. GUI 渲染線程

  • 主要負責頁面的渲染,解析 HTML、CSS,構建 DOM 樹,布局和繪製等。
  • 當界面需要重繪或者由於某種操作引發迴流時,將執行該線程。
  • 該線程與 JS 引擎線程互斥,當執行 JS 引擎線程時,GUI 渲染會被掛起,當任務隊列空閑時,JS 引擎才會去執行 GUI 渲染。

2. JS 引擎線程

  • 該線程當然是主要負責處理 JavaScript 腳本,執行代碼。
  • 也是主要負責執行準備好待執行的事件,即定時器計數結束,或者非同步請求成功並正確返回時,將依次進入任務隊列,等待 JS 引擎線程的執行。
  • 當然,該線程與 GUI 渲染線程互斥,當 JS 引擎線程執行 JavaScript 腳本時間過長,將導致頁面渲染的阻塞。

3. 定時器觸發線程

  • 負責執行非同步定時器一類的函數的線程,如: setTimeout,setInterval。
  • 主線程依次執行代碼時,遇到定時器,會將定時器交給該線程處理,當計數完畢後,事件觸發線程會將計數完畢後的事件加入到任務隊列的尾部,等待 JS 引擎線程執行。

4. 事件觸發線程

  • 主要負責將準備好的事件交給 JS 引擎線程執行。

比如 setTimeout 定時器計數結束, ajax 等非同步請求成功並觸發回調函數,或者用戶觸發點擊事件時,該線程會將整裝待發的事件依次加入到任務隊列的隊尾,等待 JS 引擎線程的執行。

5. 非同步 http 請求線程

  • 負責執行非同步請求一類的函數的線程,如: Promise,axios,ajax 等。
  • 主線程依次執行代碼時,遇到非同步請求,會將函數交給該線程處理,當監聽到狀態碼變更,如果有回調函數,事件觸發線程會將回調函數加入到任務隊列的尾部,等待 JS 引擎線程執行。

三、瀏覽器中的 Event Loop

1. Micro-Task 與 Macro-Task

事件循環中的非同步隊列有兩種:macro(宏任務)隊列和 micro(微任務)隊列。宏任務隊列可以有多個,微任務隊列只有一個

  • 常見的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整體代碼)、 I/O 操作、UI 渲染等。
  • 常見的 micro-task 比如: process.nextTick、new Promise().then(回調)、MutationObserver(html5 新特性) 等。

2. Event Loop 過程解析

一個完整的 Event Loop 過程,可以概括為以下階段:

瀏覽器與Node的事件循環(Event Loop)有何區別?

  • 一開始執行棧空,我們可以把

    執行棧認為是一個存儲函數調用的棧結構,遵循先進後出的原則

    。micro 隊列空,macro 隊列里有且只有一個 script 腳本(整體代碼)。
  • 全局上下文(script 標籤)被推入執行棧,同步代碼執行。在執行的過程中,會判斷是同步任務還是非同步任務,通過對一些介面的調用,可以產生新的 macro-task 與 micro-task,它們會分別被推入各自的任務隊列里。同步代碼執行完了,script 腳本會被移出 macro 隊列,這個過程本質上是隊列的 macro-task 的執行和出隊的過程。
  • 上一步我們出隊的是一個 macro-task,這一步我們處理的是 micro-task。但需要注意的是:當 macro-task 出隊時,任務是

    一個一個

    執行的;而 micro-task 出隊時,任務是

    一隊一隊

    執行的。因此,我們處理 micro 隊列這一步,會逐個執行隊列中的任務並把它出隊,直到隊列被清空。
  • 執行渲染操作,更新界面

  • 檢查是否存在 Web worker 任務,如果有,則對其進行處理
  • 上述過程循環往複,直到兩個隊列都清空

我們總結一下,每一次循環都是一個這樣的過程:

瀏覽器與Node的事件循環(Event Loop)有何區別?

當某個宏任務執行完後,會查看是否有微任務隊列。如果有,先執行微任務隊列中的所有任務,如果沒有,會讀取宏任務隊列中排在最前的任務,執行宏任務的過程中,遇到微任務,依次加入微任務隊列。棧空後,再次讀取微任務隊列里的任務,依次類推。

接下來我們看道例子來介紹上面流程:

Promise.resolve().then(()=>{
console.log("Promise1")
setTimeout(()=>{
console.log("setTimeout2")
},0)
})
setTimeout(()=>{
console.log("setTimeout1")
Promise.resolve().then(()=>{
console.log("Promise2")
})
},0)

最後輸出結果是 Promise1,setTimeout1,Promise2,setTimeout2

  • 一開始執行棧的同步任務(這屬於宏任務)執行完畢,會去查看是否有微任務隊列,上題中存在(有且只有一個),然後執行微任務隊列中的所有任務輸出 Promise1,同時會生成一個宏任務 setTimeout2
  • 然後去查看宏任務隊列,宏任務 setTimeout1 在 setTimeout2 之前,先執行宏任務 setTimeout1,輸出 setTimeout1
  • 在執行宏任務 setTimeout1 時會生成微任務 Promise2 ,放入微任務隊列中,接著先去清空微任務隊列中的所有任務,輸出 Promise2
  • 清空完微任務隊列中的所有任務後,就又會去宏任務隊列取一個,這回執行的是 setTimeout2

四、Node 中的 Event Loop

1. Node 簡介

Node 中的 Event Loop 和瀏覽器中的是完全不相同的東西。Node.js 採用 V8 作為 js 的解析引擎,而 I/O 處理方面使用了自己設計的 libuv,libuv 是一個基於事件驅動的跨平台抽象層,封裝了不同操作系統一些底層特性,對外提供統一的 API,事件循環機制也是它裡面的實現(下文會詳細介紹)。

瀏覽器與Node的事件循環(Event Loop)有何區別?

Node.js 的運行機制如下:

  • V8 引擎解析 JavaScript 腳本。
  • 解析後的代碼,調用 Node API。
  • libuv 庫負責 Node API 的執行。它將不同的任務分配給不同的線程,形成一個 Event Loop(事件循環),以非同步的方式將任務的執行結果返回給 V8 引擎。
  • V8 引擎再將結果返回給用戶。

2. 六個階段

其中 libuv 引擎中的事件循環分為 6 個階段,它們會按照順序反覆運行。每當進入某一個階段的時候,都會從對應的回調隊列中取出函數去執行。當隊列為空或者執行的回調函數數量到達系統設定的閾值,就會進入下一階段。

瀏覽器與Node的事件循環(Event Loop)有何區別?

從上圖中,大致看出 node 中的事件循環的順序:

外部輸入數據-->輪詢階段(poll)-->檢查階段(check)-->關閉事件回調階段(close callback)-->定時器檢測階段(timer)-->I/O 事件回調階段(I/O callbacks)-->閑置階段(idle, prepare)-->輪詢階段(按照該順序反覆運行)...

  • timers 階段:這個階段執行 timer(setTimeout、setInterval)的回調
  • I/O callbacks 階段:處理一些上一輪循環中的少數未執行的 I/O 回調
  • idle, prepare 階段:僅 node 內部使用
  • poll 階段:獲取新的 I/O 事件, 適當的條件下 node 將阻塞在這裡
  • check 階段:執行 setImmediate() 的回調
  • close callbacks 階段:執行 socket 的 close 事件回調

注意:上面六個階段都不包括 process.nextTick()(下文會介紹)

接下去我們詳細介紹timers、poll、check這 3 個階段,因為日常開發中的絕大部分非同步任務都是在這 3 個階段處理的。

(1) timer

timers 階段會執行 setTimeout 和 setInterval 回調,並且是由 poll 階段控制的。

同樣,在 Node 中定時器指定的時間也不是準確時間,只能是儘快執行

(2) poll

poll 是一個至關重要的階段,這一階段中,系統會做兩件事情

  • 回到 timer 階段執行回調
  • 執行 I/O 回調

並且在進入該階段時如果沒有設定了 timer 的話,會發生以下兩件事情

  • 如果 poll 隊列不為空,會遍歷回調隊列並同步執行,直到隊列為空或者達到系統限制
  • 如果 poll 隊列為空時,會有兩件事發生
  • 如果有 setImmediate 回調需要執行,poll 階段會停止並且進入到 check 階段執行回調
  • 如果沒有 setImmediate 回調需要執行,會等待回調被加入到隊列中並立即執行回調,這裡同樣會有個超時時間設置防止一直等待下去

當然設定了 timer 的話且 poll 隊列為空,則會判斷是否有 timer 超時,如果有的話會回到 timer 階段執行回調。

(3) check 階段

setImmediate()的回調會被加入 check 隊列中,從 event loop 的階段圖可以知道,check 階段的執行順序在 poll 階段之後。

我們先來看個例子:

console.log("start")
setTimeout(() => {
console.log("timer1")
Promise.resolve().then(function() {
console.log("promise1")
})
}, 0)
setTimeout(() => {
console.log("timer2")
Promise.resolve().then(function() {
console.log("promise2")
})
}, 0)
Promise.resolve().then(function() {
console.log("promise3")
})
console.log("end")
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2

  • 一開始執行棧的同步任務(這屬於宏任務)執行完畢後(依次列印出 start end,並將 2 個 timer 依次放入 timer 隊列),會先去執行微任務(

    這點跟瀏覽器端的一樣

    ),所以列印出 promise3
  • 然後進入 timers 階段,執行 timer1 的回調函數,列印 timer1,並將 promise.then 回調放入 microtask 隊列,同樣的步驟執行 timer2,列印 timer2;這點跟瀏覽器端相差比較大,

    timers 階段有幾個 setTimeout/setInterval 都會依次執行

    ,並不像瀏覽器端,每執行一個宏任務後就去執行一個微任務(關於 Node 與瀏覽器的 Event Loop 差異,下文還會詳細介紹)。

3. 注意點

(1) setTimeout 和 setImmediate

二者非常相似,區別主要在於調用時機不同。

  • setImmediate 設計在 poll 階段完成時執行,即 check 階段;
  • setTimeout 設計在 poll 階段為空閑時,且設定時間到達後執行,但它在 timer 階段執行

setTimeout(function timeout () {
console.log("timeout");
},0);
setImmediate(function immediate () {
console.log("immediate");
});

  • 對於以上代碼來說,setTimeout 可能執行在前,也可能執行在後。
  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的
  • 進入事件循環也是需要成本的,如果在準備時候花費了大於 1ms 的時間,那麼在 timer 階段就會直接執行 setTimeout 回調
  • 如果準備時間花費小於 1ms,那麼就是 setImmediate 回調先執行了

但當二者在非同步 i/o callback 內部調用時,總是先執行 setImmediate,再執行 setTimeout

const fs = require("fs")
fs.readFile(__filename, () => {
setTimeout(() => {
console.log("timeout");
}, 0)
setImmediate(() => {
console.log("immediate")
})
})
// immediate
// timeout

在上述代碼中,setImmediate 永遠先執行。因為兩個代碼寫在 IO 回調中,IO 回調是在 poll 階段執行,當回調執行完畢後隊列為空,發現存在 setImmediate 回調,所以就直接跳轉到 check 階段去執行回調了。

(2) process.nextTick

這個函數其實是獨立於 Event Loop 之外的,它有一個自己的隊列,當每個階段完成後,如果存在 nextTick 隊列,就會清空隊列中的所有回調函數,並且優先於其他 microtask 執行。

setTimeout(() => {
console.log("timer1")
Promise.resolve().then(function() {
console.log("promise1")
})
}, 0)
process.nextTick(() => {
console.log("nextTick")
process.nextTick(() => {
console.log("nextTick")
process.nextTick(() => {
console.log("nextTick")
process.nextTick(() => {
console.log("nextTick")
})
})
})
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

五、Node 與瀏覽器的 Event Loop 差異

瀏覽器環境下,microtask 的任務隊列是每個 macrotask 執行完之後執行。而在 Node.js 中,microtask 會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行 microtask 隊列的任務

瀏覽器與Node的事件循環(Event Loop)有何區別?

接下我們通過一個例子來說明兩者區別:

setTimeout(()=>{
console.log("timer1")
Promise.resolve().then(function() {
console.log("promise1")
})
}, 0)
setTimeout(()=>{
console.log("timer2")
Promise.resolve().then(function() {
console.log("promise2")
})
}, 0)

瀏覽器端運行結果:timer1=>promise1=>timer2=>promise2

瀏覽器端的處理過程如下:

瀏覽器與Node的事件循環(Event Loop)有何區別?

Node 端運行結果:timer1=>timer2=>promise1=>promise2

  • 全局腳本(main())執行,將 2 個 timer 依次放入 timer 隊列,main()執行完畢,調用棧空閑,任務隊列開始執行;
  • 首先進入 timers 階段,執行 timer1 的回調函數,列印 timer1,並將 promise1.then 回調放入 microtask 隊列,同樣的步驟執行 timer2,列印 timer2;
  • 至此,timer 階段執行結束,event loop 進入下一個階段之前,執行 microtask 隊列的所有任務,依次列印 promise1、promise2

Node 端的處理過程如下:

瀏覽器與Node的事件循環(Event Loop)有何區別?

六、總結

瀏覽器和 Node 環境下,microtask 任務隊列的執行時機不同

  • Node 端,microtask 在事件循環的各個階段之間執行
  • 瀏覽器端,microtask 在事件循環的 macrotask 執行完之後執行

參考文章

  • 瀏覽器進程?線程?傻傻分不清楚!
  • 事件循環機制的那些事
  • 前端性能優化原理與實踐
  • 前端面試之道
  • 深入理解 js 事件循環機制(Node.js 篇)
  • 詳解 JavaScript 中的 Event Loop(事件循環)機制
  • event-loop-timers-and-nexttick

版權聲明

作者:Fundebug

原文地址:

https://blog.fundebug.com/2019/01/15/diffrences-of-browser-and-node-in-event-loop/

Fundebug

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 程序員小新人學習 的精彩文章:

前端 + rsa加解密 + jsencrypt.min.js
Web API規範設計指引

TAG:程序員小新人學習 |