說說Nodejs高併發的原理
寫在前面
我們先來看幾個常見的說法
- 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(比如讀寫記憶體)