Node.js Event loop 原理

乃乎發表於2019-08-23

Event Loop

為什麼會有 Event loop

簡單來說 Event loop 通過將請求分發到別的地方,使得 Node.js 能夠實現非阻塞 (non-blocking) I/O 操作

Event loop 是如何工作的

流程是這樣的,你執行 node index.js 或者 npm start 之類的操作啟動服務,所有的同步程式碼會被執行,然後會判斷是否有 Active handle,如果沒有就會停止。

比如你的 index.js 是下面這樣,那程式執行完便會直接停止

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

但是,一般來說我們都會啟動 http 模組,比如下面的 express 的 hello world 事例

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。

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

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

    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 階段。

Node.js Event loop 原理

上面的圖就是 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),需要避免踩坑。

Node.js Event loop 原理

檢驗

實踐是檢驗真理的唯一標準,下面程式碼的執行結果如果和你想的一樣,那就說明你掌握了上面的知識,如果不一樣,那就再看一遍吧。

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 無需清楚作業系統的型別,或者是非同步的方式。

Node.js Event loop 原理

舉例來說,我們使用的 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 越短越好,意味著能更快相應新的請求。

Node.js Event loop 原理

但是從上面的圖片我們可以發現,在 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
複製程式碼

Node.js Event loop 原理

Ref

相關文章