瀏覽器和Node.js中的Event Loop

wannawanna發表於2019-03-04

前言

眾所周知,javascript是一門單執行緒語言,而當我們使用ajax和服務端進行通訊的時候是需要一定時間的,這樣當前執行緒就會被阻塞,使瀏覽器失去相應。因此,當js執行執行一些長時間的任務時,我們希望有一種非同步的方式處理這種任務。事件迴圈(event loop)就是如何處理非同步執行順序的一種機制。

$.get(url, function (data) {
    //do something
});
複製程式碼

瀏覽器中的事件迴圈

接下來會一一介紹,事件迴圈中的執行棧事件佇列巨集任務微任務等概念

什麼是執行棧

瀏覽器和Node.js中的Event Loop

執行棧就是js程式碼執行的地方,上圖call stack所示。當下面程式執行時,會推送的呼叫棧中被執行。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 500);
console.log('Bye');
複製程式碼

什麼是事件佇列

當瀏覽器中的事件監聽函式被觸發(DOM)、網路請求的相應(ajax)、定時器被觸發(setTimeout)相對應的回撥函式就會被推送到事件佇列中,等待執行;如上圖中的Callback Queue。

什麼是事件迴圈

事件迴圈是一個這樣的過程:當執行棧中的任務結束之後,會將事件佇列中的第一個任務推入到執行棧中執行,當任務處理完畢,又會取事件佇列中的第一個任務,如此往復,便構成了事件迴圈。

對應到下面程式碼中。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 500);
console.log('Bye');
複製程式碼
  • 程式推送到執行棧中被執行
  • 執行console語句、輸出Hi
  • 執行setTimeou語句
  • 執行console語句、輸出Bye
  • 500ms的時候,setTimeout的回撥函式被推送到事件佇列中
  • 此時事件佇列中只有setTimeout的回撥函式這一個任務,會被推到執行棧中執行
  • console語句執行、輸出cb1

通過上面的例子會對執行棧和事件佇列有個基本的認識。由於JS是單執行緒的,同步任務會造成瀏覽器阻塞,我們把任務分成一個一個的非同步任務,通過事件迴圈來執行事件佇列中的任務。這就使得當我們掛起某一個任務的時候可以去做一些其他的事情,而不需要等待這個任務執行完畢。所以事件迴圈的執行機制大致分為以下步驟:

1、檢查事件佇列是否為空,如果為空,則繼續檢查;如不為空,則執行 2;

2、取出事件佇列的首部,壓入執行棧;

3、執行任務;

4、檢查執行棧,如果執行棧為空,則跳回第 1 步;如不為空,則繼續檢查;

瀏覽器渲染時機

我們知道DOM操作會觸發瀏覽器渲染,如增、刪節點,改變背景顏色。那麼這類操作是如何在瀏覽器當中奏效的?

至此我們已經知道了事件迴圈是如何執行的,事件迴圈器會不停的檢查事件佇列,如果不為空,則取出隊首壓入執行棧執行。當一個任務執行完畢之後,事件迴圈器又會繼續不停的檢查事件佇列,不過在這間,瀏覽器會對頁面進行渲染。這就保證了使用者在瀏覽頁面的時候不會出現頁面阻塞的情況,這也使 JS 動畫成為可能。

function move() {
    setTimeout(() => {
        dom.style.left = dom.offsetLeft + 10 + 'px'
        move()
    }, 15);
}
move()
複製程式碼

現在用事件迴圈的機制說明js動畫的過程。上面程式碼會在執行棧中執行,move函式被呼叫,setTimeout的回撥函式15ms之後會被推送到事件佇列中。此時執行棧中的任務結束,瀏覽器渲染、檢查事件佇列不斷迴圈。當15ms之後事件佇列中有任務時,會被推送到執行棧中執行,這時dom節點向右偏移10px,move函式執行、執行棧結束,瀏覽渲染、檢查事件佇列。如此往復就形成了動畫。

巨集任務和微任務(microtask)

先看一段程式碼,是如何輸出的;

console.log('script start');
setTimeout(function () {
    console.log('setTimeout');
}, 0);
Promise.resolve().then(function () {
    console.log('promise1');
}).then(function () {
    console.log('promise2');

});
console.log('script end');
複製程式碼

答案是:'script start''script end''promise1''promise2''setTimeout'

setTimeout的回撥函式是巨集任務、Promise的回撥函式是微任務。微任務和巨集任務一樣遵循事件迴圈機制,但是他們還是有些差別。

1、巨集任務和微任務的事件佇列是相互獨立的;

2、微任務佇列的檢查時機早於巨集任務。(執行棧中任務結束就會馬上清空微任務事件佇列)

