事件迴圈機制的那些事
寫在前面
校招筆試中我們經常會遇到這樣一個問題:
JavaScript12345678910 | 寫出下面程式碼的執行結果:console |
看完這道題的我自信的寫下了答案: 1,2,4,3
面試官:為什麼是這個答案?
我:首先列印 1
,遇到定時器,等待時間為0,所以列印 2
,又遇到一個定時器,等待時間為2秒,所以先列印 4
,兩秒後列印 3
。
然後就……
與這段程式碼相關的知識點就是JavaScript事件迴圈機制,下面將從有關的基本概念出發,先了解了相關的概念,才能更好的理解事件迴圈的機制原理。以下都是自己的個人理解,如有不正確的地方,歡迎大家在評論區拍磚。
執行緒與程序
關於執行緒與程序的關係可以用下面的圖進行說明:
- 程序好比圖中的工廠,有單獨的專屬自己的工廠資源。
- 執行緒好比圖中的工人,多個工人在一個工廠中協作工作,工廠與工人是
1:n
的關係。 - 多個工廠之間獨立存在。
而官方的說法是:
- 程序是
CPU
資源分配的最小單位。 - 執行緒是
CPU
排程的最小單位。
從更直觀的例子來看,可以開啟工作管理員檢視,第一個 tab
便是程序列表,每一個程序佔有的 CPU
資源和記憶體資源的比例很直觀的展示出來。
為什麼js是單執行緒
初學計算機語言的時候,無論是 C、C++
還是 JAVA
,都是支援多執行緒,偏偏 JavaScript
是單執行緒,不支援多執行緒,這也跟 JavaScript
JavaScript
是主要執行在瀏覽器的指令碼語言,最終操作的是頁面的 DOM
結構,當兩個 JavaScript
指令碼同時修改頁面的同一個 DOM
節點時,瀏覽器該執行哪個呢?所以當時設計 JavaScript
時,便要求當前修改操作完成後方可進行下一步修改操作。
瀏覽器是支援多程序
同樣我們開啟瀏覽器的工作管理員,以下圖為例:
瀏覽器的每一個 tab
頁都是一個程序,有對應的記憶體佔用空間、 CPU
使用量以及程序ID。 新開啟一個 tab
頁時,都會新建一個程序,所以就有一個 tab
頁對應一個程序的說法,但是這種說法又是錯誤的,因為瀏覽器有自己的優化機制,當我們開啟多個空白的 tab
頁時,瀏覽器會將這多個空白頁的程序合併為一個,從而減少了程序的數量個數。
瀏覽器核心
瀏覽器核心中有多個程序在同步工作,今天涉及到的瀏覽器的程序主要包括以下程序:
- Browser 程序
主程序,主要負責頁面管理以及管理其他程序的建立和銷燬等,常駐的執行緒有:
- GUI渲染執行緒
- JS引擎執行緒
- 事件觸發執行緒
- 定時器觸發執行緒
- HTTP請求執行緒
GUI渲染執行緒
- 主要負責頁面的渲染,解析HTML、CSS,構建DOM樹,佈局和繪製等。
- 當介面需要重繪或者由於某種操作引發迴流時,將執行該執行緒。
- 該執行緒與JS引擎執行緒互斥,當執行JS引擎執行緒時,GUI渲染會被掛起,當任務佇列空閒時,JS引擎才會去執行GUI渲染。
JS引擎執行緒
- 該執行緒當然是主要負責處理
JavaScript
指令碼,執行程式碼。- 也是主要負責執行準備好待執行的事件,即定時器計數結束,或者非同步請求成功並正確返回時,將依次進入任務佇列,等待
JS引擎執行緒
的執行。- 當然,該執行緒與
GUI渲染執行緒
互斥,當JS引擎執行緒
執行JavaScript
指令碼時間過長,將導致頁面渲染的阻塞。事件觸發執行緒
- 主要負責將準備好的事件交給
JS引擎執行緒
執行。- 比如
setTimeout
定時器計數結束,ajax
等非同步請求成功並觸發回撥函式,或者使用者觸發點選事件時,該執行緒會將整裝待發的事件依次加入到任務佇列的隊尾,等待JS引擎執行緒
的執行。定時器觸發執行緒
- 顧名思義,負責執行非同步定時器一類的函式的執行緒,如:
setTimeout,setInterval
。- 主執行緒依次執行程式碼時,遇到定時器,會將定時器交給該執行緒處理,當計數完畢後,事件觸發執行緒會將計數完畢後的事件加入到任務佇列的尾部,等待JS引擎執行緒執行。
HTTP請求執行緒
- 顧名思義,負責執行非同步請求一類的函式的執行緒,如:
Promise,anxios,ajax
等。- 主執行緒依次執行程式碼時,遇到非同步請求,會將函式交給該執行緒處理,當監聽到狀態碼變更,如果有回撥函式,事件觸發執行緒會將回調函式加入到任務佇列的尾部,等待JS引擎執行緒執行。
多個執行緒之間配合工作,各司其職。
- Render 程序
瀏覽器渲染程序(瀏覽器核心),主要負責頁面的渲染、JS執行以及事件的迴圈。
同步任務和非同步任務
- 同步任務 即可以立即執行的任務,例如
console.log()
列印一條日誌、宣告一個變數或者執行一次加法操作等。 - 非同步任務 相反不會立即執行的事件任務。非同步任務包括巨集任務和微任務(後面會進行解釋~)。
- 常見的非同步操作:
- Ajax
- DOM的事件操作
- setTimeout
- Promise的then方法
- Node的讀取檔案
下圖給出了同步任務與非同步任務的執行流程:
- 棧 就像是一個容器,任務都是在棧中執行。
- 主執行緒 就像是操作員,負責執行棧中的任務。
- 任務佇列 就像是等待被加工的物品。
- 非同步任務完成註冊後會將回調函式加入任務佇列等待主執行緒執行。
- 執行棧中的同步任務執行完畢後,會檢視並讀取任務佇列中的事件函式,於是任務佇列的函式結束等待狀態,進入執行棧,開始執行。
那麼任務到底是如何入棧和出棧的呢?可以用一小段程式碼進行解釋。
入棧與出棧
以下面的程式碼為例:
JavaScript12345678910111213 | console.log(1);functionfn1(){console.log(2);}functionfn2(){console.log(3);fn1();}setTimeout(function(){console.log(4);},2000);fn2();console.log(5); |
所以上面程式碼執行的結果為:1,3,2,5,4。
巨集任務和微任務
非同步任務分為巨集任務和微任務,巨集任務佇列可以有多個,微任務佇列只有一個。
巨集任務和微任務的執行方式在瀏覽器和 Node
中有差異。
巨集任務(macrotask)
script
(全域性任務),setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
微任務(macrotask)
process.nextTick
,Promise.then()
,Object.observe
,MutationObserver
在微任務中 process.nextTick 優先順序高於Promise
當一個非同步任務入棧時,主執行緒判斷該任務為非同步任務,並把該任務交給非同步處理模組處理,當非同步處理模組處理完打到觸發條件時,根據任務的型別,將回調函式壓入任務佇列。
- 如果是巨集任務,則新增一個巨集任務佇列,任務佇列中的巨集任務可以有多個來源。
- 如果是微任務,則直接壓入微任務佇列。
所以上圖的任務佇列可以繼續細化一下:
那麼當棧為空時,巨集任務和微任務的執行機制又是什麼呢?
Event Loop
到這裡,除了上面的問題,我們已經把事件迴圈的最基本的處理方式搞清楚了,但具體到非同步任務中的巨集任務和微任務,還沒有弄明白。我們可以先順一遍執行機制:
- 從全域性任務
script
開始,任務依次進入棧中,被主執行緒執行,執行完後出棧。 - 遇到非同步任務,交給非同步處理模組處理,對應的非同步處理執行緒處理非同步任務需要的操作,例如定時器的計數和非同步請求監聽狀態的變更。
- 當非同步任務達到可執行狀態時,事件觸發執行緒將回調函式加入任務佇列,等待棧為空時,依次進入棧中執行。
到這問題就來了,當非同步任務進入棧執行時,是巨集任務還是微任務呢?
- 由於執行程式碼入口都是全域性任務
script
,而全域性任務屬於巨集任務,所以當棧為空,同步任務任務執行完畢時,會先執行微任務佇列裡的任務。 - 微任務佇列裡的任務全部執行完畢後,會讀取巨集任務佇列中拍最前的任務。
- 執行巨集任務的過程中,遇到微任務,依次加入微任務佇列。
- 棧空後,再次讀取微任務佇列裡的任務,依次類推。
例項解析
回到最開始的那段程式碼,現在我們可以一步一步的看一下執行順序。
JavaScript12345678 | console.log('1');setTimeout(function(){ console.log('2');},0);setTimeout(function(){ console.log('3');},2000);console.log('4'); |
- 從全域性任務入口,首先列印日誌
1
, - 遇到巨集任務
setTimeout
,交給非同步處理模組,我們暫且先記為setTimeout1
, - 再次遇到巨集任務
setTimeout
,交給非同步處理模組,我們暫且先記為setTimeout2
, - 順序執行,列印日誌
4
, - 此時同步任務已執行完畢,讀取巨集任務佇列的任務,先執行
setTimeout1
的回撥函式,因為定時器的等待時間為0
秒,所以會直接輸出2
,但是W3C
在HTML
標準中規定,規定要求setTimeout
中低於4ms
的時間間隔算為4ms
, - 由於瀏覽器在執行以上三步時,並未耗時很久,所以當巨集任務
setTimeout1
執行完時,setTimeout2
的等待時間並未結束,所以在2秒
後列印日誌3
,實際上並未等待2秒。
下面我們可以再看一個例項:
JavaScript12345678910111213 | setTimeout(function(){console.log(1);Promise.resolve().then(function(){console.log(2);});},0);setTimeout(function(){console.log(3);},0);Promise.resolve().then(function(){console.log(4);});console.log(5); |
當代碼中遇到了非同步請求的事件,又該如何執行,根據上面總結的執行機制,又該得到什麼樣的結果?
第一輪迴圈
- 同樣從全域性任務入口,遇到巨集任務
setTimeout
,交給非同步處理模組,我們暫且先記為setTimeout1
,由於等待時間為0
,直接加入巨集任務佇列。 - 再次遇到巨集任務
setTimeout
,交給非同步處理模組,我們暫且先記為setTimeout2
,同樣直接加入巨集任務佇列。 - 遇到微任務
then()
,加入微任務佇列。 - 最後遇到列印語句,直接列印日誌
5
。
第一輪迴圈結束後,可以畫出下圖:
第二輪迴圈
- 棧空後,先執行微任務佇列中的
then()
方法,輸出4
,此時微任務佇列為空。
- 讀取巨集任務佇列的最靠前的任務
setTimeout1
。 - 先直接執行列印語句,列印日誌
1
,又遇到微任務then()
,加入微任務佇列。第二輪迴圈結束。
第三輪迴圈
- 先執行微任務佇列中的
then()
方法,輸出2
,此時微任務佇列為空。
- 繼續讀取巨集任務佇列的最靠前的任務
setTimeout2
。 - 直接執行列印語句,列印日誌
3
。第三輪迴圈結束,執行完畢。
最後我們是我們的boss,歡迎大家在評論區留言寫出自己心中的那個正確答案。
JavaScript1234567891011121314151617181920212223242526272829303132333435 | console.log('1');setTimeout(function(){console.log('2');newPromise(function(resolve){console.log('3');resolve();}).then(function(){console.log('4')})})newPromise(function(resolve){console.log('5');resolve();}).then(function(){console.log('6')})setTimeout(function(){console.log('7');})setTimeout(function(){console.log('8');newPromise(function(resolve){console.log('9');resolve();}).then(function(){console.log('10')})})newPromise(function(resolve){console.log('11');resolve();}).then(function(){console.log('12')})console.log('13'); |