前言
我在學習瀏覽器和NodeJS的Event Loop時看了大量的文章,那些文章都寫的很好,但是往往是每篇文章有那麼幾個關鍵的點,很多篇文章湊在一起綜合來看,纔可以對這些概念有較爲深入的理解。
於是,我在看了大量文章之後,想要寫這麼一篇博客,不採用官方的描述,結合自己的理解以及示例代碼,用最通俗的語言表達出來。希望大家可以通過這篇文章,瞭解到Event Loop到底是一種什麼機制,瀏覽器和NodeJS的Event Loop又有什麼區別。如果在文中出現書寫錯誤的地方,歡迎大家留言一起探討。
(PS:說到Event Loop肯定會提到Promise,我根據Promise A+規範自己實現了一個簡易Promise庫,源碼放到Github上,大家有需要的可以當做參考,後續我也會也寫一篇博客來講Promise,如果對你有用,就請給個Star吧~)
正文
Event Loop是什麼
event loop是一個執行模型,在不同的地方有不同的實現。瀏覽器和NodeJS基於不同的技術實現了各自的Event Loop。
- 瀏覽器的Event Loop是在html5的規範中明確定義。
- NodeJS的Event Loop是基於libuv實現的。可以參考Node的官方文檔以及libuv的官方文檔。
- libuv已經對Event Loop做出了實現,而HTML5規範中只是定義了瀏覽器中Event Loop的模型,具體的實現留給了瀏覽器廠商。
宏隊列和微隊列
宏隊列,macrotask,也叫tasks。 一些異步任務的回調會依次進入macro task queue,等待後續被調用,這些異步任務包括:
- setTimeout
- setInterval
- setImmediate (Node獨有)
- requestAnimationFrame (瀏覽器獨有)
- I/O
- UI rendering (瀏覽器獨有)
微隊列,microtask,也叫jobs。 另一些異步任務的回調會依次進入micro task queue,等待後續被調用,這些異步任務包括:
- process.nextTick (Node獨有)
- Promise
- Object.observe
- MutationObserver
(注:這裏只針對瀏覽器和NodeJS)
瀏覽器的Event Loop
我們先來看一張圖,再看完這篇文章後,請返回來再仔細看一下這張圖,相信你會有更深的理解。
這張圖將瀏覽器的Event Loop完整的描述了出來,我來講執行一個JavaScript代碼的具體流程:
- 執行全局Script同步代碼,這些同步代碼有一些是同步語句,有一些是異步語句(比如setTimeout等);
- 全局Script代碼執行完畢後,調用棧Stack會清空;
- 從微隊列microtask queue中取出位於隊首的回調任務,放入調用棧Stack中執行,執行完後microtask queue長度減1;
- 繼續取出位於隊首的任務,放入調用棧Stack中執行,以此類推,直到直到把microtask queue中的所有任務都執行完畢。注意,如果在執行microtask的過程中,又產生了microtask,那麼會加入到隊列的末尾,也會在這個週期被調用執行;
- microtask queue中的所有任務都執行完畢,此時microtask queue爲空隊列,調用棧Stack也爲空;
- 取出宏隊列macrotask queue中位於隊首的任務,放入Stack中執行;
- 執行完畢後,調用棧Stack爲空;
- 重複第3-7個步驟;
- 重複第3-7個步驟;
- ......
可以看到,這就是瀏覽器的事件循環Event Loop
這裏歸納3個重點:
- 宏隊列macrotask一次只從隊列中取一個任務執行,執行完後就去執行微任務隊列中的任務;
- 微任務隊列中所有的任務都會被依次取出來執行,知道microtask queue爲空;
- 圖中沒有畫UI rendering的節點,因爲這個是由瀏覽器自行判斷決定的,但是隻要執行UI rendering,它的節點是在執行完所有的microtask之後,下一個macrotask之前,緊跟着執行UI render。
好了,概念性的東西就這麼多,來看幾個示例代碼,測試一下你是否掌握了:
console.log(1); setTimeout(() => { console.log(2); Promise.resolve().then(() => { console.log(3) }); }); new Promise((resolve, reject) => { console.log(4) resolve(5) }).then((data) => { console.log(data); }) setTimeout(() => { console.log(6); }) console.log(7); 複製代碼
這裏結果會是什麼呢?運用上面瞭解到的知識,先自己做一下試試看。
// 正確答案 1 4 7 5 2 3 6 複製代碼
你答對了嗎?
我們來分析一下整個流程:
- 執行全局Script代碼
Step 1
console.log(1) 複製代碼
Stack Queue: [console]
Macrotask Queue: []
Microtask Queue: []
打印結果: 1
Step 2
setTimeout(() => { // 這個回調函數叫做callback1,setTimeout屬於macrotask,所以放到macrotask queue中 console.log(2); Promise.resolve().then(() => { console.log(3) }); }); 複製代碼
Stack Queue: [setTimeout]
Macrotask Queue: [callback1]
Microtask Queue: []
打印結果: 1
Step 3
new Promise((resolve, reject) => { // 注意,這裏是同步執行的,如果不太清楚,可以去看一下我開頭自己實現的promise啦~~ console.log(4) resolve(5) }).then((data) => { // 這個回調函數叫做callback2,promise屬於microtask,所以放到microtask queue中 console.log(data); }) 複製代碼
Stack Queue: [promise]
Macrotask Queue: [callback1]
Microtask Queue: [callback2]
打印結果: 1 4
Step 5
setTimeout(() => { // 這個回調函數叫做callback3,setTimeout屬於macrotask,所以放到macrotask queue中 console.log(6); }) 複製代碼
Stack Queue: [setTimeout]
Macrotask Queue: [callback1, callback3]
Microtask Queue: [callback2]
打印結果: 1 4
Step 6
console.log(7) 複製代碼
Stack Queue: [console]
Macrotask Queue: [callback1, callback3]
Microtask Queue: [callback2]
打印結果: 1 4 7
- 好啦,全局Script代碼執行完了,進入下一個步驟,從microtask queue中依次取出任務執行,直到microtask queue隊列爲空。
Step 7
console.log(data) // 這裏data是Promise的決議值5 複製代碼
Stack Queue: [callback2]
Macrotask Queue: [callback1, callback3]
Microtask Queue: []
打印結果: 1 4 7 5
- 這裏microtask queue中只有一個任務,執行完後開始從宏任務隊列macrotask queue中取位於隊首的任務執行
Step 8
console.log(2) 複製代碼
Stack Queue: [callback1]
Macrotask Queue: [callback3]
Microtask Queue: []
打印結果: 1 4 7 5 2
但是,執行callback1的時候又遇到了另一個Promise,Promise異步執行完後在microtask queue中又註冊了一個callback4回調函數
Step 9
Promise.resolve().then(() => { // 這個回調函數叫做callback4,promise屬於microtask,所以放到microtask queue中 console.log(3) }); 複製代碼
Stack Queue: [promise]
Macrotask v: [callback3]
Microtask Queue: [callback4]
打印結果: 1 4 7 5 2
- 取出一個宏任務macrotask執行完畢,然後再去微任務隊列microtask queue中依次取出執行
Step 10
console.log(3) 複製代碼
Stack Queue: [callback4]
Macrotask Queue: [callback3]
Microtask Queue: []
打印結果: 1 4 7 5 2 3
- 微任務隊列全部執行完,再去宏任務隊列中取第一個任務執行
Step 11
console.log(6) 複製代碼
Stack Queue: [callback3]
Macrotask Queue: []
Microtask Queue: []
打印結果: 1 4 7 5 2 3 6
- 以上,全部執行完後,Stack Queue爲空,Macrotask Queue爲空,Micro Queue爲空
Stack Queue: []
Macrotask Queue: []
Microtask Queue: []
最終打印結果: 1 4 7 5 2 3 6
因爲是第一個例子,所以這裏分析的比較詳細,大家仔細看一下,接下來我們再來一個例子:
console.log(1); setTimeout(() => { console.log(2); Promise.resolve().then(() => { console.log(3) }); }); new Promise((resolve, reject) => { console.log(4) resolve(5) }).then((data) => { console.log(data); Promise.resolve().then(() => { console.log(6) }).then(() => { console.log(7) setTimeout(() => { console.log(8) }, 0); }); }) setTimeout(() => { console.log(9); }) console.log(10); 複製代碼
最終輸出結果是什麼呢?參考前面的例子,好好想一想......
// 正確答案 1 4 10 5 6 7 2 3 9 8 複製代碼
相信大家都答對了,這裏的關鍵在前面已經提過:
在執行微隊列microtask queue中任務的時候,如果又產生了microtask,那麼會繼續添加到隊列的末尾,也會在這個週期執行,直到microtask queue爲空停止。
注:當然如果你在microtask中不斷的產生microtask,那麼其他宏任務macrotask就無法執行了,但是這個操作也不是無限的,拿NodeJS中的微任務process.nextTick()來說,它的上限是1000個,後面我們會講到。
瀏覽器的Event Loop就說到這裏,下面我們看一下NodeJS中的Event Loop,它更復雜一些,機制也不太一樣。
NodeJS中的Event Loop
libuv
先來看一張libuv的結構圖:
NodeJS中的宏隊列和微隊列
NodeJS的Event Loop中,執行宏隊列的回調任務有6個階段,如下圖:
各個階段執行的任務如下:
- timers階段:這個階段執行setTimeout和setInterval預定的callback
- I/O callback階段:執行除了close事件的callbacks、被timers設定的callbacks、setImmediate()設定的callbacks這些之外的callbacks
- idle, prepare階段:僅node內部使用
- poll階段:獲取新的I/O事件,適當的條件下node將阻塞在這裏
- check階段:執行setImmediate()設定的callbacks
- close callbacks階段:執行socket.on('close', ....)這些callbacks
NodeJS中宏隊列主要有4個
由上面的介紹可以看到,回調事件主要位於4個macrotask queue中:
- Timers Queue
- IO Callbacks Queue
- Check Queue
- Close Callbacks Queue
這4個都屬於宏隊列,但是在瀏覽器中,可以認爲只有一個宏隊列,所有的macrotask都會被加到這一個宏隊列中,但是在NodeJS中,不同的macrotask會被放置在不同的宏隊列中。
NodeJS中微隊列主要有2個:
- Next Tick Queue:是放置process.nextTick(callback)的回調任務的
- Other Micro Queue:放置其他microtask,比如Promise等
在瀏覽器中,也可以認爲只有一個微隊列,所有的microtask都會被加到這一個微隊列中,但是在NodeJS中,不同的microtask會被放置在不同的微隊列中。
具體可以通過下圖加深一下理解:
大體解釋一下NodeJS的Event Loop過程:
- 執行全局Script的同步代碼
- 執行microtask微任務,先執行所有Next Tick Queue中的所有任務,再執行Other Microtask Queue中的所有任務
- 開始執行macrotask宏任務,共6個階段,從第1個階段開始執行相應每一個階段macrotask中的所有任務,注意,這裏是所有每個階段宏任務隊列的所有任務,在瀏覽器的Event Loop中是隻取宏隊列的第一個任務出來執行,每一個階段的macrotask任務執行完畢後,開始執行微任務,也就是步驟2
- Timers Queue -> 步驟2 -> I/O Queue -> 步驟2 -> Check Queue -> 步驟2 -> Close Callback Queue -> 步驟2 -> Timers Queue ......
- 這就是Node的Event Loop
關於NodeJS的macrotask queue和microtask queue,我畫了兩張圖,大家作爲參考:
好啦,概念理解了我們通過幾個例子來實戰一下:
第一個例子
console.log('start'); setTimeout(() => { // callback1 console.log(111); setTimeout(() => { // callback2 console.log(222); }, 0); setImmediate(() => { // callback3 console.log(333); }) process.nextTick(() => { // callback4 console.log(444); }) }, 0); setImmediate(() => { // callback5 console.log(555); process.nextTick(() => { // callback6 console.log(666); }) }) setTimeout(() => { // callback7 console.log(777); process.nextTick(() => { // callback8 console.log(888); }) }, 0); process.nextTick(() => { // callback9 console.log(999); }) console.log('end'); 複製代碼
更新 2018.9.20
上面這段代碼你執行的結果可能會有多種情況,原因解釋如下。
- setTimeout(fn, 0)不是嚴格的0,一般是setTimeout(fn, 3)或什麼,會有一定的延遲時間,當setTimeout(fn, 0)和setImmediate(fn)出現在同一段同步代碼中時,就會存在兩種情況。
- 第1種情況:同步代碼執行完了,Timer還沒到期,setImmediate回調先註冊到Check Queue中,開始執行微隊列,然後是宏隊列,先從Timers Queue中開始,發現沒回調,往下走直到Check Queue中有回調,執行,然後timer到期(只要在執行完Timer Queue後到期效果就都一樣),timer回調註冊到Timers Queue中,下一輪循環執行到Timers Queue中才能執行那個timer 回調;所以,這種情況下,setImmediate(fn)回調先於setTimeout(fn, 0)回調執行。
- 第2種情況:同步代碼還沒執行完,timer先到期,timer回調先註冊到Timers Queue中,執行到setImmediate了,它的回調再註冊到Check Queue中。 然後,同步代碼執行完了,執行微隊列,然後開始先執行Timers Queue,先執行Timer 回調,再到Check Queue,執行setImmediate回調;所以,這種情況下,setTimeout(fn, 0)回調先於setImmediate(fn)回調執行。
- 所以,在同步代碼中同時調setTimeout(fn, 0)和setImmediate情況是不確定的,但是如果把他們放在一個IO的回調,比如readFile('xx', function () {// ....})回調中,那麼IO回調是在IO Queue中,setTimeout到期回調註冊到Timers Queue,setImmediate回調註冊到Check Queue,IO Queue執行完到Check Queue,timer Queue得到下個週期,所以setImmediate回調這種情況下肯定比setTimeout(fn, 0)回調先執行。
綜上,這個例子是不太好的,setTimeout(fn, 0)和setImmediate(fn)如果想要保證結果唯一,就放在一個IO Callback中吧,上面那段代碼可以把所有它倆同步執行的代碼都放在一個IO Callback中,結果就唯一了。
更新結束
請運用前面學到的知識,仔細分析一下......
// 正確答案 start end 999 111 777 444 888 555 333 666 222 複製代碼
你答對了嗎?我們來一起分析一下:
- 執行全局Script代碼,先打印start,向下執行,將setTimeout的回調callback1註冊到Timers Queue中,再向下執行,將setImmediate的回調callback5註冊到Check Queue中,接着向下執行,將setTimeout的回調callback7註冊到Timers Queue中,繼續向下,將process.nextTick的回調callback9註冊到微隊列Next Tick Queue中,最後一步打印end。此時,各個隊列的回調情況如下:
宏隊列
Timers Queue: [callback1, callback7]
Check Queue: [callback5]
IO Callback Queue: []
Close Callback Queue: []
微隊列
Next Tick Queue: [callback9]
Other Microtask Queue: []
打印結果 start end
- 全局Script執行完了,開始依次執行微任務Next Tick Queue中的全部回調任務。此時Next Tick Queue中只有一個callback9,將其取出放入調用棧中執行,打印999。
宏隊列
Timers Queue: [callback1, callback7]
Check Queue: [callback5]
IO Callback Queue: []
Close Callback Queue: []
微隊列
Next Tick Queue: []
Other Microtask Queue: []
打印結果 start end 999
- 開始依次執行6個階段各自宏隊列中的所有任務,先執行第1個階段Timers Queue中的所有任務,先取出callback1執行,打印111,callback1函數繼續向下,依次把callback2放入Timers Queue中,把callback3放入Check Queue中,把callback4放入Next Tick Queue中,然後callback1執行完畢。再取出Timers Queue中此時排在首位的callback7執行,打印777,把callback8放入Next Tick Queue中,執行完畢。此時,各隊列情況如下:
宏隊列
Timers Queue: [callback2]
Check Queue: [callback5, callback3]
IO Callback Queue: []
Close Callback Queue: []
微隊列
Next Tick Queue: [callback4, callback8]
Other Microtask Queue: []
打印結果 start end 999 111 777
- 6個階段每階段的宏任務隊列執行完畢後,都會開始執行微任務,此時,先取出Next Tick Queue中的所有任務執行,callback4開始執行,打印444,然後callback8開始執行,打印888,Next Tick Queue執行完畢,開始執行Other Microtask Queue中的任務,因爲裏面爲空,所以繼續向下。
宏隊列
Timers Queue: [callback2]
Check Queue: [callback5, callback3]
IO Callback Queue: []
Close Callback Queue: []
微隊列
Next Tick Queue: []
Other Microtask Queue: []
打印結果 start end 999 111 777 444 888
- 第2個階段IO Callback Queue隊列爲空,跳過,第3和第4個階段一般是Node內部使用,跳過,進入第5個階段Check Queue。取出callback5執行,打印555,把callback6放入Next Tick Queue中,執行callback3,打印333。
宏隊列
Timers Queue: [callback2]
Check Queue: []
IO Callback Queue: []
Close Callback Queue: []
微隊列
Next Tick Queue: [callback6]
Other Microtask Queue: []
打印結果 start end 999 111 777 444 888 555 333
- 執行微任務隊列,先執行Next Tick Queue,取出callback6執行,打印666,執行完畢,因爲Other Microtask Queue爲空,跳過。
宏隊列
Timers Queue: [callback2]
Check Queue: []
IO Callback Queue: []
Close Callback Queue: []
微隊列
Next Tick Queue: [callback6]
Other Microtask Queue: []
打印結果 start end 999 111 777 444 888 555 333
- 執行第6個階段Close Callback Queue中的任務,爲空,跳過,好了,此時一個循環已經結束。進入下一個循環,執行第1個階段Timers Queue中的所有任務,取出callback2執行,打印222,完畢。此時,所有隊列包括宏任務隊列和微任務隊列都爲空,不再打印任何東西。
宏隊列
Timers Queue: []
Check Queue: []
IO Callback Queue: []
Close Callback Queue: []
微隊列
Next Tick Queue: [callback6]
Other Microtask Queue: []
最終結果 start end 999 111 777 444 888 555 333 666 222
以上就是這道題目的詳細分析,如果沒有明白,一定要多看幾次。
下面引入Promise再來看一個例子:
console.log('1'); setTimeout(function() { console.log('2'); process.nextTick(function() { console.log('3'); }) new Promise(function(resolve) { console.log('4'); resolve(); }).then(function() { console.log('5') }) }) new Promise(function(resolve) { console.log('7'); resolve(); }).then(function() { console.log('8') }) process.nextTick(function() { console.log('6'); }) setTimeout(function() { console.log('9'); process.nextTick(function() { console.log('10'); }) new Promise(function(resolve) { console.log('11'); resolve(); }).then(function() { console.log('12') }) }) 複製代碼
大家仔細分析,相比於上一個例子,這裏由於存在Promise,所以Other Microtask Queue中也會有回調任務的存在,執行到微任務階段時,先執行Next Tick Queue中的所有任務,再執行Other Microtask Queue中的所有任務,然後纔會進入下一個階段的宏任務。明白了這一點,相信大家都可以分析出來,下面直接給出正確答案,如有疑問,歡迎留言和我討論。
// 正確答案 1 7 6 8 2 4 9 11 3 10 5 12 複製代碼
setTimeout 對比 setImmediate
- setTimeout(fn, 0)在Timers階段執行,並且是在poll階段進行判斷是否達到指定的timer時間纔會執行
- setImmediate(fn)在Check階段執行
兩者的執行順序要根據當前的執行環境才能確定:
- 如果兩者都在主模塊(main module)調用,那麼執行先後取決於進程性能,順序隨機
- 如果兩者都不在主模塊調用,即在一個I/O Circle中調用,那麼setImmediate的回調永遠先執行,因爲會先到Check階段
setImmediate 對比 process.nextTick
- setImmediate(fn)的回調任務會插入到宏隊列Check Queue中
- process.nextTick(fn)的回調任務會插入到微隊列Next Tick Queue中
- process.nextTick(fn)調用深度有限制,上限是1000,而setImmedaite則沒有
總結
- 瀏覽器的Event Loop和NodeJS的Event Loop是不同的,實現機制也不一樣,不要混爲一談。
- NodeJS可以理解成有4個宏任務隊列和2個微任務隊列,但是執行宏任務時有6個階段。先執行全局Script代碼,執行完同步代碼調用棧清空後,先從微任務隊列Next Tick Queue中依次取出所有的任務放入調用棧中執行,再從微任務隊列Other Microtask Queue中依次取出所有的任務放入調用棧中執行。然後開始宏任務的6個階段,每個階段都將該宏任務隊列中的所有任務都取出來執行(注意,這裏和瀏覽器不一樣,瀏覽器只取一個),每個宏任務階段執行完畢後,開始執行微任務,再開始執行下一階段宏任務,以此構成事件循環。
- NodeJS可以理解成有4個宏任務隊列和2個微任務隊列,但是執行宏任務時有6個階段。先執行全局Script代碼,執行完同步代碼調用棧清空後,先從微任務隊列Next Tick Queue中依次取出所有的任務放入調用棧中執行,再從微任務隊列Other Microtask Queue中依次取出所有的任務放入調用棧中執行。然後開始宏任務的6個階段,每個階段都將該宏任務隊列中的所有任務都取出來執行(注意,這裏和瀏覽器不一樣,瀏覽器只取一個),6個階段執行完畢後,再開始執行微任務,以此構成事件循環。
- MacroTask包括: setTimeout、setInterval、 setImmediate(Node)、requestAnimation(瀏覽器)、IO、UI rendering
- Microtask包括: process.nextTick(Node)、Promise、Object.observe、MutationObserver
參考鏈接
不要混淆nodejs和瀏覽器中的event loop
node中的Event模塊
Promises, process.nextTick And setImmediate
瀏覽器和Node不同的事件循環
Tasks, microtasks, queues and schedules
理解事件循環淺析