博客 / 詳情

返回

EventLoop事件循環機制(瀏覽器和Node EventLoop)

前端的同學們應該都聽説過EventLoop的概念,網上各大平台關於它的文章也是成百上千質量參差不一,其實在筆者剛開始接觸js的時候這對這方面一頭霧水,也是看了高程、官方文檔以及大量的文章後才對它有了深刻認識,在這兒就來和大家分享下我對它的的認識和理解,不過要講明白EventLoop這個東東還是要從頭説起。

本篇內容循序漸進比較長,需要耐心看完

注:如遇到有一些鏈接無法訪問可能需要科學上網

文章首發本人博客: https://blog.usword.cn

前言

眾所周知JS是一個單線程非阻塞語言,不像諸如Java、Python等多線程語言對併發處理比較友好,而JS只能同時執行一個任務。那為什麼JS不像其他語言一樣也是個多線程語言呢?其實在最初使用瀏覽器呈現頁面時,基本上都是靜態頁面和簡單的功能,並沒有考慮到複雜的交互功能,JS的創作者因此也沒必要做更復雜的設計。但近年來隨着web技術的突飛猛進,各種頁面五花八門的交互以及併發資源請求等都出現了,因此關於JS單線程處理異步任務等等概念也就被關注起來了。

雖然單線程會造成任務執行阻塞,頁面長時間等待等缺點,但JS並沒有改變它。由於JS通常伴隨着它的宿主環境瀏覽器而出現,若JS變成一個多線程的語言,那麼瀏覽器處理用户的操作將會變得非常複雜。試想在弱網環境下腳本還沒有完全加載完時,用户如果點擊頁面的上某個表單提交任務,會發現沒有任何反應,這難免會讓用户產生一些想法。而JS單線程則會阻塞頁面的渲染,當然用户也不知道可以做什麼,只不過會犧牲點加載或等待時間。

JS的Engine和Runtime

JS是一個動態解釋性語言,需要通過JS的引擎(JS Engine)進行解釋(翻譯)成對應的字節碼、機器碼然後才會運行。隨着網頁複雜性和性能要求的提高,JS引擎也經歷了從SpiderMonkey到V8(由google開發)的變革,而由谷歌開發的V8引擎最為出色,目前已被大多數現代瀏覽器等(Chrome、Edge、Safari)採用。同時JS也從以前瀏覽器單一的運行時(Runtime)演變到可以在服務端運行的NodeJS(基於V8)運行時,為它提供不同平台的運行時環境。

  • Engine:為JavaScript解析和執行提供環境條件(類似Java虛擬機),並完成內存分配和垃圾回收等等。
  • Runtime:由JavaScript的宿主環境提供額外的屬性和方法,如瀏覽器提供了用户交互和一些異步任務的功能。

JS的是通過異步回調的方式解決單線程的執行阻塞問題,雖然JS引擎是單線程的,但它的宿主環境一般都是多線程的,如通過瀏覽器的定時任務線程、網絡線程協調執行異步回調。所以常説的EventLoop是面向宿主環境的也就是Runtime,如瀏覽器和NodeJS,而瀏覽器的EventLoop總被頻繁討論,本篇將會對瀏覽器和NodeJS的EventLoop逐一展開介紹。

瞭解瀏覽器

warning
由於瀏覽器內部也在不斷的升級優化,可能每個版本會存在不同的差異,這裏只是針對其中的某段版本內部架構進行説明,其他或未來可能會發生變化,如果發現有不一致的地方可以閲讀google開發者文檔。

上述我們知道了EventLoop主要是宿主環境實現的如:瀏覽器,這裏我們需要先了解下瀏覽器的架構,本文以Chrome瀏覽器作為介紹,其他瀏覽器可能存在差異,請自行查閲相關文檔,本文不再做相關贅述。

