Node.js Event loop 原理

Event Loop

爲何會有 Event loop

簡單來講 Event loop 經過將請求分發到別的地方,使得 Node.js 可以實現非阻塞 (non-blocking) I/O 操做html

Event loop 是如何工做的

流程是這樣的,你執行 node index.js 或者 npm start 之類的操做啓動服務,全部的同步代碼會被執行,而後會判斷是否有 Active handle,若是沒有就會中止。node

好比你的 index.js 是下面這樣,那進程運行完便會直接中止git

// index.js
console.log('Hello world');
複製代碼

可是,通常來講咱們都會啓動 http 模塊,好比下面的 express 的 hello world 事例github

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => res.send('Hello World!'))
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
複製代碼

這裏運行了 app.listen 函數就是一個 active handle,有這個的存在,就至關於 Node.js "有理由"繼續運行下去,這樣咱們就進入了 Event loop。express

Event loop 包含一系列階段 (phase),每一個階段都是隻執行屬於本身的的任務 (task) 和微任務 (micro task),這些階段依次爲:npm

  1. timers
  2. pending callbacks
  3. idle, prepare
  4. poll
  5. check
  6. close callbacks
  • 先說簡單的 timer 階段,當你使用 setTimeout()setInterval() 的時候,傳入的回調函數就是在這個階段執行。promise

    setTimeout(() => {
      console.log('Hello world') // 這一行在 timer 階段執行
    }, 1000)
    複製代碼
  • check 階段和 timer 相似,當你使用 setImmediate() 函數的時候,傳入的回調函數就是在 check 階段執行。瀏覽器

    setImmediate(() => {
      console.log('Hello world') // 這一行在 check 階段執行
    })
    複製代碼
  • poll 階段基本上涵蓋了剩下的全部的狀況,你寫的大部分回調,若是不是上面兩種(還要除掉 micro task,後面會講),那基本上就是在 poll 階段執行的。性能優化

    // io 回調
    fs.readFile('index.html', "utf8", (err, data) => {
    	console.log('Hello world') // 在 poll 階段執行
    });
    
    // http 回調
    http.request('http://example.com', (res) => {
      res.on('data', () => {})
      res.on('end', () => {
    		console.log('Hello world') // 在 poll 階段執行
      })
    }).end()
    複製代碼

這裏解答一個我本身的困惑,由於我實際上是卡在這裏卡了好久。不知道讀者有沒有注意到,那就是爲何咱們一直在講回調 (callback)?難道 Node.js 就全是回調麼?服務器

嗯,還真的基本上都是。

固然這裏的回調是廣義的回調,你們能夠想想,當咱們運行 server.listen() 以後,剩下的代碼是否是都是對各個不一樣的請求的處理。只要是請求的處理函數,就都算是回調了,並且更準確的說,這些回調都會進入 poll 階段。

上面的圖就是 Event loop 的各個階段,注意到,除了咱們上面講的以外,每一個 phase 還有一個 microtask 的階段。這個階段就是咱們下面主要要講的 process.nextTickPromise 的回調函數運行的地方。

Microtask

咱們能夠想像成每一個階段有三個 queue,

  1. 這個階段的"同步" task queue
  2. 這個階段的 process.nextTick 的 queue
  3. 這個階段的 Promise queue

首先採用先進先出的方式處理該階段的 task,當全部同步的 task 處理完畢後,先清空 process.nextTick 隊列,而後是 Promise 的隊列。這裏須要注意的是,不一樣於遞歸調用 setTimeout ,若是在某一個階段一直遞歸調用 process.nextTick,會致使 main thread 一直停留在該階段,表現相似於同步代碼的 while(true),須要避免踩坑。

檢驗

實踐是檢驗真理的惟一標準,下面代碼的運行結果若是和你想的同樣,那就說明你掌握了上面的知識,若是不同,那就再看一遍吧。

const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
  testEventLoop()
});
server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

function testEventLoop() {
  console.log('=============')

  // Timer
  setTimeout(() => {
    console.log('Timer phase') 
    process.nextTick(() => {
      console.log('Timer phase - nextTick')
    })
    Promise.resolve().then(() => {
      console.log('Timer phase - promise')
    })
  });

  // Check
  setImmediate(() => {
    console.log('Check phase')
    process.nextTick(() => {
      console.log('Check phase - nextTick')
    })
    Promise.resolve().then(() => {
      console.log('Check phase - promise')
    })
  })

  // Poll
  console.log('Poll phase');
  process.nextTick(() => {
    console.log('Poll phase - nextTick')
  })
  Promise.resolve().then(() => {
    console.log('Poll phase - promise')
  })
}
複製代碼

