Node.js事件循環

說到Node.js的事件循環網上已經有了很多形形色色的文章來講述其中的原理,說的大概都是一個意思,學習了一段時間,對Node.js事件循環有了一定的瞭解之後寫一篇博客總結一下自己的學習成果。

事件循環

在筆者看來事件與循環本身就是兩個概念,事件是可以被控件識別的操作,如按下確定按鈕,選擇某個單選按鈕或者複選框。每一種控件有自己可以識別的事件,如窗體的加載、單擊、雙擊等事件,編輯框(文本框)的文本改變事件。

然而循環則是在GUI線程中包含有一個循環,然而這個循環對於開發者和用戶來講是看不見的,只有關閉了程序之後該循環纔會結束。當用戶觸發了一個按鈕事件之後,就會產生響應的事件,這些時間被加入到一個隊列中,用戶在前臺不斷的產生事件,然而後臺也在不斷的處理這些時間,在處理的時候被加入到一個隊列中,由於主循環中循環的存在會挨個處理這些對應的事件。

image

而對於JavaScript來講的話由於JavaScript是單線程的,對於一個比較耗時的操作則是使用異步的方法解決(Ajax...)。對於不同的異步事件來也是由不同的線程各司其職來處理的。

Node.js中的事件循環

Node.js的事件循環與瀏覽器的事件循環還是有很大的區別的,當Node.js啓動後,它會初始化事件輪詢;處理已提供的輸入腳本(或丟入REPL,本文不涉及到),它可能會調用一些異步的API函數調用,安排任務處理事件,或者調用process.nextTick(),然後開始處理事件循環。

有一點是非常明確的,事件循環同樣運行在單線程環境下,JavaScript的事件循環是依靠於瀏覽器來實現的,然而Node.js則是依賴於Libuv來實現的。

根據Node.js官方介紹,每次事件循環都包含了6個階段,對應到Libuv源碼中的實現,如下圖所示,圖中顯示了事件循環的概述以及執行順序。

image

  1. timersj階段:這個階段執行timer(setTimeout、setInterval)的回調
  2. I/O callbacks:執行一些系統調用錯誤,比如網絡通信的錯誤回調
  3. idle,prepare:僅node內部使用
  4. poll:獲取新的I/O事件, 適當的條件下node將阻塞在這裏
  5. check:執行 setImmediate() 的回調
  6. close callbacks:執行 socket 的 close 事件回調

下面是Node.js事件循環源代碼:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;
  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);
  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    // timers階段
    uv__run_timers(loop);
    // I/O callbacks階段
    ran_pending = uv__run_pending(loop);
    // idle階段
    uv__run_idle(loop);
    // prepare階段
    uv__run_prepare(loop);
    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);
    // poll階段
    uv__io_poll(loop, timeout);
    // check階段
    uv__run_check(loop);
    // close callbacks階段
    uv__run_closing_handles(loop);
    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }
    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;
  return r;
}

假設事件循環進入到某一個階段,及時在這期間其他隊列中的事件已經準備就緒,也會先將當前階段對應隊列中所有的回調方法執行完畢之後纔會繼續向下執行,結合代碼也是能夠很好的理解的。不難可以得出在事件循環系統中回調的執行順序是有跡可循的,同樣也會造成事件阻塞。

var fs = require("fs");
fs.readFile('input.txt', function (err, data) {
   if (err){
      console.log(err.stack);
      return;
   }
   console.log(data.toString());
});
fs.readFile('test.txt', function (err, data) {
   if (err){
      console.log(err.stack);
      return;
   }
   console.log(data.toString());
});
console.log("程序執行完畢");

對於整個事件循環有個一個大概的認知之後,接下來針對每個階段進行詳細的說明。

timers

該階段主要用來處理定時器相關的回調方法,當一個定時器超市後一個事件就會加入到該階段的隊列中,事件循環會跳轉至這個階段執行對應的回調方法。

定時器的回調會在觸發後儘可能早的被調用,爲什麼要說儘可能早的呢?因爲實際的觸發事件可能要比預先設置的時間要長。Node.js並不能保證timer在預設時間到了就會立即執行,因爲Node.jstimer的過期檢查不一定靠譜,它會受機器上其它運行程序影響,或者那個時間點主線程不空閒。

I/O callbacks

在這個階段中除了timers、setImmediate,以及close操作之外的大多數的回調方法都位於這個階段執行。例一個TCP socket執行出現了一些錯誤,那麼這個回調函數會在I/O callbacks階段來執行。名字會讓人誤解爲執行I/O回調處理程序,然而一些常見的回調則會再poll階段進行處理。

I/O callbacks階段主要經過如下過程:

  1. 檢查是否有pending的I/O回調。如果有,執行回調。如果沒有,退出該階段。
  2. 檢查是否有process.nextTick任務,如果有,全部執行。
  3. 檢查是否有microtask,如果有,全部執行。
  4. 退出該階段。

