事件迴圈機制的那些事

發表於2018-11-14

寫在前面

校招筆試中我們經常會遇到這樣一個問題:

看完這道題的我自信的寫下了答案: 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的讀取檔案

下圖給出了同步任務與非同步任務的執行流程:

事件迴圈機制的那些事

  •  就像是一個容器,任務都是在棧中執行。
  • 主執行緒 就像是操作員,負責執行棧中的任務。
  • 任務佇列 就像是等待被加工的物品。
  • 非同步任務完成註冊後會將回撥函式加入任務佇列等待主執行緒執行。
  • 執行棧中的同步任務執行完畢後,會檢視並讀取任務佇列中的事件函式,於是任務佇列的函式結束等待狀態,進入執行棧,開始執行。

那麼任務到底是如何入棧和出棧的呢?可以用一小段程式碼進行解釋。

入棧與出棧

以下面的程式碼為例:

事件迴圈機制的那些事

所以上面程式碼執行的結果為: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,而全域性任務屬於巨集任務,所以當棧為空,同步任務任務執行完畢時,會先執行微任務佇列裡的任務。
  • 微任務佇列裡的任務全部執行完畢後,會讀取巨集任務佇列中拍最前的任務。
  • 執行巨集任務的過程中,遇到微任務,依次加入微任務佇列。
  • 棧空後,再次讀取微任務佇列裡的任務,依次類推。

例項解析

回到最開始的那段程式碼,現在我們可以一步一步的看一下執行順序。

  • 從全域性任務入口,首先列印日誌 1
  • 遇到巨集任務 setTimeout,交給非同步處理模組,我們暫且先記為 setTimeout1
  • 再次遇到巨集任務 setTimeout,交給非同步處理模組,我們暫且先記為 setTimeout2
  • 順序執行,列印日誌 4
  • 此時同步任務已執行完畢,讀取巨集任務佇列的任務,先執行 setTimeout1的回撥函式,因為定時器的等待時間為 0秒,所以會直接輸出 2,但是 W3C在 HTML標準中規定,規定要求 setTimeout中低於 4ms的時間間隔算為 4ms
  • 由於瀏覽器在執行以上三步時,並未耗時很久,所以當巨集任務 setTimeout1執行完時, setTimeout2的等待時間並未結束,所以在 2秒後列印日誌 3,實際上並未等待2秒。

下面我們可以再看一個例項:

當程式碼中遇到了非同步請求的事件,又該如何執行,根據上面總結的執行機制,又該得到什麼樣的結果?

第一輪迴圈

  • 同樣從全域性任務入口,遇到巨集任務 setTimeout,交給非同步處理模組,我們暫且先記為 setTimeout1,由於等待時間為 0,直接加入巨集任務佇列。
  • 再次遇到巨集任務 setTimeout,交給非同步處理模組,我們暫且先記為 setTimeout2,同樣直接加入巨集任務佇列。
  • 遇到微任務 then(),加入微任務佇列。
  • 最後遇到列印語句,直接列印日誌 5

第一輪迴圈結束後,可以畫出下圖:

事件迴圈機制的那些事

第二輪迴圈

  • 棧空後,先執行微任務佇列中的 then()方法,輸出 4,此時微任務佇列為空。

事件迴圈機制的那些事

  • 讀取巨集任務佇列的最靠前的任務 setTimeout1
  • 先直接執行列印語句,列印日誌 1,又遇到微任務 then(),加入微任務佇列。第二輪迴圈結束。

事件迴圈機制的那些事

第三輪迴圈

  • 先執行微任務佇列中的 then()方法,輸出 2,此時微任務佇列為空。

事件迴圈機制的那些事

  • 繼續讀取巨集任務佇列的最靠前的任務 setTimeout2
  • 直接執行列印語句,列印日誌 3。第三輪迴圈結束,執行完畢。

事件迴圈機制的那些事

最後我們是我們的boss,歡迎大家在評論區留言寫出自己心中的那個正確答案。

 

相關文章