由setTimeout和setImmediate執行順序的隨機性窺探Node的事件循環機制

問題引入

接觸過事件循環的同窗大都會糾結一個點,就是在Node中setTimeoutsetImmediate執行順序的隨機性。javascript

好比說下面這段代碼:java

setTimeout(() => {
    console.log('setTimeout');
}, 0);
setImmediate(() => {
    console.log('setImmediate');
})

執行的結果是這樣子的:node

爲何會出現這種狀況呢?別急,咱們先往下看。git

瀏覽器中事件循環模型

咱們都知道,JavaScript是單線程的語言,對I/O的控制是經過異步來實現的,具體是經過「事件循環」機制來實現。github

對於JavaScript中的單線程,指的是JavaScript執行在單線程中,而內部I/O任務實際上是另有線程池來完成的。瀏覽器

在瀏覽器中,咱們討論事件循環,是以「從宏任務隊列中取一個任務執行,再取出微任務隊列中的全部任務」來分析執行代碼的。可是在Node環境中並不適用。具體的瀏覽器事件循環解析:傳送門異步

在Node中,事件循環的模型和瀏覽器相比大體相同,而最大的不一樣點在於Node中事件循環分不一樣的階段。具體咱們下面會討論到。本文核心也在這裏。socket

Node中事件循環階段解析

下面是事件循環不一樣階段的示意圖:ide

每一個階段都有一個先進先出的回調隊列要執行。而每一個階段都有本身的特殊之處。簡單來講,就是當事件循環進入某個階段後,會執行該階段特定的任意操做,而後纔會執行這個階段裏的回調。當隊列被執行完,或者執行的回調數量達到上限後,事件循環纔會進入下一個階段。函數

如下是各個階段詳情。

timers

一個timer指定一個下限時間而不是準確時間,在達到這個下限時間後執行回調。在指定的時間事後,timers會盡早的執行回調,可是系統調度或者其餘回調的執行可能會延遲它們。

從技術上來講, poll階段控制 timers何時執行,而執行的具體位置在 timers

下限的時間有一個範圍:[1, 2147483647],若是設定的時間不在這個範圍,將被設置爲1。

I/O callbacks

這個階段執行一些系統操做的回調,好比說TCP鏈接發生錯誤。

idle, prepare

系統內部的一些調用。

poll

這是最複雜的一個階段。

poll階段有兩個主要的功能:一是執行下限時間已經達到的timers的回調,一是處理poll隊列裏的事件

注:Node不少API都是基於事件訂閱完成的,這些API的回調應該都在poll階段完成。

如下是Node官網的介紹:

筆者把官網陳述的狀況以不一樣的條件分解,更加的清楚。(若是有誤,師請改正。)

當事件循環進入poll階段:

  • poll隊列不爲空的時候,事件循環確定是先遍歷隊列並同步執行回調,直到隊列清空或執行回調數達到系統上限。
  • poll隊列爲空的時候,這裏有兩種狀況。

    • 若是代碼已經被setImmediate()設定了回調,那麼事件循環直接結束poll階段進入check階段來執行check隊列裏的回調。
    • 若是代碼沒有被設定setImmediate()設定回調:

      • 若是有被設定的timers,那麼此時事件循環會檢查timers,若是有一個或多個timers下限時間已經到達,那麼事件循環將繞回timers階段,並執行timers的有效回調隊列。
      • 若是沒有被設定timers,這個時候事件循環是阻塞在poll階段等待回調被加入poll隊列。

check

這個階段容許在poll階段結束後當即執行回調。若是poll階段空閒,而且有被setImmediate()設定的回調,那麼事件循環直接跳到check執行而不是阻塞在poll階段等待回調被加入。

setImmediate()其實是一個特殊的timer,跑在事件循環中的一個獨立的階段。它使用libuvAPI來設定在poll階段結束後當即執行回調。

注:setImmediate()具備最高優先級,只要poll隊列爲空,代碼被setImmediate(),不管是否有timers達到下限時間,setImmediate()的代碼都先執行。

close callbacks

若是一個sockethandle被忽然關掉(好比socket.destroy()),close事件將在這個階段被觸發,不然將經過process.nextTick()觸發。

關於setTimeout和setImmediate

代碼重現,咱們會發現setTimeoutsetImmediate在Node環境下執行是靠「隨緣法則」的。

好比說下面這段代碼:

setTimeout(() => {
    console.log('setTimeout');
}, 0);
setImmediate(() => {
    console.log('setImmediate');
})

執行的結果是這樣子的:

爲何會這樣子呢?

這裏咱們要根據前面的那個事件循環不一樣階段的圖解來講明一下:

