Event Loop的規範和實現

2021-03-02 Vue中文社區

一直以來,我對Event Loop的認知界定都是可知可不知的分級,因此僅僅保留淺顯的概念,從未真正學習過,直到看了這篇文章——《這一次,徹底弄懂 JavaScript 執行機制》。該文作者寫的非常友好,從最小的例子展開,讓我獲益匪淺,但最後的示例牽扯出了chrome和Node下的運行結果迥異,我很好奇,我覺得有必要對這一塊知識進行學習。

由於上述原因,本文誕生,原本我計劃全文共分3部分來展開:規範、實現、應用。但遺憾的是由於自己的認知尚淺,在如何根據Event Loop的特性來設想應用場景時,實在沒有什麼產出,導致有關應用的篇幅過小,故不在標題中作體現了。

(本文所有代碼運行環境僅包含Node v8.9.4以及 Chrome v63)

PART 1:規範為什麼要有Event Loop?

因為Javascript設計之初就是一門單線程語言,因此為了實現主線程的不阻塞,Event Loop這樣的方案應運而生。

小測試(1)

先來看一段代碼,列印結果會是?

console.log(1)

setTimeout(() => {
 console.log(2)
}, 0)

Promise.resolve().then(() => {
   console.log(3)
}).then(() => {
   console.log(4)
})

console.log(5)

不熟悉Event Loop的我嘗試進行如下分析:

首先,我們先排除異步代碼,先把同步執行的代碼找出,可以知道先列印的一定是1、5

但是,setTimeout和Promise是否有優先級?還是看執行順序?

還有,Promise的多級then之間是否會插入setTimeout?

帶著困惑,我試著運行了一下代碼,正確結果是:1、5、3、4、2。

那這到底是為什麼呢?

定義

看來需要先從規範定義入手,於是查閱一下HTML規範,規範著實詳(luo)細(suo),我就不貼了,提煉下來關鍵步驟如下:

執行最舊的task(一次)

檢查是否存在microtask,然後不停執行,直到清空隊列(多次)

執行render

好傢夥,問題還沒搞明白,一下子又多出來2個概念taskmicrotask,讓懵逼的我更加凌亂了。。。

不慌不慌,通過仔細閱讀文檔得知,這兩個概念屬於對異步任務的分類,不同的API註冊的異步任務會依次進入自身對應的隊列中,然後等待Event Loop將它們依次壓入執行棧中執行。

task主要包含:setTimeout、setInterval、setImmediate、I/O、UI交互事件

microtask主要包含:Promise、process.nextTick、MutaionObserver

整個最基本的Event Loop如圖所示:

queue可以看做一種數據結構,用以存儲需要執行的函數

timer類型的API(setTimeout/setInterval)註冊的函數,等到期後進入task隊列(這裡不詳細展開timer的運行機制)

其餘API註冊函數直接進入自身對應的task/microtask隊列

Event Loop執行一次,從task隊列中拉出一個task執行

Event Loop繼續檢查microtask隊列是否為空,依次執行直至清空隊列

規範.png | center | 585x357繼續測試(2)

這時候,回頭再看下之前的測試(1),發現概念非常清晰,一下子就得出了正確答案,感覺自己萌萌噠,再也不怕Event Loop了~

接著,準備挑戰一下更高難度的問題(本題出自中提到的那篇文章,我先去除了process.nextTick):

console.log(1)
setTimeout(() => {
   console.log(2)
   new Promise(resolve => {
       console.log(4)
       resolve()
   }).then(() => {
       console.log(5)
   })
})
new Promise(resolve => {
   console.log(7)
   resolve()
}).then(() => {
   console.log(8)
})
setTimeout(() => {
   console.log(9)
   new Promise(resolve => {
       console.log(11)
       resolve()
   }).then(() => {
       console.log(12)
   })
})

分析如下:

同步運行的代碼首先輸出:1、7

接著,清空microtask隊列:8

第一個task執行:2、4

接著,清空microtask隊列:5

第二個task執行:9、11