結果

=============
Poll phase
Poll phase - nextTick
Poll phase - promise
Check phase
Check phase - nextTick
Check phase - promise
Timer phase 
Timer phase - nextTick
Timer phase - promise
複製代碼

libuv 線程池與內核

總結下第一部分的內容咱們能夠發現,其實 Event loop 就是咱們所認爲的 Node.js 的單線程,也就是 main-thread,負責 dispatch tasks 和執行 JavaScript 代碼。那當咱們發起 I/O 請求的時候,好比讀取文件,是誰來負責執行的呢?這個問題就涉及到咱們這個部分的主要內容 - Node.js 的異步實現方式。

直接說結論,調用操做系統的接口,都是由 Node.js 調用 libuv 的 API 實現的,其中咱們能夠將這些異步的 Node.js API 分爲兩類:

  1. 直接用內核 (Kernel) 的異步方法
  2. 使用線程池 (Thread pool) 來模擬異步

下面的表列出了哪些 API 分別使用哪一種調用機制,固然這些都是由 libuv 封裝實現的,Node.js 無需清楚操做系統的類型,或者是異步的方式。

舉例來講,咱們使用的 http 模塊就是使用的 kernel async 的方式。這種異步方式由內核直接實現,因此像下面的代碼,多個請求之間不會有明顯的時間間隔。

const https = require('https')

function testHttps() {
  const num = 6;
  const startTime = Date.now();
  console.log('--------------------')
  for(let i=1; i <= num; i++) {
    https.request('https://nebri.us/static/me.jpg', (res) => {
      res.on('data', () => {})
      res.on('end', () => {
        const endTIme = Date.now();
        const diff = endTIme - startTime;
        console.log(`https time ${diff}ms`)
      })
    }).end()
  }
}

testHttps()

/** -------------------- https time 4105ms https time 4332ms https time 4337ms https time 4422ms https time 4454ms https time 4499ms */
複製代碼

其中一個使用線程池的例子是 pbkdf2 加密函數。加密是一個很耗費計算 (CPU intensive) 的操做,由 libuv 線程池來模擬異步。線程池默認只有 4 個線程,因此當咱們同時調用 6 個加密操做,後面 2 個會被前面 4 個 block。因此最後的結果會像下面的代碼,能夠看到第五個明顯比前四個要慢。

const crypto = require('crypto')

function testCrypto() {
  const num = 6
  const startTime = Date.now();
  console.log('--------------------')
  for(let i=1; i <= num; i++) {
    crypto.pbkdf2('secret', 'salt', 10000, 512, 'sha512', () => {
      const endTIme = Date.now();
      const diff = endTIme - startTime;
      console.log(`Crypto time ${diff}ms`)
    })
  }
}

testCrypto()

/** -------------------- Crypto time 69ms Crypto time 69ms Crypto time 70ms Crypto time 72ms Crypto time 132ms Crypto time 132ms */
複製代碼

還有些特殊的狀況,好比 fs.readFile,儘管官方文檔說 fs.readFile 也是使用 libuv 線程池的,理論上來講,應該和 pbkdf2 相似,因爲線程池的緣由,第五個文件的讀取應該被前四個阻塞,但實際上能夠看到結果並非這樣。這個我不是很肯定,可是估計是在 Node.js 這裏作了 partition 處理,至於什麼是 partition?後面會講。

const fs = require('fs')

function testFile(){
  const num = 6
  const startTime = Date.now();
  console.log('--------')
  for(let i=1; i <= num; i++) {
    fs.readFile(`index${i}.html`, "utf8", (err, data) => {
      const endTIme = Date.now();
      const diff = endTIme - startTime;
      console.log(`Read file ${i} ` + diff)
    });
  }
}

testFile()

/** -------- Read file 5 138 Read file 1 159 Read file 2 191 Read file 6 218 Read file 4 243 Read file 3 270 -------- Read file 2 416 Read file 6 444 Read file 4 474 Read file 1 501 Read file 3 531 Read file 8 560 Read file 9 587 Read file 5 656 Read file 7 689 */
複製代碼

性能優化

