淺析Node高並行的原理

2022-10-18 22:00:16

node.js極速入門課程:進入學習

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

  • 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應用範例虛擬碼

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伺服器端應用常見的程式碼,
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

1.png


非阻塞式I/O

2.png


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

3.png


訊號驅動I/O

4.png


非同步I/O

5.png


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

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

6.png

最上面一層是就是我們編寫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底層實現的虛擬碼幫助大家理解

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(比如讀寫記憶體)

更多node相關知識,請存取:!

以上就是淺析Node高並行的原理的詳細內容,更多請關注TW511.COM其它相關文章!