接著,清空microtask隊列:12

在chrome下運行一下,全對!

自信的我膨脹了,準備加上process.nextTick後在node上繼續測試。我先測試第一個task,代碼如下:

console.log(1)
setTimeout(() => {
   console.log(2)
   new Promise(resolve => {
       console.log(4)
       resolve()
   }).then(() => {
       console.log(5)
   })
   process.nextTick(() => {
       console.log(3)
   })
})
new Promise(resolve => {
   console.log(7)
   resolve()
}).then(() => {
   console.log(8)
})
process.nextTick(() => {
   console.log(6)
})

有了之前的積累,我這回自信的寫下了答案:1、7、8、6、2、4、5、3。

然而,帥不過3秒,正確答案是:1、7、6、8、2、4、3、5。

打臉3.png | left | 64x64

我陷入了困惑,不過很快明白了,這說明**process.nextTick註冊的函數優先級高於Promise**,這樣就全說的通了~

接著,我再測試第二個task:

console.log(1)
setTimeout(() => {
   console.log(2)
   new Promise(resolve => {
       console.log(4)
       resolve()
   }).then(() => {
       console.log(5)
   })
   process.nextTick(() => {
       console.log(3)
   })
})
new Promise(resolve => {
   console.log(7)
   resolve()
}).then(() => {
   console.log(8)
})
process.nextTick(() => {
   console.log(6)
})
setTimeout(() => {
   console.log(9)
   process.nextTick(() => {
       console.log(10)
   })
   new Promise(resolve => {
       console.log(11)
       resolve()
   }).then(() => {
       console.log(12)
   })
})

吃一塹長一智,這次我掌握了microtask的優先級,所以答案應該是:

然而,啪啪打臉。。。

我第一次執行,輸出結果是:1、7、6、8、2、4、9、11、3、10、5、12(即兩次task的執行混合在一起了)。我繼續執行,有時候又會輸出我預期的答案。

現實真的是如此莫名啊!啊!啊!

吐血1.jpg | left | 200x117

(啊,不好意思,血一時止不住)所以,這到底是為什麼???

PART 2:實現

俗話說得好:

規範是人定的,代碼是人寫的。 ——無名氏

規範無法囊括所有場景,雖然chrome和node都基於v8引擎,但引擎只負責管理內存堆棧,API還是由各runtime自行設計並實現的。

小測試(3)

Timer是整個Event Loop中非常重要的一環,我們先從timer切入,來切身體會下規範和實現的差異。

首先再來一個小測試,它的輸出會是什麼呢?

setTimeout(() => {
   console.log(2)
}, 2)
setTimeout(() => {
   console.log(1)
}, 1)
setTimeout(() => {
   console.log(0)
}, 0)

沒有深入接觸過timer的同學如果直接從代碼中的延時設置來看,會回答:0、1、2。

而另一些有一定經驗的同學可能會回答:2、1、0。因為MDN的setTimeout文檔中提到HTML規範最低延時為4ms:

(補充說明:最低延時的設置是為了給CPU留下休息時間)

In fact, 4ms is specified by the HTML5 spec and is consistent across browsers released in 2010 and onward. Prior to (Firefox 5.0 / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms.

而真正痛過的同學會告訴你,答案是:1、0、2。並且,無論是chrome還是node下的運行結果都是一致的。

(錯誤訂正:經多次驗證,node下的輸出順序依然是無法保證的,node的timer真是一門玄學~)

Chrome中的timer

從測試(3)結果可以看出,0ms和1ms的延時效果是一致的,那背後的原因是為什麼呢?我們先查查blink的實現。

(Blink代碼託管的地方我都不知道如何進行搜索,還好文件名比較明顯,沒花太久,找到了答案)

(我直接貼出最底層代碼,上層代碼如有興趣請自行查閱)

// https://chromium.googlesource.com/chromium/blink/+/master/Source/core/frame/DOMTimer.cpp
double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);

這裡interval就是傳入的數值,可以看出傳入0和傳入1結果都是oneMillisecond,即1ms。

