前言
本文我們將會介紹 JS 實現非同步的原理,並且瞭解了在瀏覽器和 Node 中 Event Loop 其實是不相同的。
一、執行緒與程式
1.概念
我們經常說JS 是單執行緒執行的,指的是一個程式裡只有一個主執行緒,那到底什麼是執行緒?什麼是程式?
官方的說法是:程式是 CPU資源分配的最小單位;執行緒是 CPU排程的最小單位。這兩句話並不好理解,我們先來看張圖:
- 程式好比圖中的工廠,有單獨的專屬自己的工廠資源。
- 執行緒好比圖中的工人,多個工人在一個工廠中協作工作,工廠與工人是 1:n的關係。也就是說一個程式由一個或多個執行緒組成,執行緒是一個程式中程式碼的不同執行路線;
- 工廠的空間是工人們共享的,這象徵一個程式的記憶體空間是共享的,每個執行緒都可用這些共享記憶體。
- 多個工廠之間獨立存在。
2.多程式與多執行緒
- 多程式:在同一個時間裡,同一個計算機系統中如果允許兩個或兩個以上的程式處於執行狀態。多程式帶來的好處是明顯的,比如你可以聽歌的同時,開啟編輯器敲程式碼,編輯器和聽歌軟體的程式之間絲毫不會相互干擾。
- 多執行緒:程式中包含多個執行流,即在一個程式中可以同時執行多個不同的執行緒來執行不同的任務,也就是說允許單個程式建立多個並行執行的執行緒來完成各自的任務。
以Chrome瀏覽器中為例,當你開啟一個 Tab 頁時,其實就是建立了一個程式,一個程式中可以有多個執行緒(下文會詳細介紹),比如渲染執行緒、JS 引擎執行緒、HTTP 請求執行緒等等。當你發起一個請求時,其實就是建立了一個執行緒,當請求結束後,該執行緒可能就會被銷燬。
二、瀏覽器核心
簡單來說瀏覽器核心是通過取得頁面內容、整理資訊(應用CSS)、計算和組合最終輸出視覺化的影象結果,通常也被稱為渲染引擎。
瀏覽器核心是多執行緒,在核心控制下各執行緒相互配合以保持同步,一個瀏覽器通常由以下常駐執行緒組成:
- GUI 渲染執行緒
- JavaScript引擎執行緒
- 定時觸發器執行緒
- 事件觸發執行緒
- 非同步http請求執行緒
1.GUI渲染執行緒
- 主要負責頁面的渲染,解析HTML、CSS,構建DOM樹,佈局和繪製等。
- 當介面需要重繪或者由於某種操作引發迴流時,將執行該執行緒。
- 該執行緒與JS引擎執行緒互斥,當執行JS引擎執行緒時,GUI渲染會被掛起,當任務佇列空閒時,主執行緒才會去執行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、script(整體程式碼)、 I/O 操作、UI 渲染等。
- 常見的 micro-task 比如: new Promise().then(回撥)、MutationObserver(html5新特性) 等。
2.Event Loop 過程解析
一個完整的 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 任務,如果有,則對其進行處理
- 上述過程迴圈往復,直到兩個佇列都清空
我們總結一下,每一次迴圈都是一個這樣的過程:
當某個巨集任務執行完後,會檢視是否有微任務佇列。如果有,先執行微任務佇列中的所有任務,如果沒有,會讀取巨集任務佇列中排在最前的任務,執行巨集任務的過程中,遇到微任務,依次加入微任務佇列。棧空後,再次讀取微任務佇列裡的任務,依次類推。
接下來我們看道例子來介紹上面流程:
1 2 3 4 5 6 7 8 9 10 11 12 |
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.js的執行機制如下:
- V8引擎解析JavaScript指令碼。
- 解析後的程式碼,呼叫Node API。
- libuv庫負責Node API的執行。它將不同的任務分配給不同的執行緒,形成一個Event Loop(事件迴圈),以非同步的方式將任務的執行結果返回給V8引擎。
- V8引擎再將結果返回給使用者。
2.六個階段
其中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 是一個至關重要的階段,這一階段中,系統會做兩件事情
1.回到 timer 階段執行回撥
2.執行 I/O 回撥
並且在進入該階段時如果沒有設定了 timer 的話,會發生以下兩件事情
- 如果 poll 佇列不為空,會遍歷回撥佇列並同步執行,直到佇列為空或者達到系統限制
- 如果 poll 佇列為空時,會有兩件事發生
- 如果有 setImmediate 回撥需要執行,poll 階段會停止並且進入到 check 階段執行回撥
- 如果沒有 setImmediate 回撥需要執行,會等待回撥被加入到佇列中並立即執行回撥,這裡同樣會有個超時時間設定防止一直等待下去
當然設定了 timer 的話且 poll 佇列為空,則會判斷是否有 timer 超時,如果有的話會回到 timer 階段執行回撥。
(3) check階段
setImmediate()的回撥會被加入check佇列中,從event loop的階段圖可以知道,check階段的執行順序在poll階段之後。
我們先來看個例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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.Micro-Task 與 Macro-Task
Node端事件迴圈中的非同步佇列也是這兩種:macro(巨集任務)佇列和 micro(微任務)佇列。
- 常見的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整體程式碼)、 I/O 操作等。
- 常見的 micro-task 比如: process.nextTick、new Promise().then(回撥)等。
4.注意點
(1) setTimeout 和 setImmediate
二者非常相似,區別主要在於呼叫時機不同。
- setImmediate 設計在poll階段完成時執行,即check階段;
- setTimeout 設計在poll階段為空閒時,且設定時間到達後執行,但它在timer階段執行
123456setTimeout(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
1 2 3 4 5 6 7 8 9 10 11 |
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 執行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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佇列的任務。
接下我們通過一個例子來說明兩者區別:
1 2 3 4 5 6 7 8 9 10 11 12 |
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端執行結果分兩種情況:
- 如果是node11版本一旦執行一個階段裡的一個巨集任務(setTimeout,setInterval和setImmediate)就立刻執行微任務佇列,這就跟瀏覽器端執行一致,最後的結果為
timer1=>promise1=>timer2=>promise2
- 如果是node10及其之前版本:要看第一個定時器執行完,第二個定時器是否在完成佇列中。
- 如果是第二個定時器還未在完成佇列中,最後的結果為
timer1=>promise1=>timer2=>promise2
- 如果是第二個定時器已經在完成佇列中,則最後的結果為
timer1=>timer2=>promise1=>promise2
(下文過程解釋基於這種情況下)
- 如果是第二個定時器還未在完成佇列中,最後的結果為
1.全域性指令碼(main())執行,將2個timer依次放入timer佇列,main()執行完畢,呼叫棧空閒,任務佇列開始執行;
2.首先進入timers階段,執行timer1的回撥函式,列印timer1,並將promise1.then回撥放入microtask佇列,同樣的步驟執行timer2,列印timer2;
3.至此,timer階段執行結束,event loop進入下一個階段之前,執行microtask佇列的所有任務,依次列印promise1、promise2
Node端的處理過程如下:
六、總結
瀏覽器和Node 環境下,microtask 任務佇列的執行時機不同
- Node端,microtask 在事件迴圈的各個階段之間執行
- 瀏覽器端,microtask 在事件迴圈的 macrotask 執行完之後執行
後記
文章於2019.1.16晚,對最後一個例子在node執行結果,重新修改!再次特別感謝zy445566和BuptStEve的精彩點評,由於node版本更新到11,Event Loop執行原理髮生了變化,一旦執行一個階段裡的一個巨集任務(setTimeout,setInterval和setImmediate)就立刻執行微任務佇列,這點就跟瀏覽器端一致。