您好,登錄后才能下訂單哦!
這篇文章將為大家詳細講解有關瀏覽器與Node的事件循環(Event Loop)之間的區別有哪些,小編覺得挺實用的,因此分享給大家做個參考,希望大家閱讀完這篇文章后可以有所收獲。
一、線程與進程
1. 概念
我們經常說 JS 是單線程執行的,指的是一個進程里只有一個主線程,那到底什么是線程?什么是進程?
官方的說法是:進程是 CPU 資源分配的最小單位;線程是 CPU 調度的最小單位。這兩句話并不好理解,我們先來看張圖:
進程好比圖中的工廠,有單獨的專屬自己的工廠資源。
線程好比圖中的工人,多個工人在一個工廠中協作工作,工廠與工人是 1:n 的關系。也就是說一個進程由一個或多個線程組成,線程是一個進程中代碼的不同執行路線;
工廠的空間是工人們共享的,這象征一個進程的內存空間是共享的,每個線程都可用這些共享內存。
多個工廠之間獨立存在。
多進程:在同一個時間里,同一個計算機系統中如果允許兩個或兩個以上的進程處于運行狀態。多進程帶來的好處是明顯的,比如你可以聽歌的同時,打開編輯器敲代碼,編輯器和聽歌軟件的進程之間絲毫不會相互干擾。
多線程:程序中包含多個執行流,即在一個程序中可以同時運行多個不同的線程來執行不同的任務,也就是說允許單個程序創建多個并行執行的線程來完成各自的任務。
以 Chrome 瀏覽器中為例,當你打開一個 Tab 頁時,其實就是創建了一個進程,一個進程中可以有多個線程(下文會詳細介紹),比如渲染線程、JS 引擎線程、HTTP 請求線程等等。當你發起一個請求時,其實就是創建了一個線程,當請求結束后,該線程可能就會被銷毀。
簡單來說瀏覽器內核是通過取得頁面內容、整理信息(應用 CSS)、計算和組合最終輸出可視化的圖像結果,通常也被稱為渲染引擎。
瀏覽器內核是多線程,在內核控制下各線程相互配合以保持同步,一個瀏覽器通常由以下常駐線程組成:
GUI 渲染線程
JavaScript 引擎線程
定時觸發器線程
事件觸發線程
異步 http 請求線程
主要負責頁面的渲染,解析 HTML、CSS,構建 DOM 樹,布局和繪制等。
當界面需要重繪或者由于某種操作引發回流時,將執行該線程。
該線程與 JS 引擎線程互斥,當執行 JS 引擎線程時,GUI 渲染會被掛起,當任務隊列空閑時,JS 引擎才會去執行 GUI 渲染。
該線程當然是主要負責處理 JavaScript 腳本,執行代碼。
也是主要負責執行準備好待執行的事件,即定時器計數結束,或者異步請求成功并正確返回時,將依次進入任務隊列,等待 JS 引擎線程的執行。
當然,該線程與 GUI 渲染線程互斥,當 JS 引擎線程執行 JavaScript 腳本時間過長,將導致頁面渲染的阻塞。
負責執行異步定時器一類的函數的線程,如: setTimeout,setInterval。
主線程依次執行代碼時,遇到定時器,會將定時器交給該線程處理,當計數完畢后,事件觸發線程會將計數完畢后的事件加入到任務隊列的尾部,等待 JS 引擎線程執行。
主要負責將準備好的事件交給 JS 引擎線程執行。
比如 setTimeout 定時器計數結束, ajax 等異步請求成功并觸發回調函數,或者用戶觸發點擊事件時,該線程會將整裝待發的事件依次加入到任務隊列的隊尾,等待 JS 引擎線程的執行。
負責執行異步請求一類的函數的線程,如: Promise,axios,ajax 等。
主線程依次執行代碼時,遇到異步請求,會將函數交給該線程處理,當監聽到狀態碼變更,如果有回調函數,事件觸發線程會將回調函數加入到任務隊列的尾部,等待 JS 引擎線程執行。
事件循環中的異步隊列有兩種:macro(宏任務)隊列和 micro(微任務)隊列。宏任務隊列可以有多個,微任務隊列只有一個。
常見的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整體代碼)、 I/O 操作、UI 渲染等。
常見的 micro-task 比如: process.nextTick、new Promise().then(回調)、MutationObserver(html5 新特性) 等。
一個完整的 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 任務,如果有,則對其進行處理
上述過程循環往復,直到兩個隊列都清空
我們總結一下,每一次循環都是一個這樣的過程:
當某個宏任務執行完后,會查看是否有微任務隊列。如果有,先執行微任務隊列中的所有任務,如果沒有,會讀取宏任務隊列中排在最前的任務,執行宏任務的過程中,遇到微任務,依次加入微任務隊列。棧空后,再次讀取微任務隊列里的任務,依次類推。
接下來我們看道例子來介紹上面流程:
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 和瀏覽器中的是完全不相同的東西。Node.js 采用 V8 作為 js 的解析引擎,而 I/O 處理方面使用了自己設計的 libuv,libuv 是一個基于事件驅動的跨平臺抽象層,封裝了不同操作系統一些底層特性,對外提供統一的 API,事件循環機制也是它里面的實現(下文會詳細介紹)。
Node.js 的運行機制如下:
V8 引擎解析 JavaScript 腳本。
解析后的代碼,調用 Node API。
libuv 庫負責 Node API 的執行。它將不同的任務分配給不同的線程,形成一個 Event Loop(事件循環),以異步的方式將任務的執行結果返回給 V8 引擎。
V8 引擎再將結果返回給用戶。
其中 libuv 引擎中的事件循環分為 6 個階段,它們會按照順序反復運行。每當進入某一個階段的時候,都會從對應的回調隊列中取出函數去執行。當隊列為空或者執行的回調函數數量到達系統設定的閾值,就會進入下一階段。
從上圖中,大致看出 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 差異,下文還會詳細介紹)。
(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
瀏覽器環境下,microtask 的任務隊列是每個 macrotask 執行完之后執行。而在 Node.js 中,microtask 會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行 microtask 隊列的任務。
接下我們通過一個例子來說明兩者區別:
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 端運行結果: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 環境下,microtask 任務隊列的執行時機不同
Node 端,microtask 在事件循環的各個階段之間執行
瀏覽器端,microtask 在事件循環的 macrotask 執行完之后執行
關于瀏覽器與Node的事件循環(Event Loop)之間的區別有哪些就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。