Event Loop 事件循環機制

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規範,規範着實詳細,我就不貼了,提煉下來關鍵步驟如下:

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

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

不慌不慌,通過仔細閱讀文檔得知,這兩個概念屬於對異步任務的分類,不同的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. 同步運行的代碼首先輸出: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

我陷入了困惑,不過很快明白了,這說明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:實現

俗話說得好:

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

規範無法囊括所有場景,雖然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.

而真正痛過的同學會告訴你,chrome環境下的答案是:1、0、2

Chrome中的timer

測試(3)結果可以看出,0ms和1ms的延時效果是一致的,那背後的原因是爲什麼呢?我們先查查 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的延時效果一致呢?

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的實現本文不做展開

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

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
  • 和規範的區別,在於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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章