這部分主要講咱們在寫 Node.js 的時候須要注意什麼,其實基本上也就只有一點,和瀏覽器環境相似,那就是不要阻塞你的主線程 (Do not block you main thread)。至於爲何想必你們也都知道,主線程指的是 Event loop,這個被阻塞的話,相似於服務器被 DDOS 攻擊,沒有辦法處理新的請求了。

不要使用 *sync

Node.js API 提供了不少同步的調用方式,一句話,儘可能不要用,由於這些同步調用會阻塞 Event loop。好比 fs.readFileSync(),儘管是使用 libuv 線程池讀取文件的,可是 Event loop 仍是會主動阻塞等待完成。Event loop 這段阻塞的時間完徹底全是浪費的,因此,不要用。

When event loop idle

從下面的圖片咱們能看出什麼?這裏先補充一些背景知識:

  • 什麼是 tick?一個 tick 指的是 Event loop 完整的走完一圈
  • tick frequency 指的是 tick 的頻率,tick duration 指的是一個 tick 的時間長度。通常咱們認爲,tick duration 越短越好,意味着能更快相應新的請求。

可是從上面的圖片咱們能夠發現,在 idle 的時候和在高併發的時候,tick duration 表現很類似。這裏就引出了一個 Event loop 的細節,Event loop 在閒置的時候,究竟在幹嗎。直觀理解可能會認爲,閒置的時候就一直轉圈圈,但從上面的圖咱們能夠發現,實際上不是的。當 poll 階段空閒的時候:

  • 若是沒有 timer (這裏包括 setTimeout, setInterval )和 setImmediate ,就會一直在 poll 階段阻塞;
  • 若是有已經到時的 timer 或者 setImmedate,則會 proceeds to next phase

Offloading

爲何不少人說 Node.js 不適合作 CPU intensive 的 task。這個其實應區別來講,首先,由於咱們的主線程其實就是 Event loop。咱們的 JavaScript 代碼就運行在 Event loop,若是 JavaScript 代碼涉及到太多的計算,的確會致使 Event loop 阻塞。可是實際上 CPU intensive 的部分咱們能夠交給別人來作,這個操做就叫作 offloading。好比 pbkd2 加密,是交給 libuv 的線程池來搞定的,並不會阻塞主線程,也就不會有什麼問題。

Partition

上面的 offloading 至關於把任務交給別人作,咱們只要作任務完成後的回調就能夠。還有一種不阻塞主線程的方式叫 partition (能夠看成時間切片) 。好比咱們要計算一個累加,若是遇到大數的狀況,有可能會阻塞主線程。可是能夠用 partition 的方式異步處理,這樣就將時間複雜度從原來的 O(n) 變成 n * O(1),不會阻塞 Event loop。

function normalAdd(n) {
  const start = Date.now();
  let sum = 0
  for (let i=1; i <=n; i++) {
    sum += i
  }
  const end = Date.now();
  const diff = end - start;
  console.log('normal time ' + diff)
  return sum
}

function partitionAdd(n, cb) {
  const start = Date.now();
  let sum = 0
  let i = 1
  const count = () => {
    if (i <= n) {
      sum += i
      i += 1
      return setImmediate(count)
    }
    const end = Date.now();
    const diff = end - start;
    console.log('partition time ' + diff)
    cb(sum)
  }
  setImmediate(count)
}

console.log(normalAdd(1000000)); 
partitionAdd(1000000, console.log); 

/** normal time 4 500000500000 partition time 943 500000500000 */

複製代碼

如何監控 Node.js 服務

由上面的圖能夠看出 Event loop duration 沒辦法反應出服務當前的健康狀況,由於空閒狀況和高併發狀況的表現相似,那咱們有什麼方式監控能咱們的 Node.js 服務是否正常處理用戶請求呢?**Event loop latency ** 是一個很好的指標。

咱們知道 setTimeout 的回調函數過時後會在 timer 階段執行,可是若是若是 poll 階段的任務執行時間過長,setTimeout 的回調函數過時後也不必定當即執行,而是會有一段時間的 delay,若是這個 delay 的時間過長,就說明 Event loop 在 poll 階段被阻塞了。

console.log('start', Date.now())
setTimeout(() => {
  console.log('end', Date.now())
}, 1000)
// end - start 有可能會 > 1000ms
複製代碼

Ref

相關文章
相關標籤/搜索