一直以來,我對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隊列是否為空,依次執行直至清空隊列
規範.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