根據上面的規則,解釋程式碼的輸出。

  • 執行棧中的程式碼執行,巨集任務推入巨集任務事件佇列、微任務推入微任務事件佇列,執行棧任務結束

  • 檢查微任務事件佇列,此時已經有Promise的回撥函式,推入執行棧,輸出promise1。Promise還有回撥函式,推入微任務事件佇列,執行棧結束。

  • 檢查微任務事件佇列,推入執行棧,輸出promise2,執行棧結束。

  • 檢查微任務事件佇列,此時被清空

  • 檢查巨集任務事件佇列,推入執行棧,輸出setTimeout,執行棧結束。

      巨集任務有: **setTimeout** 、**setImmediate** 、 **MessageChannel**
      微任務有: **setTimeout** 、**setImmediate** 、 **MessageChannel**
    複製程式碼

Node.js中的事件迴圈

Node中的事件迴圈是和瀏覽器有很大區別的

當Node.js啟動時,會初始化event loop;每個event loop都會包含按如下順序六個迴圈階段

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────
複製程式碼
  • timers 階段: 這個階段執行setTimeout(callback) and setInterval(callback)預定的callback;
  • I/O callbacks 階段: 執行除了 close事件的callbacks、被timers(定時器,setTimeout、setInterval等)設定的callbacks、setImmediate()設定的callbacks之外的callbacks;
  • idle, prepare 階段: 僅node內部使用;
  • poll 階段: 獲取新的I/O事件, 適當的條件下node將阻塞在這裡;
  • check 階段: 執行setImmediate() 設定的callbacks;
  • close callbacks 階段: 比如socket.on(‘close’, callback)的callback會在這個階段執行。

每一個階段都有一個裝有callbacks的fifo queue(佇列),當event loop執行到一個指定階段時, node將執行該階段的fifo queue(佇列),當佇列callback執行完或者執行callbacks數量超過該階段的上限時,event loop會轉入下一下階段。

Node.js中的巨集任務和微任務

巨集任務:setTimeout和setImmediate
複製程式碼
  • setTimeout 設計在poll階段為空閒時,且設定時間到達後執行;但其在timer階段執行
  • setImmediate 設計在check階段執行;

誰先輸出,誰後輸出?

setTimeout(function timeout () {
  console.log('timeout');
},0);

setImmediate(function immediate () {
  console.log('immediate');
});
複製程式碼

答案是不確定的。有兩個前提我們是需要清楚的;

  • event loop初始化是需要一定時間的
  • setTimeout有最小毫秒數的,通常是4ms。

當:event loop準備時間 > setTimeout最小毫秒數。從timers階段檢查,此時佇列中已經有setTimeout的任務,所以timeout先輸出;

當:event loop準備時間 < setTimeout最小毫秒數。從timers階段檢查,此時佇列是空的就下檢查接下來的階段,到check階段,已經有setImmediate的任務,所以immediate先輸出;

微任務:process.nextTick()和Promise.then()
複製程式碼

微任務不在event loop的任何階段執行,而是在各個階段切換的中間執行,即從一個階段切換到下個階段前執行;nextTick比Promise.then()先執行

下面程式碼是如何執行的。

setImmediate(() => {
  console.log('setImmediate1')
  setTimeout(() => {
    console.log('setTimeout1')
  }, 0);
})
setTimeout(()=>{
  process.nextTick(()=>console.log('nextTick'))
  console.log('setTimeout2')
  setImmediate(()=>{
    console.log('setImmediate2')
  })
},0);
複製程式碼
  • 從前面的知識知道,此時setTimeout和setImmediate執行順序是不確定的。
  • 假設setImmediate先執行,輸出setImmediate1,setTimeout的任務新增到timer階段
  • 檢查timer階段,這時已經有兩個任務。先執行之前的第一個任務,nextTick新增到微任務佇列,輸出setTimeout2,setImmediate的任務新增到check階段。
  • timer中還有一個任務,執行輸出setTimeout1
  • 切換階段,微任務執行,輸出nextTick
  • 檢查check階段,輸出setImmediate2

思考題

let fs = require('fs')

fs.readFile('./1.txt', 'utf8', function (err, data) {
    setTimeout(() => {
        console.log('setTimeout')
    }, 0);
    setImmediate(() => {
        console.log('setImmediate')
    })
})
複製程式碼

這種情況下的setTimeout和setImmediate執行的順序確定嗎?readFile的回撥函式是在poll階段執行 答案是setImmediatesetTimeout先執行

結語

瀏覽器中和Node.js中的事件迴圈可以說是兩套不同的機制,做個總結,希望有所幫助。

相關文章