天天看點

JavaScript中的Event Loop(事件循環)機制

前言

我們都知道,javascript從誕生之日起就是一門單線程的非阻塞的腳本語言。這是由其最初的用途來決定的:與浏覽器互動。

單線程意味着,javascript代碼在執行的任何時候,都隻有一個主線程來處理所有的任務。

而非阻塞則是當代碼需要進行一項異步任務(無法立刻傳回結果,需要花一定時間才能傳回的任務,如I/O事件)的時候,主線程會挂起(pending)這個任務,然後在異步任務傳回結果的時候再根據一定規則去執行相應的回調。

單線程是必要的,也是javascript這門語言的基石,原因之一在其最初也是最主要的執行環境——浏覽器中,我們需要進行各種各樣的dom操作。試想一下 如果javascript是多線程的,那麼當兩個線程同時對dom進行一項操作,例如一個向其添加事件,而另一個删除了這個dom,此時該如何處理呢?是以,為了保證不會 發生類似于這個例子中的情景,javascript選擇隻用一個主線程來執行代碼,這樣就保證了程式執行的一緻性。

當然,現如今人們也意識到,單線程在保證了執行順序的同時也限制了javascript的效率,是以開發出了web worker技術。這項技術号稱讓javascript成為一門多線程語言。

然而,使用web worker技術開的多線程有着諸多限制,例如:所有新線程都受主線程的完全控制,不能獨立執行。這意味着這些“線程” 實際上應屬于主線程的子線程。另外,這些子線程并沒有執行I/O操作的權限,隻能為主線程分擔一些諸如計算等任務。是以嚴格來講這些線程并沒有完整的功能,也是以這項技術并非改變了javascript語言的單線程本質。

可以預見,未來的javascript也會一直是一門單線程的語言。

話說回來,前面提到javascript的另一個特點是“非阻塞”,那麼javascript引擎到底是如何實作的這一點呢?答案就是今天這篇文章的主角——event loop(事件循環)。

注:雖然nodejs中的也存在與傳統浏覽器環境下的相似的事件循環。然而兩者間卻有着諸多不同,故把兩者分開,單獨解釋。

正文

浏覽器環境下js引擎的事件循環機制

1.執行棧與事件隊列

當javascript代碼執行的時候會将不同的變量存于記憶體中的不同位置:堆(heap)和棧(stack)中來加以區分。其中,堆裡存放着一些對象。而棧中則存放着一些基礎類型變量以及對象的指針。 但是我們這裡說的執行棧和上面這個棧的意義卻有些不同。

我們知道,當我們調用一個方法的時候,js會生成一個與這個方法對應的執行環境(context),又叫執行上下文。這個執行環境中存在着這個方法的私有作用域,上層作用域的指向,方法的參數,這個作用域中定義的變量以及這個作用域的this對象。 而當一系列方法被依次調用的時候,因為js是單線程的,同一時間隻能執行一個方法,于是這些方法被排隊在一個單獨的地方。這個地方被稱為執行棧。

當一個腳本第一次執行的時候,js引擎會解析這段代碼,并将其中的同步代碼按照執行順序加入執行棧中,然後從頭開始執行。如果目前執行的是一個方法,那麼js會向執行棧中添加這個方法的執行環境,然後進入這個執行環境繼續執行其中的代碼。當這個執行環境中的代碼 執行完畢并傳回結果後,js會退出這個執行環境并把這個執行環境銷毀,回到上一個方法的執行環境。。這個過程反複進行,直到執行棧中的代碼全部執行完畢。

下面這個圖檔非常直覺的展示了這個過程,其中的global就是初次運作腳本時向執行棧中加入的代碼:

JavaScript中的Event Loop(事件循環)機制

從圖檔可知,一個方法執行會向執行棧中加入這個方法的執行環境,在這個執行環境中還可以調用其他方法,甚至是自己,其結果不過是在執行棧中再添加一個執行環境。這個過程可以是無限進行下去的,除非發生了棧溢出,即超過了所能使用記憶體的最大值。

以上的過程說的都是同步代碼的執行。那麼當一個異步代碼(如發送ajax請求資料)執行後會如何呢?前文提過,js的另一大特點是非阻塞,實作這一點的關鍵在于下面要說的這項機制——事件隊列(Task Queue)。