首先進入的是timers階段,若是咱們的機器性能通常,那麼進入timers階段,一毫秒已通過去了(setTimeout(fn, 0)等價於setTimeout(fn, 1)),那麼setTimeout的回調會首先執行。

若是沒有到一毫秒,那麼在timers階段的時候,下限時間沒到,setTimeout回調不執行,事件循環來到了poll階段,這個時候隊列爲空,此時有代碼被setImmediate(),因而先執行了setImmediate()的回調函數,以後在下一個事件循環再執行setTimemout的回調函數。

而咱們在執行代碼的時候,進入timers的時間延遲實際上是隨機的,並非肯定的,因此會出現兩個函數執行順序隨機的狀況。

那咱們再來看一段代碼:

var fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});

這裏咱們就會發現,setImmediate永遠先於setTimeout執行。

緣由以下:

fs.readFile的回調是在poll階段執行的,當其回調執行完畢以後,poll隊列爲空,而setTimeout入了timers的隊列,此時有代碼被setImmediate(),因而事件循環先進入check階段執行回調,以後在下一個事件循環再在timers階段中執行有效回調。

一樣的,這段代碼也是同樣的道理:

setTimeout(() => {
    setImmediate(() => {
        console.log('setImmediate');
    });
    setTimeout(() => {
        console.log('setTimeout');
    }, 0);
}, 0);

以上的代碼在timers階段執行外部的setTimeout回調後,內層的setTimeoutsetImmediate入隊,以後事件循環繼續日後面的階段走,走到poll階段的時候發現隊列爲空,此時有代碼被setImmedate(),因此直接進入check階段執行響應回調(注意這裏沒有去檢測timers隊列中是否有成員到達下限事件,由於setImmediate()優先)。以後在第二個事件循環的timers階段中再去執行相應的回調。

綜上,咱們能夠總結:

  • 若是二者都在主模塊中調用,那麼執行前後取決於進程性能,也就是隨機。
  • 若是二者都不在主模塊調用(被一個異步操做包裹),那麼setImmediate的回調永遠先執行。

process.nextTick() and Promise

對於這兩個,咱們能夠把它們理解成一個微任務。也就是說,它其實不屬於事件循環的一部分。

那麼他們是在何時執行呢?

無論在什麼地方調用,他們都會在其所處的事件循環最後,事件循環進入下一個循環的階段前執行。

舉個?:

setTimeout(() => {
    console.log('timeout0');
    process.nextTick(() => {
        console.log('nextTick1');
        process.nextTick(() => {
            console.log('nextTick2');
        });
    });
    process.nextTick(() => {
        console.log('nextTick3');
    });
    console.log('sync');
    setTimeout(() => {
        console.log('timeout2');
    }, 0);
}, 0);

結果是:

再解釋一下:

timers階段執行外層setTimeout的回調,遇到同步代碼先執行,也就有timeout0sync的輸出。遇到process.nextTick後入微任務隊列,依次nextTick1nextTick3nextTick2入隊後出隊輸出。以後,在下一個事件循環的timers階段,執行setTimeout回調輸出timeout2

最後

下面給出兩段代碼,若是可以理解其執行順序說明你已經理解透徹。

代碼1:

setImmediate(function(){
  console.log("setImmediate");
  setImmediate(function(){
    console.log("嵌套setImmediate");
  });
  process.nextTick(function(){
    console.log("nextTick");
  })
});

// setImmediate
// nextTick
// 嵌套setImmediate

解析:事件循環check階段執行回調函數輸出setImmediate,以後輸出nextTick。嵌套的setImmediate在下一個事件循環的check階段執行回調輸出嵌套的setImmediate

代碼2:

var fs = require('fs');

function someAsyncOperation (callback) {
  // 假設這個任務要消耗 95ms
  fs.readFile('/path/to/file', callback);
}

var timeoutScheduled = Date.now();

setTimeout(function () {

  var delay = Date.now() - timeoutScheduled;

  console.log(delay + "ms have passed since I was scheduled");
}, 100);


// someAsyncOperation要消耗 95 ms 才能完成
someAsyncOperation(function () {

  var startCallback = Date.now();

  // 消耗 10ms...
  while (Date.now() - startCallback < 10) {
    ; // do nothing
  }

});

解析:事件循環進入poll階段發現隊列爲空,而且沒有代碼被setImmediate()。因而在poll階段等待timers下限時間到達。當等到95ms時,fs.readFile首先執行了,它的回調被添加進poll隊列並同步執行,耗時10ms。此時總共時間累積105ms。等到poll隊列爲空的時候,事件循環會查看最近到達的timer的下限時間,發現已經到達,再回到timers階段,執行timer的回調。


若是有什麼問題,歡迎留言交流探討。

參考連接:

https://nodejs.org/en/docs/gu...

https://github.com/creeperyan...

相關文章
相關標籤/搜索