這樣解釋了為何1ms和0ms行為是一致的,那4ms到底是怎麼回事?我再次確認了HTML規範,發現雖然有4ms的限制,但是是存在條件的,詳見規範第11點:

If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

並且有意思的是,MDN英文文檔的說明也已經貼合了這個規範。

我鬥膽推測,一開始HTML5規範確實有定最低4ms的規範,不過在後續修訂中進行了修改,我認為甚至不排除規範在向實現看齊,即逆向影響。

Node中的timer

那node中,為什麼0ms和1ms的延時效果一致呢?

(還是github託管代碼看起來方便,直接搜到目標代碼)

// https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js
if (!(after >= 1 && after <= TIMEOUT_MAX))
 after = 1; // schedule on next tick, follows browser behavior

代碼中的注釋直接說明了,設置最低1ms的行為是為了向瀏覽器行為看齊。

Node中的Event Loop

上文的timer算一個小插曲,我們現在回歸本文核心——Event Loop

讓我們聚焦在node的實現上,blink的實現本文不做展開,主要是因為:

chrome行為目前看來和規範一致

可參考的文檔不多

不會搜索,根本不知道核心代碼從何找起。。。

原諒1.jpg | left | 264x250

(略過所有研究過程。。。)

直接看結論,下圖是node的Event Loop實現:

node_event_loop.png | center | 832x460

補充說明:

Node的Event Loop分階段,階段有先後,依次是

expired timers and intervals,即到期的setTimeout/setInterval

I/O events,包含文件,網絡等等

immediates,通過setImmediate註冊的函數

close handlers,close事件的回調,比如TCP連接斷開

同步任務及每個階段之後都會清空microtask隊列

優先清空next tick queue,即通過process.nextTick註冊的函數

再清空other queue,常見的如Promise

而和規範的區別,在於node會清空當前所處階段的隊列,即執行所有task

重新挑戰測試(2)

了解了實現,再回頭看測試(2):



setTimeout(() => {
   
})

setTimeout(() => {
   
})

可以看出由於兩個setTimeout延時相同,被合併入了同一個expired timers queue,而一起執行了。所以,只要將第二個setTimeout的延時改成超過2ms(1ms無效,詳見上文),就可以保證這兩個setTimeout不會同時過期,也能夠保證輸出結果的一致性。

那如果我把其中一個setTimeout改為setImmediate,是否也可以做到保證輸出順序?

答案是不能。雖然可以保證setTimeout和setImmediate的回調不會混在一起執行,但無法保證的是setTimeout和setImmediate的回調的執行順序。

在node下,看一個最簡單的例子,下面代碼的輸出結果是無法保證的:

setTimeout(() => {
   console.log(0)
})
setImmediate(() => {
   console.log(1)
})
// or
setImmediate(() => {
   console.log(0)
})
setTimeout(() => {
   console.log(1)
})

問題的關鍵在於setTimeout何時到期,只有到期的setTimeout才能保證在setImmediate之前執行。

不過如果是這樣的例子(2),雖然基本能保證輸出的一致性,不過強烈不推薦:


setTimeout(() => {
   
})

new Promise(resolve => {
   
})
process.nextTick(() => {
   
})

setImmediate(() => {
   
})

或者換種思路來保證順序:

const fs = require('fs')
fs.readFile('/path/to/file', () => {
   setTimeout(() => {
       console.log('timeout')
   })
   setImmediate(() => {
       console.log('immediate')
   })
})

那,為何這樣的代碼能保證setImmediate的回調優先於setTimeout的回調執行呢?

因為當兩個回調同時註冊成功後,當前node的Event Loop正處於I/O queue階段,而下一個階段是immediates queue,所以能夠保證即使setTimeout已經到期,也會在setImmediate的回調之後執行。

PART 3:應用

由於也是剛剛學習Event Loop,無論是依託於規範還是實現,我能想到的應用場景還比較少。那掌握Event Loop,我們能用在哪些地方呢?

查Bug

