非同步程式設計之事件迴圈機制

vivo網際網路技術發表於2020-12-08

JavaScript 是一門單執行緒語言,我們可以通過非同步程式設計的方式來實現實現類似於多執行緒語言的併發操作。

本文著重講解通過事件迴圈機制來實現多個非同步操作的有序執行、併發執行;通過事件佇列實現同級多個併發操作的先後執行順序,通過微任務和巨集任務的概念來講解不同階段任務執行的先後順序,最後通過將瀏覽器和 Node 下的事件迴圈機制進行對比,對比其事件迴圈機制的不同之處,以及在 Node 端通過libuv引擎來實現多個非同步任務的併發執行。

一、前言

我們知道JavaScript 是一門單執行緒語言,對於大多數人而言,單執行緒最大的好處是不用像多執行緒那樣處處在意狀態的同步問題,這裡沒有死鎖的存在,也沒有像多執行緒之間來回切換帶來效能上的開銷。同樣,單執行緒也存在自身的弱點,主要表現在以下幾個方面:

  1. 無法利用多核cpu,一個簡單的例子,在一個位置從同一臺伺服器拉取不同的資源,如果採用單執行緒同步的方式去拉取,程式碼大致如下:

    getData(‘from_db’),//耗時為M,
    getData(‘from_db_api’),//耗時為N,
    如果採用同步單執行緒的方式總共耗時為:M+N

     

  2. js程式碼錯誤或者耗時過長會阻塞後面程式碼的執行,例如頁面在進行dom渲染時,如果頁面的js程式碼報錯會引起整個頁面白屏的現象。

  3. 大量計算佔用CPU導致無法繼續呼叫非同步I/O。
    後來HTML5定製了Web Workers能夠建立多執行緒來進行計算,但是使用Web Workers技術開的多執行緒有著諸多的限制,例如:所有新執行緒都受主執行緒的完全控制,不能獨立執行。這意味著這些“執行緒” 實際上應屬於主執行緒的子執行緒。另外,這些子執行緒並沒有執行I/O操作的許可權,只能為主執行緒分擔一些簡單的計算任務。所以嚴格來講這些執行緒並沒有完整的功能,也因此這項技術並非改變了 JavaScript 語言的單執行緒本質。


    所以我們可以預見,未來的 JavaScript 依然會是一門單執行緒語言,因此JavaScript採用非同步程式設計方式實現程式“非阻塞”的特點,那麼我們如何實現這一特徵了,答案就是我們今天要講的——event loop(事件迴圈)。

二、瀏覽器下的事件迴圈機制

1、執行棧

JavaScript變數主要儲存在堆和棧兩個位置,其中,堆裡主要儲存物件,棧主要儲存基本型別的變數以及指標變數。當我們呼叫一個方法時,JS 會生成一個與這個方法對應的執行環境,又叫執行上下文,當一系列方法被呼叫時,由於我們的js是單執行緒的,所以這些方法會被單獨排在一個地方,這個地方叫做執行棧。
當一個指令碼第一次執行的時候,JS  引擎會解析這段程式碼,並將其中的同步程式碼按照執行順序加入執行棧中,然後從頭開始執行。如果當前執行的是一個方法,那麼 JS 會向執行棧中新增這個方法的執行環境,然後進入這個執行環境繼續執行其中的程式碼。當這個執行環境中的程式碼 執行完畢並返回結果後,JS 會退出這個執行環境並把這個執行環境銷燬,回到上一個方法的執行環境。這個過程反覆進行,直到執行棧中的程式碼全部執行完畢。

2、事件佇列

以上說的都是 JS 同步程式碼的執行,那麼當程式執行非同步程式碼後會如何進行呢?我們前面提到過 JS 最大的特點是非阻塞,下面我們說一下實現這一點的關鍵在於這項機制——事件佇列。

