作者簡介:nekron 螞蟻金服·資料體驗技術團隊
序
一直以來,我對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個概念task和microtask,讓懵逼的我更加凌亂了。。。
不慌不慌,通過仔細閱讀文件得知,這兩個概念屬於對非同步任務的分類,不同的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佇列是否為空,依次執行直至清空佇列
繼續測試(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
。
我陷入了困惑,不過很快明白了,這說明**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的執行混合在一起了)。我繼續執行,有時候又會輸出我預期的答案。
現實真的是如此莫名啊!啊!啊!
(啊,不好意思,血一時止不住)所以,這到底是為什麼???
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#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
行為目前看來和規範一致- 可參考的文件不多
- 不會搜尋,根本不知道核心程式碼從何找起。。。
(略過所有研究過程。。。)
直接看結論,下圖是node
的Event Loop實現:
補充說明:
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
- 優先清空next tick queue,即通過
- 而和規範的區別,在於node會清空當前所處階段的佇列,即執行所有task
重新挑戰測試(2)
瞭解了實現,再回頭看測試(2)
:
// 程式碼簡略表示
// 1
setTimeout(() => {
// ...
})
// 2
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註冊
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
的回撥執行呢?
因為當兩個回撥同時註冊成功後,當前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
對團隊感興趣的同學可以關注專欄或者傳送簡歷至`tao.qit####alibaba-inc.com`.replace(`####`, `@`),歡迎有志之士加入~