說說Nodejs高併發的原理

語言: CN / TW / HK

寫在前面

我們先來看幾個常見的說法

  • nodejs是單執行緒 + 非阻塞I/O模型
  • nodejs適合高併發
  • nodejs適合I/O密集型應用,不適合CPU密集型應用

在具體分析這幾個說法是不是、為什麼之前,我們先來做一些準備工作

從頭聊起

一個常見web應用會做哪些事情

  • 運算(執行業務邏輯、數學運算、函式呼叫等。主要工作在CPU進行)
  • I/O(如讀寫檔案、讀寫資料庫、讀寫網路請求等。主要工作在各種I/O裝置,如磁碟、網絡卡等)

一個典型的傳統web應用實現

  • 多程序,一個請求fork一個(子)程序 + 阻塞I/O(即blocking I/O或BIO)
  • 多執行緒,一個請求建立一個執行緒 + 阻塞I/O

多程序web應用示例虛擬碼

```scss listenFd = new Socket(); // 建立監聽socket Bind(listenFd, 80); // 繫結埠 Listen(listenFd); // 開始監聽

for ( ; ; ) { // 接收客戶端請求,通過新的socket建立連線 connFd = Accept(listenFd); // fork子程序 if ((pid = Fork()) === 0) { // 子程序中 // BIO讀取網路請求資料,阻塞,發生程序排程 request = connFd.read(); // BIO讀取本地檔案,阻塞,發生程序排程 content = ReadFile('test.txt'); // 將檔案內容寫入響應 Response.write(content); } }

```

多執行緒應用實際上和多程序類似,只不過將一個請求分配一個程序換成了一個請求分配一個執行緒。執行緒對比程序更輕量,在系統資源佔用上更少,上下文切換(ps:所謂上下文切換,稍微解釋一下:單核心CPU的情況下同一時間只能執行一個程序或執行緒中的任務,而為了巨集觀上的並行,則需要在多個程序或執行緒之間按時間片來回切換以保證各進、執行緒都有機會被執行)的開銷也更小;同時執行緒間更容易共享記憶體,便於開發

上文中提到了web應用的兩個核心要點,一個是進(線)程模型,一個是I/O模型。那阻塞I/O到底是什麼?又有哪些其他的I/O模型呢?彆著急,首先我們看一下什麼是阻塞

什麼是阻塞?什麼是阻塞I/O?

簡而言之,阻塞是指函式呼叫返回之前,當前進(線)程會被掛起,進入等待狀態,在這個狀態下,當前進(線)程暫停執行,引起CPU的進(線)程排程。函式只有在內部工作全部執行完成後才會返回給呼叫者

所以阻塞I/O是,應用程式通過API呼叫I/O操作後,當前進(線)程將會進入等待狀態,程式碼無法繼續往下執行,這時CPU可以進行進(線)程排程,即切換到其他可執行的進(線)程繼續執行,當前進(線)程在底層I/O請求處理完後才會返回並可以繼續執行

多進(線)程 + 阻塞I/O模型有什麼問題?

在瞭解了什麼是阻塞和阻塞I/O後,我們來分析一下傳統web應用多進(線)程 + 阻塞I/O模型有什麼弊端。

因為一個請求需要分配一個進(線)程,這樣的系統在併發量大時需要維護大量進(線)程,且需要進行大量的上下文切換,這都需要大量的CPU、記憶體等系統資源支撐,所以在高併發請求進來時CPU和記憶體開銷會急劇上升,可能會迅速拖垮整個系統導致服務不可用

nodejs應用實現

接下來我們看看nodejs應用是如何實現的。

  • 事件驅動,單執行緒(主執行緒)
  • 非阻塞I/O 在官網上可以看到,nodejs最主要的兩大特點,一個是單執行緒事件驅動,一個是“非阻塞”I/O模型。單執行緒 + 事件驅動比較好理解,前端同學應該都很熟悉js的單執行緒和事件迴圈這套機制了,那我們主要來研究一下這個“非阻塞I/O”是怎麼一回事。首先來看一段nodejs服務端應用常見的程式碼,

```javascript const net = require('net'); const server = net.createServer(); const fs = require('fs');

server.listen(80); // 監聽埠 // 監聽事件建立連線 server.on('connection', (socket) => { // 監聽事件讀取請求資料 socket.on('data', (data) => { // 非同步讀取本地檔案 fs.readFile('test.txt', (err, data) => { // 將讀取的內容寫入響應 socket.write(data); socket.end(); }) }); });

```

可以看到在nodejs中,我們可以以非同步的方式去進行I/O操作,通過API呼叫I/O操作後會馬上返回,緊接著就可以繼續執行其他程式碼邏輯,那為什麼nodejs中的I/O是“非阻塞”的呢?回答這個問題之前我們再做一些準備工作,參考nodejs進階視訊講解:進入學習

read操作基本步驟

首先看下一個read操作需要經歷哪些步驟

  • 使用者程式呼叫I/O操作API,內部發出系統呼叫,程序從使用者態轉到核心態
  • 系統發出I/O請求,等待資料準備好(如網路I/O,等待資料從網路中到達socket;等待系統從磁碟上讀取資料等)
  • 資料準備好後,複製到核心緩衝區
  • 從核心空間複製到使用者空間,使用者程式拿到資料