js引擎遇到一個異步事件後并不會一直等待其傳回結果,而是會将這個事件挂起,繼續執行執行棧中的其他任務。當一個異步事件傳回結果後,js會将這個事件加入與目前執行棧不同的另一個隊列,我們稱之為事件隊列。被放入事件隊列不會立刻執行其回調,而是等待目前執行棧中的所有任務都執行完畢, 主線程處于閑置狀态時,主線程會去查找事件隊列是否有任務。如果有,那麼主線程會從中取出排在第一位的事件,并把這個事件對應的回調放入執行棧中,然後執行其中的同步代碼...,如此反複,這樣就形成了一個無限的循環。這就是這個過程被稱為“事件循環(Event Loop)”的原因。

這裡還有一張圖來展示這個過程:

JavaScript中的Event Loop(事件循環)機制

圖中的stack表示我們所說的執行棧,web apis則是代表一些異步事件,而callback queue即事件隊列。

2.macro task與micro task

以上的事件循環過程是一個宏觀的表述,實際上因為異步任務之間并不相同,是以他們的執行優先級也有差別。不同的異步任務被分為兩類:微任務(micro task)和宏任務(macro task)。

以下事件屬于宏任務:

  • setInterval()

  • setTimeout()

以下事件屬于微任務

  • new Promise()

  • new MutaionObserver()

前面我們介紹過,在一個事件循環中,異步事件傳回結果後會被放到一個任務隊列中。然而,根據這個異步事件的類型,這個事件實際上會被對應的宏任務隊列或者微任務隊列中去。并且在目前執行棧為空的時候,主線程會 檢視微任務隊列是否有事件存在。如果不存在,那麼再去宏任務隊列中取出一個事件并把對應的回到加入目前執行棧;如果存在,則會依次執行隊列中事件對應的回調,直到微任務隊列為空,然後去宏任務隊列中取出最前面的一個事件,把對應的回調加入目前執行棧...如此反複,進入循環。

我們隻需記住當目前執行棧執行完畢時會立刻先處理所有微任務隊列中的事件,然後再去宏任務隊列中取出一個事件。同一次事件循環中,微任務永遠在宏任務之前執行。

這樣就能解釋下面這段代碼的結果:

setTimeout(function () {
    console.log(1);
});

new Promise(function(resolve,reject){
    console.log(2)
    resolve(3)
}).then(function(val){
    console.log(val);
})
           

結果為:

2
3
1
 
           

node環境下的事件循環機制

1.與浏覽器環境有何不同?

在node中,事件循環表現出的狀态與浏覽器中大緻相同。不同的是node中有一套自己的模型。node中事件循環的實作是依靠的libuv引擎。我們知道node選擇chrome v8引擎作為js解釋器,v8引擎将js代碼分析後去調用對應的node api,而這些api最後則由libuv引擎驅動,執行對應的任務,并把不同的事件放在不同的隊列中等待主線程執行。 是以實際上node中的事件循環存在于libuv引擎中。

2.事件循環模型

下面是一個libuv引擎中的事件循環的模型:

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
           

注:模型中的每一個方塊代表事件循環的一個階段

這個模型是node官網上的一篇文章中給出的,我下面的解釋也都來源于這篇文章。我會在文末把文章位址貼出來,有興趣的朋友可以親自與看看原文。

3.事件循環各階段詳解

從上面這個模型中,我們可以大緻分析出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事件的回調。

下面我們來按照代碼第一次進入libuv引擎後的順序來詳細解說這些階段:

poll階段

當個v8引擎将js代碼解析後傳入libuv引擎後,循環首先進入poll階段。poll階段的執行邏輯如下: 先檢視poll queue中是否有事件,有任務就按先進先出的順序依次執行回調。 當queue為空時,會檢查是否有setImmediate()的callback,如果有就進入check階段執行這些callback。但同時也會檢查是否有到期的timer,如果有,就把這些到期的timer的callback按照調用順序放到timer queue中,之後循環會進入timer階段執行queue中的 callback。 這兩者的順序是不固定的,收到代碼運作的環境的影響。如果兩者的queue都是空的,那麼loop會在poll階段停留,直到有一個i/o事件傳回,循環會進入i/o callback階段并立即執行這個事件的callback。