關於瀏覽器的架構的發展也進行了很長一段時間,由單進程到多進程、單渲染進程到多渲染進程等多種機制的演變,更多關於瀏覽器的演變可以閲讀我的『現代瀏覽器架構』一文。以Chrome為例,它是多進程和多線程的架構,其內部包括:

  • Brower進程:提供瀏覽器URL、後退/前進、調度等全局作用
  • 網絡進程:進行網絡資源請求、安全策略等等
  • GPU進程:3D渲染、動畫繪製等等
  • 渲染進程:負責每個Tab頁面加載解析,JS、CSS、DOM等相關頁面和用户操作
  • 插件進程:瀏覽器插件

除了以上列出的進程外,還有一些其它的進程。

這裏主要來説下渲染進程,它是前端開發者最必要的關注點。Chrome為每個tab頁面提供一個渲染進程。渲染進程會包括很多線程:

  1. 主線程:調度頁面的加載解析,執行dom、css、js操作處理等等
  2. GUI線程:負責頁面的渲染
  3. JS引擎線程:進行解析執行JS
  4. 定時器線程:處理異步定時任務
  5. 異步請求線程:進行網絡請求
  6. 事件觸發線程:監聽執行事件callback
  7. WebWorker線程:獨立的腳本,不影響頁面渲染,通常用來執行密集複雜的計算

等等...

以上簡單介紹了瀏覽器每個頁面進行渲染時,渲染進程會為頁面提供不同的線程來負責不同的任務。這裏需要知道當<u>加載頁面時會從上到下解析文檔,當遇到JS腳本(通常情況下)時會阻塞DOM的解析,也就是JS引擎的執行會阻塞GUI線程渲染的執行</u>,這也符合JS是個單線程語言的特徵。不過渲染進程也提供了不同的線程去處理異步任務,可以並行處理多個任務,如:定時器線程、網絡請求線程等等,而不會影響頁面的渲染推翻JS單線程的理念。

事件驅動

其實瀏覽器多線程執行異步任務的原理背後是基於事件驅動機制的。不同類型的事件觸發不同的任務,如:點擊事件、滾動事件等等。而事件循環機制(EventLoop)就是基於事件驅動機制的。

<u>當JS執行代碼時,如果遇到異步代碼如Ajax請求時,會交給別的線程去執行異步任務,然後主線程掛起當前任務,不會阻塞後面代碼的執行</u>。這些異步任務會由瀏覽器不同的線程進行負責,不會影響到主線程和JS引擎線程,當這些異步任務執行完畢後,會被存放到指定的任務隊列中,等JS的執行棧中當前同步任務執行完畢後,會從這些任務隊列中取出待執行的任務,而具體優先取哪一個這就是要取決於事件循環機制了。

瀏覽器的EventLoop

通過上面的介紹你應該會了解到瀏覽器的多線程其實就是讓JS擁有多線程併發處理異步任務的能力,主要負責點擊等事件、定時任務、網絡請求、腳本執行、用户交互和頁面渲染之間的的調度。

先來看看JS內存結構概念,這裏借用MDN的圖,如下圖:

JS內存結構

從JS的內存模型圖可以將JS內存大致的分為:調用棧、堆和任務隊列。

調用棧

調用棧就是來執行JS代碼的,它會記錄函數調用的整個過程,並將函數的變量等信息以棧幀的形式壓入,當執行完函數式,將棧頂的幀彈出。如下代碼:

function foo(b) {
  let a = 10;
  return a + b + 11;
}

function bar(x) {
  let y = 3;
  return foo(x * y);
}

console.log(bar(7)); // 返回 42

當調用 bar 時,第一個幀被創建並壓入棧中,幀中包含了 bar 的參數和局部變量。當 bar 調用 foo 時,第二個幀被創建並被壓入棧中,放在第一個幀之上,幀中包含 foo 的參數和局部變量。當 foo 執行完畢然後返回時,第二個幀就被彈出棧(剩下 bar 函數的調用幀)。當 bar 也執行完畢然後返回時,第一個幀也被彈出,棧就被清空了。

