Event Loop的規範和實現

做者簡介:nekron 螞蟻金服·數據體驗技術團隊html

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

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

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

PART 1:規範

爲何要有Event Loop?

由於Javascript設計之初就是一門單線程語言,所以爲了實現主線程的不阻塞,Event Loop這樣的方案應運而生。web

小測試(1)

先來看一段代碼,打印結果會是?面試

console.log(1)

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

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

console.log(5)
複製代碼

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

  1. 首先,咱們先排除異步代碼,先把同步執行的代碼找出,能夠知道先打印的必定是一、5
  2. 可是,setTimeout和Promise是否有優先級?仍是看執行順序?
  3. 還有,Promise的多級then之間是否會插入setTimeout?

帶着困惑,我試着運行了一下代碼,正確結果是:一、五、三、四、2api

那這究竟是爲何呢?瀏覽器

定義

看來須要先從規範定義入手,因而查閱一下HTML規範,規範着實詳(luo)細(suo),我就不貼了,提煉下來關鍵步驟以下:bash

  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. 同步運行的代碼首先輸出:一、7
  2. 接着,清空microtask隊列:8
  3. 第一個task執行:二、4
  4. 接着,清空microtask隊列:5
  5. 第二個task執行:九、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)
})
複製代碼

有了以前的積累,我這回自信的寫下了答案:一、七、八、六、二、四、五、3

然而,帥不過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輸出:一、七、六、八、二、四、三、5
  • 而後,第二個task輸出:九、十一、十、12

然而,啪啪打臉。。。

我第一次執行,輸出結果是:一、七、六、八、二、四、九、十一、三、十、五、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、一、2

而另外一些有必定經驗的同窗可能會回答:二、一、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.

而真正痛過的同窗會告訴你,答案是:一、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…

相關文章
相關標籤/搜索