JavaScript 執行機制--Event Loop詳解

白偉業發表於2018-03-19

JavaScript(簡稱JS)是前端的首要研究語言,要想真正理解JavaScript就繞不開他的執行機制--Event Loop(事件環)

JS是一門單執行緒的語言,非同步操作是實際應用中的重要的一部分,關於非同步操作參考我的另一篇文章js非同步發展歷史與Promise原理分析 這裡不再贅述。

堆、棧、佇列

堆(heap)

堆(heap)是指程式執行時申請的動態記憶體,在JS執行時用來存放物件。

棧(stack)

棧(stack)遵循的原則是“先進後出”,JS種的基本資料型別與指向物件的地址存放在棧記憶體中,此外還有一塊棧記憶體用來執行JS主執行緒--執行棧(execution context stack),此文章中的棧只考慮執行棧。

佇列(queue)

佇列(queue)遵循的原則是“先進先出”,JS中除了主執行緒之外還存在一個“任務佇列”(其實有兩個,後面再詳細說明)。

Event Loop

JS的單執行緒也就是說所有的任務都需要按照一定的規則順序排隊執行,這個規則就是我們要說明的Event Loop事件環。Event Loop在不同的執行環境下有著不同的方式。

瀏覽器環境下的Event Loop

先上圖(轉自Philip Roberts的演講《Help, I'm stuck in an event-loop》)

JavaScript 執行機制--Event Loop詳解

  • 當主執行緒執行的時候,JS會產生堆和棧(執行棧)
  • 主執行緒中呼叫的webaip所產生的非同步操作(dom事件、ajax回撥、定時器等)只要產生結果,就把這個回撥塞進“任務佇列”中等待執行。
  • 當主執行緒中的同步任務執行完畢,系統就會依次讀取“任務佇列”中的任務,將任務放進執行棧中執行。
  • 執行任務時可能還會產生新的非同步操作,會產生新的迴圈,整個過程是迴圈不斷的。

從事件環中不難看出當我們呼叫setTimeout並設定一個確定的時間,而這個任務的實際執行時間可能會由於主執行緒中的任務沒有執行完而大於我們設定的時間,導致定時器不準確,也是連續呼叫setTimeout與呼叫setInterval會產生不同效果的原因(此處就不再展開,有時間我會單獨寫一篇文章)。

接下來上程式碼:

console.log(1);
console.log(2);
setTimeout(function(){
    console.log(3)
    setTimeout(function(){
        console.log(6);
    })
},0)
setTimeout(function(){
    console.log(4);
    setTimeout(function(){
        console.log(7);
    })
},0)
console.log(5)
複製程式碼

程式碼中的setTimeout的時間給得0,相當於4ms,也有可能大於4ms(不重要)。我們要注意的是程式碼輸出的順序。我們把任務以其輸出的數字命名。 先執行的一定是同步程式碼,先輸出1,2,5,而3任務,4任務這時會依次進入“任務佇列中”。同步程式碼執行完畢,佇列中的3會進入執行棧執行,4到了佇列的最前端,3執行完後,內部的setTimeout將6的任務放入佇列尾部。開始執行4任務……

最終我們得到的輸出為1,2,5,3,4,6,7。

巨集任務與微任務

任務佇列中的所有任務都是會乖乖排隊的嗎?答案是否定的,任務也是有區別的,總是有任務會有一些特權(比如插隊),就是任務中的vip--微任務(micro-task),那些沒有特權的--巨集任務(macro-task)。 我們看一段程式碼:

console.log(1);
setTimeout(function(){
    console.log(2);
    Promise.resolve(1).then(function(){
        console.log('promise')
    })
})
setTimeout(function(){
    console.log(3);
})
複製程式碼

按照“佇列理論”,結果應該為1,2,3,promise。可是實際結果事與願違輸出的是1,2,promise,3。

明明是3先進入的佇列 ,為什麼promise會排在前面輸出?這是因為promise有特權是微任務,當主執行緒任務執行完畢微任務會排在巨集任務前面先去執行,不管是不是後來的。

換句話說,就是任務佇列實際上有兩個,一個是巨集任務佇列,一個是微任務佇列,當主執行緒執行完畢,如果微任務佇列中有微任務,則會先進入執行棧,當微任務佇列沒有任務時,才會執行巨集任務的佇列。

微任務包括: 原生Promise(有些實現的promise將then方法放到了巨集任務中),Object.observe(已廢棄), MutationObserver, MessageChannel;

巨集任務包括:setTimeout, setInterval, setImmediate, I/O;

Node環境下的Event Loop

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製程式碼

node中的時間迴圈與瀏覽器的不太一樣,如圖:

  • 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會轉入下一下階段。

process.nextTick

process.nextTick方法不在上面的事件環中,我們可以把它理解為微任務,它的執行時機是當前"執行棧"的尾部----下一次Event Loop(主執行緒讀取"任務佇列")之前----觸發回撥函式。也就是說,它指定的任務總是發生在所有非同步任務之前。setImmediate方法則是在當前"任務佇列"的尾部新增事件,也就是說,它指定的任務總是在下一次Event Loop時執行。上程式碼:

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED
複製程式碼

程式碼可以看出,不僅函式A比setTimeout指定的回撥函式timeout先執行,而且函式B也比timeout先執行。這說明,如果有多個process.nextTick語句(不管它們是否巢狀),將全部在當前"執行棧"執行。

setTimeout 和 setImmediate

二者非常相似,但是二者區別取決於他們什麼時候被呼叫.

  • setImmediate 設計在poll階段完成時執行,即check階段;
  • setTimeout 設計在poll階段為空閒時,且設定時間到達後執行;但其在timer階段執行

其二者的呼叫順序取決於當前event loop的上下文,如果他們在非同步i/o callback之外呼叫,其執行先後順序是不確定的。

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

setImmediate(function immediate () {
  console.log('immediate');
});
複製程式碼
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
複製程式碼

這是因為後一個事件進入的時候,事件環可能處於不同的階段導致結果的不確定。當我們給了事件環確定的上下文,事件的先後就能確定了。

var fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})
複製程式碼
$ node timeout_vs_immediate.js
immediate
timeout
複製程式碼

這是因為因為fs.readFile callback執行完後,程式設定了timer 和 setImmediate,因此poll階段不會被阻塞進而進入check階段先執行setImmediate,後進入timer階段執行setTimeout。

相關文章