本段代碼來自MDN,更多詳情點此處查看

用來存儲對象變量或其他複雜的數據結構變量

任務隊列

任務隊列用來儲存帶執行的任務如:點擊事件、回調函數等等,任務又包括:宏任務(Macro Task)和微任務(Mirco Task),不同類型的任務優先級以及執行時機會有所不同。

EventLoop就是通過事件循環的機制當執行棧空閒時,主線程判斷任務隊列中是否有合適的任務,取出最老的一個任務將其壓入調用棧中執行,執行完後再次出棧,如此反覆不斷循環,就是所謂的事件循環機制EventLoop,如下圖。

eventloop.png

MacroTask和MicroTask

瀏覽器EventLoop會有一個或多個Macro任務隊列,存放着來自不同任務源(Task Source)的任務,這裏有人喜歡將其作為隊列結構遵循先進先出的規則,事實上卻是個Set集合,每次循環都會選擇不同類型任務隊列的第一個可執行任務。

在HTML標準中定義了常見的MacroTask Source:

  • dom manipulation(DOM操作): 如沒有阻塞的插入元素
  • user interaction(用户交互):用户進行輸入、鍵盤等UI交互事件
  • network(網絡請求):網絡資源請求如Ajax請求
  • navigation和history:導航和history操作

MacroTask Source的定義非常廣泛,常見的鍵盤、鼠標、Ajax、setTimeout、setInterval、操作數據庫等都屬於MacroTask Source,對於宏任務MacroTask、TaskSource和MacroTask Queue有相關約定:

  • 來自同一個 TaskSource 的 MacroTask必須放到相同的MacroTask Queue
  • 同一個MacroTask Queue中的MacroTask按順序排列
  • 瀏覽器會根據不同TaskSource的優先級可能會被先調度,以快速響應用户的交互

Mirco task在HTML標準中並沒有明確定義,一般以下幾種被視為微任務

  • Promise
  • Object.observe(已棄用)
  • MutationObserve
  • queueMicrotask

<u>那麼為什麼任務隊列中會有宏任務(Macro Task)和微任務(Mirco Task)呢?其目的就是讓不同類型的任務源有不同的執行優先級。</u>

在EventLoop中的每一次循環成一個tick,每一次tick都會先執行同步任務,然後查看是否有微任務,將所有的微任務在這個階段執行完,如果執行微任務階段再次產生微任務也會把他執行完(每次tick只會有一個微任務隊列),接下來會<u>可能</u>會進行視圖的渲染,然後再從MacroTask隊列中選擇一個合適的任務放入執行棧執行,然後重複前面的步驟不斷循環,再次拿出經典圖:
eventloop.png

需要注意的是所謂的微任務並不會交給其他線程處理,而是V8自己內部的實現,微任務V8會將其放入一個專門的隊列,待當前同步任務執行完後,便會清空當前隊列,而MacroTask會交給其他線程去處理。

接下來可以套用上面的概念看一段代碼的執行結果(先別看答案自己先過一遍寫出結果,最後再對比下哪裏的想法有問題):

setTimeout(() => {
  console.log('setTimeout start');
  Promise.resolve()
    .then(() => console.log('promise resolve1'))
    .then(() => console.log('promise resolve2'))
    .then(() => console.log('promise resolve3'))
    .then(() => console.log('promise resolve4'))
  new Promise((resolve) => {
    console.log('promise1 start');
    resolve()
  }).then(() => {
    console.log('promise1 end');
  });
  setTimeout(() => {
    console.log('inner setTimeout')
  })
  console.log('setTimeout end');
}, 0);

function promise2() {
  return new Promise((resolve) => {
    console.log('promise2');
    resolve();
  })
}

async function async1() {
  console.log('async1 start');
  await promise2();
  console.log('async1 end');
}

async1();
console.log('script end');