poll

對於Poll階段其主要的功能主要有兩點:

  1. 處理 poll 隊列的事件
  2. 當有已超時的 timer,執行它的回調函數

當事件循環到達poll階段時,如果這時沒有要處理的定時器的回調方法,則會進行如下判斷:

  1. 如果poll隊列不爲空,則事件循環會按照順序便利執行隊列中的回調方法,這個過程是同步的。
  2. 如果poll隊列爲空則會再次進行判斷

    • 若有預設的setImmediate(),事件循環將結束poll階段進入check階段,並執行check階段的任務隊列
    • 若沒有預設的setImmediate(),那麼事件循環可能會進入等待狀態,並等待新事件的產生,這也是該階段爲什麼被命名爲poll的原因。出了這些意外,該階段還會不斷的檢查是否有相關的定時器超市,如果有就會跳轉到timers階段,然後執行對應的回調方法

check

該階段執行setImmediate()的回調函數。關於setImmediate是一個比較特殊的定時器方法,setImmediate的回調則會加入到check隊列中,從事件循環的階段圖可以知道,check階段的執行順序是在poll之後的。

一般情況下,事件循環到達poll階段後,就會檢查當前代碼是否調用了setImmediate方法,這個在敘述poll階段的時候已經有提及了,如果一個回調函數是被setImmediate方法調用的,事件循環則會跳出poll階段從而進入到check階段。(這一段有點重複...)

close

close階段是用來管理關閉事件,用於清理應用程序的狀態。如程序中的socket關閉等都會加入到close隊列中,當本輪事件結束後則會進入下一輪循環。

小結

對於事件循環來說每個階段都有一個任務隊列,當事件循環到達某個階段的時候,講執行該階段的任務隊列,知道隊列清空或執行的對調到達系統上限後,纔會轉入到下一個階段。當所有的階段被執行一次後,事件循環則就完成了一個tick

process.nextTick

這是Node.js特有的方法,它不存在於任何瀏覽器(以及進程對象)中,process.nextTick是一個異步的動作,並且讓這個動作在事件循環中當前階段執行完之後立即執行,也就是上面所說的tick

process.nextTick(() => {
    console.log("1")   
})
console.log("2")
//  2
//  1

官方對於process.nextTick有一段很有意思的解釋:從語義角度看,setImmediate(稍後會說到)應該比process.nextTick先執行纔對,而事實相反,命名是歷史原因也很難再變。

然而對於process.nextTick來說該方法並不是事件循環中的一部分,但是它的回調方法確是由事件循環調用的,該方法定義的回調方法會被加入到nextTickQueue的隊列中。相反地,nextTickQueue將會在當前操作完成之後立即被處理,而不管當前處於事件循環的哪個階段。

Node.jsprocess.nextTick進行了限制,若遞歸調用process.nextTick當倒帶nextTickQueue最大限制之後則會拋出一個錯誤。

function nextTick (i){
    while(i<9999){
        process.nextTick(nextTick(i++));
    }
}

//  Maxmum call stack size exceeded
nextTick(0);

既然說process.nextTick也是存在於隊列中,那麼其執行順序也是根據程序所編寫順序執行的。

process.nextTick(() => {
    console.log(1)
});
process.nextTick(() => {
    console.log(2)
});

//  1
//  2

和其它回調函數一樣,process.nextTick定義的回調也是由事件循環執行的,如果process.nextTick的回調方法中出現了阻塞操作,後面的要執行的回調函數同樣會被阻塞。process.nextTick會在各個事件階段之間執行,一旦執行,要直到nextTickQueue被清空,纔會進入到下一個事件階段,所以如果遞歸調用process.nextTick,會導致出現I/O starving的問題,比如下面例子的readFile已經完成,但它的回調一直無法執行。

const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {
  endtime = Date.now()
  console.log('finish reading time: ', endtime - starttime)
})
let index = 0
function handler () {
  if (index++ >= 1000) return
  console.log(`nextTick ${index}`)
  process.nextTick(handler)
}
handler();

//  nextTick 1
//  nextTick 2
//  ......
//  nextTick 999
//  nextTick 1000
//  finish reading time: 170

process.nextTick() vs setImmediate()

seImmediate方法不屬於ECMAScript標準,而是Node.js提出的新方法,它同樣將一個回調函數加入到事件隊列中,不同於setTimeoutsetIntervalsetImmediate並不接受一個時間作爲參數,setImmediate的事件會在當前事件循環的結尾觸發,對應的回調方法會在當前事件循環的末尾(check)執行。雖然它確實存在於某些瀏覽器中,但並未在所有瀏覽器中達到一致的行爲,因此在瀏覽器中使用時,您需要非常小心。它類似於setTimeout(fn,0)代碼,但有時會優先於它。這裏的命名也不是最好的。

  1. process.nextTick中的回調在事件循環的當前階段中被立即執行。
  2. setImmediate中的回調在事件循環的下一次迭代或tick中被執行