正常情況下,我們不會碰到非常複雜的隊列場景。不過萬一碰到了,比如執行順序無法保證的情況時,我們可以快速定位到問題。

面試

那什麼時候會有複雜的隊列場景呢?比如面試,保不準會有這種稀奇古怪的測試,這樣就能輕鬆應付了~

執行優先級

說回正經的,如果從規範來看,microtask優先於task執行。那如果有需要優先執行的邏輯,放入microtask隊列會比task更早的被執行,這個特性可以被用於在框架中設計任務調度機制。

如果從node的實現來看,如果時機合適,microtask的執行甚至可以阻塞I/O,是一把雙刃劍。

綜上,高優先級的代碼可以用Promise/process.nextTick註冊執行。

執行效率

從node的實現來看,setTimeout這種timer類型的API,需要創建定時器對象和迭代等操作,任務的處理需要操作小根堆,時間複雜度為O(log(n))。而相對的,process.nextTick和setImmediate時間複雜度為O(1),效率更高。

如果對執行效率有要求,優先使用process.nextTick和setImmediate。

其他

歡迎大家一同補充~

參考

這一次,徹底弄懂 JavaScript 執行機制

Tasks, microtasks, queues and schedules

Event Loop and the Big Picture

Timers, Immediates and Process.nextTick

What you should know to really understand the Node.js Event Loop

Node異步那些事

libuv design

作者:nekron

https://github.com/ProtoTeam/blog/edit/master/201801/2.md

Vue中文社區 獨家公眾號,面向前端愛好者, 每日更新最有料的文章,最前沿的資訊,內容包含但不限於Vue,React,Angular,前端工程化...等各種"大保健"知識點,右上角點關注,老司機帶你彎道超車,不定期更有各種福利贈送 

FEweekly