當js引擎遇到一個非同步事件後不會一直等待返回結果,這個事件會先掛起,繼續執行執行棧中的其他任務,直到這個非同步事件的結果返回,JS 引擎會將這個事件放入與當前執行棧不同的一個佇列中,我們稱之為事件佇列。

被放入事件佇列不會立刻執行其回撥,而是等待當前執行棧中的所有任務都執行完畢, 主執行緒處於閒置狀態時,主執行緒會去查詢事件佇列是否有任務。如果有,那麼主執行緒會從中取出排在第一位的事件,並把這個事件對應的回撥放入執行棧中,然後執行其中的同步程式碼...,如此反覆,這樣就形成了一個無限的迴圈。這就是這個過程被稱為“事件迴圈(Event Loop)”的原因。

非同步程式設計之事件迴圈機制

(圖片來源:網路)

 

3、微任務和巨集任務

關於微任務和巨集任務我們可以用一張圖來說明:

非同步程式設計之事件迴圈機制

(圖片來源:網路)

在一個事件迴圈中,非同步事件返回結果後會被放到一個任務佇列中。然而,根據這個非同步事件的型別,這個事件實際上會被對應的巨集任務佇列或者微任務佇列中去。並且在當前執行棧為空的時候,主執行緒會 檢視微任務佇列是否有事件存在。如果不存在,那麼再去巨集任務佇列中取出一個事件並把對應的回撥加入當前執行棧;如果存在,則會依次執行佇列中事件對應的回撥,直到微任務佇列為空,然後去巨集任務佇列中取出最前面的一個事件,把對應的回撥加入當前執行棧...如此反覆,進入迴圈。

巨集任務主要包含:script( 整體程式碼)、setTimeout、setInterval、I/O、UI 互動事件、setImmediate(Node.js 環境)

微任務主要包含:Promise、MutaionObserver、process.nextTick(Node.js 環境)

三、Node環境下的事件迴圈模型

與瀏覽器有何異同?

在 Node 中,事件迴圈表現出的狀態與瀏覽器中大致相同。不同的是 Node  中有一套自己的模型。Node  中事件迴圈的實現是依靠的libuv引擎。我們知道 Node  選擇Chrome V8引擎作為js直譯器,V8引擎將js程式碼分析後去呼叫對應的Node   api,而這些api最後則由libuv引擎驅動,執行對應的任務,並把不同的事件放在不同的佇列中等待主執行緒執行。因此實際上 Node  中的事件迴圈存在於libuv引擎中。

非同步程式設計之事件迴圈機制

(圖片來源:網路)

 

從上面這個模型中,我們可以大致分析出 Node  中的事件迴圈的順序:

外部輸入資料-->輪詢階段(poll)-->檢查階段(check)-->關閉事件回撥階段(close callback)-->定時器檢測階段(timer)-->I/O事件回撥階段(I/O callbacks)-->閒置階段(idle, prepare)-->輪詢階段...

以上各階段的名稱是根據我個人理解的翻譯,為了避免錯誤和歧義,下面解釋的時候會用英文來表示這些階段。這些階段大致的功能如下:

timers: 這個階段執行定時器佇列中的回撥如 setTimeout() 和 setInterval()。

  • I/O callbacks: 這個階段執行幾乎所有的回撥。但是不包括close事件,定時器和setImmediate()的回撥。
  • idle, prepare: 這個階段僅在內部使用,可以不必理會。
  • poll: 等待新的I/O事件,node在一些特殊情況下會阻塞在這裡。
  • check: setImmediate()的回撥會在這個階段執行。
  • close callbacks: 例如socket.on('close', ...)這種close事件的回撥。

四、小結

JavaScript事件迴圈是非常重要的一個基礎概念,我們可以通過這種機制實現非同步程式設計,解決JavaScript同步單執行緒無法實現併發操作的問題,可以使我們對一段非同步程式碼的執行順序有一個清晰的認識,從而減少程式碼執行的不確定性。合理的使用各種延遲事件的方法,有助於程式碼更好的按照其優先順序去執行。

作者:Liu Gang

相關文章