接下來我們看一下作業系統中有哪些I/O模型

幾種I/O模型

阻塞式I/O


非阻塞式I/O


I/O多路複用(程序可同時監聽多個I/O裝置就緒)


訊號驅動I/O


非同步I/O


那麼nodejs裡到底使用了哪種I/O模型呢?是上圖中的“非阻塞I/O”嗎?彆著急,先接著往下看,我們來了解下nodejs的體系結構

nodejs體系結構,執行緒、I/O模型分析

最上面一層是就是我們編寫nodejs應用程式碼時可以使用的API庫,下面一層則是用來打通nodejs和它所依賴的底層庫的一箇中間層,比如實現讓js程式碼可以呼叫底層的c程式碼庫。來到最下面一層,可以看到前端同學熟悉的V8,還有其他一些底層依賴。注意,這裡有一個叫libuv的庫,它是幹什麼的呢?從圖中也能看出,libuv幫助nodejs實現了底層的執行緒池、非同步I/O等功能。libuv實際上是一個跨平臺的c語言庫,它在windows、linux等不同平臺下會呼叫不同的實現。我這裡主要分析linux下libuv的實現,因為我們的應用大部分時候還是執行在linux環境下的,且平臺間的差異性並不會影響我們對nodejs原理的分析和理解。好了,對於nodejs在linux下的I/O模型來說,libuv實際上提供了兩種不同場景下的不同實現,處理網路I/O主要由epoll函式實現(其實就是I/O多路複用,在前面的圖中使用的是select函式來實現I/O多路複用,而epoll可以理解為select函式的升級版,這個暫時不做具體分析),而處理檔案I/O則由多執行緒(執行緒池) + 阻塞I/O模擬非同步I/O實現


下面是一段我寫的nodejs底層實現的虛擬碼幫助大家理解

```scss listenFd = new Socket(); // 建立監聽socket Bind(listenFd, 80); // 繫結埠 Listen(listenFd); // 開始監聽

for ( ; ; ) { // 阻塞在epoll函式上,等待網路資料準備好 // epoll可同時監聽listenFd以及多個客戶端連線上是否有資料準備就緒 // clients表示當前所有客戶端連線,curFd表示epoll函式最終拿到的一個就緒的連線 curFd = Epoll(listenFd, clients);

if (curFd === listenFd) {
    // 監聽套接字收到新的客戶端連線,建立套接字
    int connFd = Accept(listenFd);
    // 將新建的連線新增到epoll監聽的list
    clients.push(connFd);
}

else {
    // 某個客戶端連線資料就緒,讀取請求資料
    request = curFd.read();
    // 這裡拿到請求資料後可以發出data事件進入nodejs的事件迴圈
    ...
}

}

// 讀取本地檔案時,libuv用多執行緒(執行緒池) + BIO模擬非同步I/O ThreadPool.run((callback) => { // 線上程裡用BIO讀取檔案 String content = Read('text.txt');
// 發出事件呼叫nodejs提供的callback });

```

通過I/O多路複用 + 多執行緒模擬的非同步I/O配合事件迴圈機制,nodejs就實現了單執行緒處理併發請求並且不會阻塞。所以回到之前所說的“非阻塞I/O”模型,實際上nodejs並沒有直接使用通常定義上的非阻塞I/O模型,而是I/O多路複用模型 + 多執行緒BIO。我認為“非阻塞I/O”其實更多是對nodejs程式設計人員來說的一種描述,從編碼方式和程式碼執行順序上來講,nodejs的I/O呼叫的確是“非阻塞”的

總結

至此我們應該可以瞭解到,nodejs的I/O模型其實主要是由I/O多路複用和多執行緒下的阻塞I/O兩種方式一起組成的,而應對高併發請求時發揮作用的主要就是I/O多路複用。好了,那最後我們來總結一下nodejs執行緒模型和I/O模型對比傳統web應用多進(線)程 + 阻塞I/O模型的優勢和劣勢

  • nodejs利用單執行緒模型省去了系統維護和切換多進(線)程的開銷,同時多路複用的I/O模型可以讓nodejs的單執行緒不會阻塞在某一個連線上。在高併發場景下,nodejs應用只需要建立和管理多個客戶端連線對應的socket描述符而不需要建立對應的程序或執行緒,系統開銷上大大減少,所以能同時處理更多的客戶端連線
  • nodejs並不能提升底層真正I/O操作的效率。如果底層I/O成為系統的效能瓶頸,nodejs依然無法解決,即nodejs可以接收高併發請求,但如果需要處理大量慢I/O操作(比如讀寫磁碟),仍可能造成系統資源過載。所以高併發並不能簡單的通過單執行緒 + 非阻塞I/O模型來解決
  • CPU密集型應用可能會讓nodejs的單執行緒模型成為效能瓶頸
  • nodejs適合高併發處理少量業務邏輯或快I/O(比如讀寫記憶體)