Event Loop的規範和實現

螞蟻金服資料體驗技術發表於2019-03-04

作者簡介:nekron 螞蟻金服·資料體驗技術團隊

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

由於上述原因,本文誕生,原本我計劃全文共分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. 首先,我們先排除非同步程式碼,先把同步執行的程式碼找出,可以知道先列印的一定是1、5
  2. 但是,setTimeout和Promise是否有優先順序?還是看執行順序?
  3. 還有,Promise的多級then之間是否會插入setTimeout?

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

那這到底是為什麼呢?

定義

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

  1. 執行最舊的task(一次)
  2. 檢查是否存在microtask,然後不停執行,直到清空佇列(多次)
  3. 執行render

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

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

task主要包含:setTimeoutsetIntervalsetImmediateI/OUI互動事件

microtask主要包含:Promiseprocess.nextTickMutaionObserver

整個最基本的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. 同步執行的程式碼首先輸出:1、7
  2. 接著,清空microtask佇列:8
  3. 第一個task執行:2、4
  4. 接著,清空microtask佇列:5
  5. 第二個task執行:9、11
  6. 接著,清空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的優先順序,所以答案應該是:

  • 第一個task輸出:1、7、6、8、2、4、3、5
  • 然後,第二個task輸出:9、11、10、12

然而,啪啪打臉。。。

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

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

吐血1.jpg | left | 200x117

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

PART 2:實現

俗話說得好:

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

規範無法囊括所有場景,雖然chromenode都基於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#93

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#L456

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

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

直接看結論,下圖是nodeEvent Loop實現:

node_event_loop.png | center | 832x460

補充說明:

  • NodeEvent 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)

// 程式碼簡略表示
// 1
setTimeout(() => {
	// ...
})

// 2
setTimeout(() => {
	// ...
})
複製程式碼

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

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

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

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

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

setImmediate(() => {
	console.log(1)
})

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

setTimeout(() => {
	console.log(1)	
})
複製程式碼

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

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

// 先使用setTimeout註冊
setTimeout(() => {
	// ...
})

// 一系列micro tasks執行,保證setTimeout順利到期
new Promise(resolve => {
	// ...
})
process.nextTick(() => {
	// ...
})

// 再使用setImmediate註冊,“幾乎”確保後執行
setImmediate(() => {
	// ...
})
複製程式碼

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

const fs = require(`fs`)

fs.readFile(`/path/to/file`, () => {
    setTimeout(() => {
        console.log(`timeout`)
    })
    setImmediate(() => {
        console.log(`immediate`)
    })
})
複製程式碼

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

因為當兩個回撥同時註冊成功後,當前nodeEvent 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.nextTicksetImmediate時間複雜度為O(1),效率更高。

如果對執行效率有要求,優先使用process.nextTicksetImmediate

其他

歡迎大家一同補充~

參考

對團隊感興趣的同學可以關注專欄或者傳送簡歷至`tao.qit####alibaba-inc.com`.replace(`####`, `@`),歡迎有志之士加入~

原文地址:github.com/ProtoTeam/b…

相關文章