本質上,它們兩個的名字應該互相調換一下。process.nextTick()的執行時機比setImmediate()要更及時(上面有提過)。實施這項改變將導致很多npm包無法使用。每天都有很多新模塊被加入,這意味着每等待一天,就會有更多潛在的破壞發生。雖然他們的名字相互混淆,但將它們調換名字這種事是不會發生的(建議開發者在所有地方使用setImmediate,這樣程序更容易讓人理解)。

仍然使用上述例子,若把nextTick替換成setImmediate會怎樣呢?

const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {
  endtime = Date.now()
  console.log('finish reading time: ', endtime - starttime)
})
let index = 0
function handler () {
  if (index++ >= 1000) return
  console.log(`setImmediate ${index}`)
  setImmediate(handler)
}
handler();

// setImmediate 1
// setImmediate 2
// finish reading time: 80
// ......
// setImmediate 999
// setImmediate 1000

這是因爲嵌套調用的setImmediate()回調,被排到了下一次事件循環才執行,所以不會出現阻塞。

setImmediate vs setTimeout

定時器在Node.js和瀏覽器中的表現形式是相同的。關於定時器的一個重要的事情是,我們提供的延遲不代表在這個時間之後回調就會被執行。它的真正含義是,一旦主線程完成所有操作(包括微任務)並且沒有其它具有更高優先級的定時器,Node.js將在此時間之後執行回調。

  1. setImmediate()被設計在poll階段結束後立即執行回調
  2. setTimeout()被設計在指定下限時間到達後執行回調
setTimeout(function timeout () {
  console.log('timeout');
},0);

setImmediate(function immediate () {
  console.log('immediate');
});

//  結果一
//  timeout
//  immediate
/**--------華麗的分割線--------**/
//  結果二
//  immediate
//  timeout

why?爲什麼會有兩個結果,筆者在研究這裏的時候也是有些不太明白,於是又做了第二個例子:

var fs = require('fs')
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
});
//  運行N次
//  immediate
//  timeout
  1. 如果兩者都在主模塊調用,那麼執行先後取決於進程性能,即隨機。
  2. 如果兩者都不在主模塊調用,那麼setImmediate的回調永遠先執行。

雖然結論得出來了,但是這又是爲啥呢?回想一下文章上半段所敘述的事件循環。首先進入timer階段,如果我們的機器性能一般,那麼進入timer階段時,1毫秒可能已經過去了(setTimeout(fn,0)等價於setTimeout(fn,1)),那麼setTimeout的回調會首先執行。如果沒到一毫秒,那麼我們可以知道,在check階段,setImmediate的回調會先執行。爲什麼fs.readFile回調裏設置的,setImmediate始終先執行?因爲fs.readFile的回調執行是在poll階段,所以,接下來的check階段會先執行setImmediate的回調。我們可以注意到,UV_RUN_ONCE模式下,事件循環會在開始和結束都去執行timer

練習題

閱讀完本文章有什麼收穫呢?不如看下下面的代碼,預測一下輸出結果是什麼樣的。先不要急着看答案額...

const fs = require('fs');
console.log('beginning of the program');
const promise = new Promise(resolve => {
    console.log('I am in the promise function!');
    resolve('resolved message');
});
promise.then(() => {
    console.log('I am in the first resolved promise');
}).then(() => {
    console.log('I am in the second resolved promise');
});
process.nextTick(() => {
    console.log('I am in the process next tick now');
});
fs.readFile('index.html', () => {
    console.log('==================');
    setTimeout(() => {
        console.log('I am in the callback from setTimeout with 0ms delay');
    }, 0);
    setImmediate(() => {
        console.log('I am from setImmediate callback');
    });
});
setTimeout(() => {
    console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);
setImmediate(() => {
    console.log('I am from setImmediate callback');
});

// beginning of the program
// I am in the promise function!
// I am in the process next tick now
// I am in the first resolved promise
// I am in the second resolved promise
// I am in the callback from setTimeout with 0ms delay
// I am from setImmediate callback
// ==================
// I am from setImmediate callback
// I am in the callback from setTimeout with 0ms delay

總結

對於本文中一些知識點任然有些模糊,懵懵懂懂,一直都在學習中,通過學習事件循環也看了一些文獻,在其中看到了這一句話:除了你的代碼,一切都是同步的,我覺得很有道理,對於理解事件循環很有幫助。

  1. Node.js的事件循環分爲6個階段
  2. process.nextTick不屬於事件循環,但是產生的回調會加入到nextTickQueue
  3. setImmediatesetTimeout的執行順序會受到環境所影響

文章略長若文章中有哪些錯誤,請在評論區指出,我會盡快做出修正。大家可以踊躍發言共同進步,交流。

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