相關焦點

  • Event Loop 解疑
    譯者說:文章藉助讀取文件時的「同步」和「異步」兩種模式來解釋 Event Loop 到底要解決什麼問題,並用偽代碼的方式說明一個簡單的 Event Loop 是如何實現的。最後,文章還猜測了 V8 引擎的 Event Loop 工作原理。
  • 【第1790期】圖解Event Loop
    Web API包括DOM API、定時器、HTTP請求等特性,可以幫助我們實現異步、非阻塞的行為。當我們調用一個函數時,函數會被放入一個叫做調用棧(call stack,也叫執行上下文棧)的地方。調用棧是JS引擎的一部分,並非瀏覽器特有的。調用棧是一個棧數據結構,具有後進先出的特點(Last in, first out. LIFO)。當函數執行完畢返回時,會被彈出調用棧。
  • Event Loop - 事件隊列
    Event Loop定義:event - 事件 loop - 循環,既然叫事件循環,那麼循環的點在哪?循環的是一個又一個的任務隊列,這些任務隊列由宏任務和微任務構成兩條原則一次處理一個任務一個任務開始後直到完成,不會被其他任務中斷事件處理之間的關係一次事件處理中,最多處理一個宏任務,但是會處理所有的微任務,任務開始後,會將內部所有的宏觀函數加到宏觀隊列等待
  • Out of the loop?
    here are a few articles that might help those who are out of the loop.A loop, you see, is a ring, or anything that’s circular shaped.
  • 從一道面試題談談對 EventLoop 的理解
    棧是只能在某一端插入和刪除的特殊線性表。(也就是執行microtasks隊列裡的任務)7.更新渲染(Update the rendering):可以簡單理解為瀏覽器渲染...8.如果這是一個worker event loop,但是沒有任務在task隊列中,並且WorkerGlobalScope[5]對象的closing標識為true,則銷毀Eveent Loop,中止這些步驟,然後進行定義在Web workers[6]章節的run a worker
  • 《FreeSWITCH: VoIP實戰》: 使用Erlang建立IVR實現複雜業務邏輯
    曾寫過一篇使用XML實現IVR 。但當你要實現更複雜、更智能的業務邏輯時,你免不了跟資料庫或其它系統交互。我們曾用Ruby藉助event_socket實現過比較複雜的功能,但當業務變得更加複雜時,我們使用Erlang重寫了整個邏輯。  什麼是 Erlang ?
  • 看到電子郵件寫「I will keep you in the loop」是什麼意思?
    loop一詞在英文裡是指「環狀物」,舉凡狀似圈圈或是如圓環狀的東西,都可稱為loop。至於身處國際職場的我們,最常看到loop的應該是在英文的電子郵件裡in the loop的片語。把「in the loop」加以衍申,則它還有「在決策圈內」或「處理要務或參與決策的智囊團中人士」之意。例如:Henry Meyer denied that he was in the loop regarding the scandal.
  • Netty的EventLoop和線程模型
    Java5 引入Executor,其線程池通過緩存和重用 Thread 極大提升性能。()使用 EventLoop 調度周期性的任務 EventLoop繼承於ScheduledExecutorService,所以也提供了JDK實現的所有方法,包括之前的schedule()和scheduleAtFixedRate()。
  • 成為R語言高手:再談apply和for loop循環
    但是for loop循環真的那麼不堪麼?for loop和apply的爭論,本質上涉及到兩個問題:這裡說來話有點長,首先要搞清楚小的基本問題,最後我們再綜合起來討論。1)apply系列函數是什麼?在R語言裡面,這種函數是高階函數,也就是函數的函數(這可以看出R語言是支持函數式編程的),英文叫functional,他們可以把函數作為輸入參數,輸出一個vector,和functional對應的另一種函數叫做closure,這裡就不展開說了。R裡面很多functional都是基於lapply實現,那lapply呢?lapply是在c裡面用for loop實現的。
  • EventBus—事件總線
    $on('event2', cb4);// 用$once方法監聽event2,回調函數為cb5vm.$once('event2', cb5);// 觸發event2事件,會執行cb4和cb5vm.$emit('event2');// 再次觸發event2事件,這裡只會執行cb4,不會執行cb5,cb5隻會執行一次vm.
  • 要想實現EventQueue,那麼該Queue應有的三種狀態代碼,都在這了
    實現一個EventQueue,該Queue有如下三種狀態:隊列滿——最多可容納多少個Event,好比一個系統最多同時能夠受理多少業務一樣;隊列空——當所有的Event都被處理並且沒有新的Event被提交的時候,此時隊列將是空的狀態;有Event
  • 美國習慣用語|in the loop
    今天要學的第一個習慣用語是:in the loop。在這個習慣用語裡的loop,它的意思是圈子,但是那可不是繩子系成的圈子,而是指圈內人物,具體說是互通重要情報、共同參與決策的一個群體。如果有人說你在辦公室內是in the loop,那麼他的意思是你的地位比一般人更為重要一些。換句話說,你是接近領導中心的內圍人物。In the loop這個習慣用語也常用在政界的競選運動中。
  • 您知道in the loop是什麼意思嗎?
    今天,我們一起看一下loop這個單詞。單詞loop很簡單,和它相似的單詞有loom和loon等。今天,我們就一起看一下loop的用法。首先,我們看一下loop做名詞的用法。這句話中loop的意思是環、圈,通常指繩、電線等形成的環。打個圈可以表達為make a loop。3、The film is on a loop.這部電影已製成循環音像磁帶。這句話中loop的意思是循環電影膠片、循環音像磁帶。
  • Event的詞源與在漢語中的含義
    另外,在漢語中,事件還被用於不同的領域中,如(法律術語)事件是指法律規範規定的不依當事人意志所轉移的能夠引起法律關係產生、變更與消滅的客觀事實。主要包括自然事件和社會事件。(計算機術語)事件是可以被控制項識別的操作,如按下確定按鈕,選擇某個單選按鈕或者複選框。
  • keep me in the loop是什麼意思?
    A loop is a circular shape formed when something thin crosses over itself.環是一種圓形的形狀,當細的東西在自己身上交叉時形成。Keep me in the loop.A: 好吧,酷。隨時通知我。