以上代碼的打印順序為:async1 start => promise2 => script end => async1 end => setTimeout start => promise1 start => setTimeout end => promise resolve1 => promise1 end => promise resolve2 => promise resolve3 => promise resolve4 => inner setTimeout,不管和你預期的結果是否一樣,接下來我們逐行分析:

  1. 首先script整體作為同步任務執行,遇到setTimeout定時宏任務時交給定時線程去執行,其結果會放入宏任務隊列,主線程掛起當前異步任務繼續執行後面的代碼。
  2. 執行async1(),async1函數入棧,併為當前函數提供一些變量上下文。首先打印async1 start,遇到await promise2,會執行new Promise()其也是個同步代碼,所以會打印promise2,接着會執行resolve(),它返回的是個promise,然後回到async1函數內部,await其實是個語法糖,後面的代碼會作為promise的then代碼塊執行,而then會當做微任務進入微任務隊列(promise不清楚的可以看我『異步編程』一文),async1執行完後出棧。
  3. 執行console.log(script end),打印,然後出棧,第一輪同步任務執行完畢。
  4. 同步任務執行完後先看有沒有微任務,第2步await後面的語句已經被放入微任務隊列了,執行後打印async1 end,微任務隊列清空。
  5. 這裏沒有涉及到視圖更新等等。
  6. 主線程接着從任務隊列中選取一個最老的宏任務(MacroTask)來執行,這裏任務隊列中只有一個setTimeout定時任務,首先會判斷執行它的時機到了沒,如果沒到由於沒有其他宏任務了,主線程什麼都不會做。反之執行其會首先打印setTimeout start。接着執行Promise.resolve()其返回promise是個微任務會放入微任務隊列,接着new Promise()執行打印promise1 start,內部也會resolve返回promise也是個微任務放入微任務隊列。接着就是setTimeout放入宏任務隊列,最後執行setTimeout end
  7. MacroTask執行完畢,清空所有的MircoTask。首先執行promise resolve1,而後的then也是個微任務會被放入當前微任務隊列。接着執行promise1 end,接下來又會執行promise resolve2,它也一樣返回微任務(和前面重複步驟)直到執行完後面所有的then。
  8. MircoTask執行完後MacroTask Queue就剩下一個setTimeout任務了,合適的時機打印inner setTimeout

通過一步一步的分析相信你已經對其執行過程有了初步認識,為了加深大家的印象,這裏錄製了一個視頻來動畫展示其運行過程,點擊這裏查看

定時器誤差

我們已經知道異步的MacroTask其實會交給其它線程去處理,當執行棧中的代碼執行完後,才會通過EventLoop去獲取下一個Task執行。而定時任務(如:setTimeout)當指定了時間後執行,若執行棧的任務還沒有執行完,就算定時器時間到了,也永遠不會去執行,直到清空當前執行棧後才會執行,來看下面代碼:

setTimeout(() => console.log('setTimeout'), 1000);
for(let count = 0;count<10000000000;count++);

這段代碼很簡單定時器在1s後打印setTimeout,然後執行for循環,當你執行後會發現打印的時間已經遠超1s,對於電腦性能不好的可能要更久。

我們用EventLoop分析下上面的代碼,當程序遇到setTimeout後,會交給定時器線程去執行,然後等1s後將其放入MacroTask Queue,等待主線程的調度。主線程遇到setTimeout掛起它,執行後面的for循環,直到執行完for循環才會去MacroTask Queue調度下一個Task也就是setTimeout。而這裏for循環執行耗時時間已經遠超1s,所以setTimeout回調的調度也會被推遲,這就是為什麼setTimeout定義了1s執行卻沒有執行的本質原因,如果你瞭解了事件循環機制EventLoop,關於定時器誤差也會很容易理解。

視圖更新時機

前面已經講了在每一次tick執行完所有的MircoTask Queue後就會進行視圖的更新,但一般視圖的更新會跟隨這系統幀率通常都是60fps(16.666ms一次),如果在每次tick中不斷修改dom並不會立即更新:

<div id="title" />
<script>
  const wrapper = document.querySelector("#title");
  let color = "red";
  let count = 0;
  const timer = setInterval(() => {
    count++;
    if (count > 10) clearInterval(timer);
    if (color === "red") {
      wrapper.style.background = "blue";
      color = "blue";
    } else {
      wrapper.style.background = "red";
      color = "red";
    }
  }, 4);
</script>


上面代碼每次都是宏任務會經歷10次tick,dom也會更新10次,而實際圖中也就一兩次,這再次説明了更新視圖會在合適的點進行更新,一般都是根據系統幀率60fps,若果用setTimeout做動畫時間設置成17ms,應該不會掉太多幀,但setTimeout可能會被執行棧延遲調用,所以用setTimeout做動畫掉幀的可能性非常大。

requestAnimationFrame

考慮
requestAnimationFrame是微任務還是宏任務? 其實都不是

requestAnimation會在每次更新視圖前執行,他不會收到主線程的阻塞,也就是説視圖更新的幀率為60fps,requestAnimationFrame也會執行60次,並且執行時間間隔非常穩定,所以很適合做動畫,也不會卡頓。

function rF() {
  count ++;
  if (count < 60) {
    requestAnimationFrame(() => {
      if (color === "red") {
        wrapper.style.background = "blue";
        color = "blue";
      } else {
        wrapper.style.background = "red";
        color = "red";
      }
      rF();
    })
  }
}
rF();

通過requestAnimationFrame將會在每次更新視圖時都會正確的更新每次修改的內容。

瀏覽器EventLoop總結

通過以上了解到瀏覽器是多線程的,其通過事件驅動模型用EventLoop事件循環機制來調度定時任務、用户交互、網絡請求等異步任務,其大致過程如下:

  • 首先script整體作為宏任務執行,執行同步任務,當遇到異步任務時,如果是宏任務就會交給其它線程處理,並放入對應的任務隊列中。微任務放入微任務隊列,主線程掛起這些異步任務,接着執行後面的代碼。
  • 當前執行棧清空會先執行所有的微任務,如果還遇到微任務也會將其放入當前微任務隊列後,直到所有執行完。
  • 當所有微任務執行完後,瀏覽器可能會更新視圖,與幀率有很大關係。
  • 然後主線程根據調度優先級從宏任務隊列中適合執行的任務執行,然後不斷重複以上操作。

Node的EventLoop

講完了瀏覽器的EventLoop接下來看看Node的EventLoop,Node和瀏覽器都是基於v8引擎,瀏覽器中的異步方法node中也會有,除此之外還包括:

  • 文件I/O
  • process.nextTick
  • setImmediate
  • 監聽關閉事件

Node中的事件循環機制是基於Libuv(Asynchronous I/O)引擎實現的,v8引擎會分析對應的js代碼然後調用node的api,而node又被libuv驅動執行對應的任務,並把任務放入到對應的任務隊列等待主線程的調度,因此node的EventLoop是libuv裏實現的,看下node原理圖:

node-system.png

執行階段

node事件循環機制中和瀏覽器的不太一樣,在node中一般不説微任務和宏任務,通常分為不同的執行階段,而在這些不同的執行階段都會對應執行任務,node就是這樣不斷循環這些不同的執行階段調度任務的執行順序,其EventLoop包括以下執行階段:

  • timers:執行定時器任務隊列回調:setTimeout、setInterval...
  • pending callback:執行除了定時器、setImmediate以外的大部分回調,如操作系統TCP連接回調、IO回調等等。
  • idle、prepare:內部調度不用關心
  • poll:等待新的請求連接或I/O事件。node一開始進入這個階段,如果當前階段的任務隊列執行空,先看有沒有setImmediate回調,如果有進入check執行setImmediate回調,或等待新的I/O請求連接,同時也會檢測timer是否有到期,若有會直接進入timer階段執行回調,受代碼執行環境的影響
  • check:執行setImmediate回調
  • close:執行socket等關閉操作回調