值得注意的是,poll階段在執行poll queue中的回調時實際上不會無限的執行下去。有兩種情況poll階段會終止執行poll queue中的下一個回調:1.所有回調執行完畢。2.執行數超過了node的限制。

check階段

check階段專門用來執行

setImmediate()

方法的回調,當poll階段進入空閑狀态,并且setImmediate queue中有callback時,事件循環進入這個階段。

close階段

當一個socket連接配接或者一個handle被突然關閉時(例如調用了

socket.destroy()

方法),close事件會被發送到這個階段執行回調。否則事件會用

process.nextTick()

方法發送出去。

timer階段

這個階段以先進先出的方式執行所有到期的timer加入timer隊列裡的callback,一個timer callback指得是一個通過setTimeout或者setInterval函數設定的回調函數。

I/O callback階段

如上文所言,這個階段主要執行大部分I/O事件的回調,包括一些為作業系統執行的回調。例如一個TCP連接配接生錯誤時,系統需要執行回調來獲得這個錯誤的報告。

4.process.nextTick,setTimeout與setImmediate的差別與使用場景

在node中有三個常用的用來推遲任務執行的方法:process.nextTick,setTimeout(setInterval與之相同)與setImmediate

這三者間存在着一些非常不同的差別:

process.nextTick()

盡管沒有提及,但是實際上node中存在着一個特殊的隊列,即nextTick queue。這個隊列中的回調執行雖然沒有被表示為一個階段,當時這些事件卻會在每一個階段執行完畢準備進入下一個階段時優先執行。當事件循環準備進入下一個階段之前,會先檢查nextTick queue中是否有任務,如果有,那麼會先清空這個隊列。與執行poll queue中的任務不同的是,這個操作在隊列清空前是不會停止的。這也就意味着,錯誤的使用

process.nextTick()

方法會導緻node進入一個死循環。。直到記憶體洩漏。

那麼合适使用這個方法比較合适呢?下面有一個例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});
           

這個例子中當,當listen方法被調用時,除非端口被占用,否則會立刻綁定在對應的端口上。這意味着此時這個端口可以立刻觸發listening事件并執行其回調。然而,這時候

on('listening)

還沒有将callback設定好,自然沒有callback可以執行。為了避免出現這種情況,node會在listen事件中使用

process.nextTick()

方法,確定事件在回調函數綁定後被觸發。

setTimeout()和setImmediate()

在三個方法中,這兩個方法最容易被弄混。實際上,某些情況下這兩個方法的表現也非常相似。然而實際上,這兩個方法的意義卻大為不同。

setTimeout()

方法是定義一個回調,并且希望這個回調在我們所指定的時間間隔後第一時間去執行。注意這個“第一時間執行”,這意味着,受到作業系統和目前執行任務的諸多影響,該回調并不會在我們預期的時間間隔後精準的執行。執行的時間存在一定的延遲和誤差,這是不可避免的。node會在可以執行timer回調的第一時間去執行你所設定的任務。

setImmediate()

方法從意義上将是立刻執行的意思,但是實際上它卻是在一個固定的階段才會執行回調,即poll階段之後。有趣的是,這個名字的意義和之前提到過的

process.nextTick()

方法才是最比對的。node的開發者們也清楚這兩個方法的命名上存在一定的混淆,他們表示不會把這兩個方法的名字調換過來---因為有大量的node程式使用着這兩個方法,調換命名所帶來的好處與它的影響相比不值一提。

setTimeout()

和不設定時間間隔的

setImmediate()

表現上及其相似。猜猜下面這段代碼的結果是什麼?

setTimeout(() => {
    console.log('timeout');
}, 0);

setImmediate(() => {
    console.log('immediate');
});
           

實際上,答案是不一定。沒錯,就連node的開發者都無法準确的判斷這兩者的順序誰前誰後。這取決于這段代碼的運作環境。運作環境中的各種複雜的情況會導緻在同步隊列裡兩個方法的順序随機決定。但是,在一種情況下可以準确判斷兩個方法回調的執行順序,那就是在一個I/O事件的回調中。下面這段代碼的順序永遠是固定的:

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});
           

答案永遠是:

immediate
timeout
           

因為在I/O事件的回調中,setImmediate方法的回調永遠在timer的回調前執行。

繼續閱讀