以下是來自官網的一張事件循環圖
node-eventloop.png

以上每個階段的任務執行完在進入下一個階段前會先清空當前階段的微任務隊列,而老版本的node則會先把當前階段的代碼回調執行完後才會執行微任務隊列,如下代碼:

setTimeout(() => {
  console.log("setTimeout1");
  Promise.resolve().then(() => console.log("promise1"));
});
setTimeout(() => {
  console.log("setTimeout2");
  Promise.resolve().then(() => console.log("promise2"));
});
  • 使用node版本(9.11.2),打印順序:setTimeout1 => setTimeout2 => promise1 => promise2,node會先將timer階段的回調執行完後,才會執行當前階段微任務隊列中的所有微任務。
  • 瀏覽器打印順序:setTimeout1 => promise1 => setTimeout2 => promise2
  • 使用高版本瀏覽器打印順序和瀏覽器一致。

setTimeout和setImmediate

通常情況下我們都會使用setTimeout執行延時任務,在node中也提供了setImmediate來執行異步任務表示立即執行,前面講到它在check階段執行;若將setTimeout的時間設置為0是不是和前者有同樣的效果:

setTimeout(() => console.log("setTimeout"));
setImmediate(() => console.log("setImmediate"));

多次執行以上代碼會發現打印順序並不固定,這是為什麼呢?

以上代碼就兩個異步任務,前面講了node剛開始會進入poll階段,如果當前階段沒有任何任務要執行時,就會看看有無setImmediate回調,如果有的話進入check階段執行回調,但是同時也會監聽setTimeout的回調,如果到期也會立馬進入timer階段去執行定時器的回調,兩者優先級不是固定的,這就是為什麼打印順序並不是一致的原因。

現在將上面的代碼稍作修改,讓其在IO回調裏執行:

const fs = require("fs")
fs.readFile(__dirname + "/inherit.js", 'utf8', (err, data) => {
  setTimeout(() => console.log("setTimout"));
  setImmediate(() => console.log("setImmediate"));
})

以上的打印順序永遠是setImmediate => setTimeout,為什麼呢?因為上面代碼是在IO回調裏執行的。回到事件循環的執行階段中,I/O回調會在pending callback階段執行,按照事件循環機制check階段會在timer階段前執行,所以為什麼打印順序不會變。

process.nextTick

process.nextTick也是立即執行的意思,但它和setImmediate不一樣的是,其不是階段性的任務。process.nextTick在每個階段同步任務執行完後都會執行,並且優先於微任務(promise)執行:

setTimeout(() => {
  Promise.resolve().then(() => console.log("promise"));
  process.nextTick(() => console.log("nextTick"));
});
setImmediate(()=> console.log('setImmediate'));

上面執行結果promisenextTick打印永遠在一起,並且nextTick印永遠會在promise之前打印,而setImmediate只會在check階段打印。

按照執行時機,promise.nextTick更適合於立即執行某個任務。

總結

到這裏關於JS的EventLoop就講完了,你應該也已經明白了什麼是EventLoop了。本篇通過對EventLoop的學習可以對JS的執行順序和瀏覽器渲染時機等等有更深的理解,這會大大減少程序運行的不確定性,也能更好的通過事件循環機制調度任務執行的優先級,如果沒有徹底理解請根據文中例子和概念不斷反覆練習。

相關參考:

  • https://jakearchibald.com/2015/tasks-microtasks-queues-and-sc...
  • http://latentflip.com/loupe
  • https://nodejs.org/en/docs/guides/event-loop-timers-and-nextt...
  • https://www.youtube.com/watch?v=8aGhZQkoFbQ

若您在閲讀過程中發現錯誤:如語句不通、文字等錯誤,可以在評論區指出,我會及時調整修改,感謝您的閲讀。若您覺得這篇文章對您有幫助,願意的話可以打賞作者一筆作為鼓勵,金額不限,再次感謝啦🤝。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.