單機高並行模型設計

2022-07-09 12:01:26

背景

在微服務架構下,我們習慣使用多機器、分散式儲存、快取去支援一個高並行的請求模型,而忽略了單機高並行模型是如何工作的。這篇文章通過解構使用者端與伺服器端的建立連線和資料傳輸過程,闡述下如何進行單機高並行模型設計。

經典C10K問題

如何在一臺物理機上同時服務10K使用者,及10000個使用者,對於java程式設計師來說,這不是什麼難事,使用netty就能構建出支援並行超過10000的伺服器端程式。那麼netty是如何實現的?首先我們忘掉netty,從頭開始分析。
每個使用者一個連線,對於伺服器端就是兩件事

  1. 管理這10000個連線
  2. 處理10000個連線的資料傳輸

TCP連線與資料傳輸

連線建立

我們以常見TCP連線為例。

一張很熟悉的圖。這篇重點在伺服器端分析,所以先忽略使用者端細節。
伺服器端通過建立socket,bind埠,listen準備好了。最後通過accept和使用者端建立連線。得到一個connectFd,即連線通訊端(在Linux都是檔案描述符),用來唯一標識一個連線。之後資料傳輸都基於這個。

資料傳輸

為了進行資料傳輸,伺服器端開闢一個執行緒處理資料。具體過程如下

  1. select應用程式向系統核心空間,詢問資料是否準備好(因為有視窗大小限制,不是有資料,就可以讀),資料未準備好,應用程式一直阻塞,等待應答。

  2. read核心判斷資料準備好了,將資料從核心拷貝到應用程式,完成後,成功返回。

  3. 應用程式進行decode,業務邏輯處理,最後encode,再傳送出去,返回給使用者端

因為是一個執行緒處理一個連線資料,對應的執行緒模型是這樣

多路複用

阻塞vs非阻塞

因為一個連線傳輸,一個執行緒,需要的執行緒數太多,佔用的資源比較多。同時連線結束,資源銷燬。又得重新建立連線。所以一個自然而然的想法是複用執行緒。即多個連線使用同一個執行緒。這樣就引發一個問題,
原本我們進行資料傳輸的入口處,,假設執行緒正在處理某個連線的資料,但是資料又一直沒有好時,因為select是阻塞的,這樣即使其他連線有資料可讀,也讀不到。所以不能是阻塞的,否則多個連線沒法共用一個執行緒。所以必須是非阻塞的。

輪詢 VS 事件通知

改成非阻塞後,應用程式就需要不斷輪詢核心空間,判斷某個連線是否ready.

for (connectfd fd:  connectFds) {
    if (fd.ready) {
        process();
    }
}

輪詢這種方式效率比較低,非常耗CPU,所以一種常見的做法就是被呼叫方發事件通知告知呼叫方,而不是呼叫方一直輪詢。這就是IO多路複用,一路指的就是標準輸入和連線通訊端。通過提前註冊一批通訊端到某個分組中,當這個分組中有任意一個IO事件時,就去通知阻塞物件準備好了。

select/poll/epoll

IO多路複用技術實現常見有select,poll。select與poll區別不大,主要就是poll沒有最大檔案描述符的限制。

從輪詢變成事件通知,使用多路複用IO優化後,雖然應用程式不用一直輪詢核心空間了。但是收到核心空間的事件通知後,應用程式並不知道是哪個對應的連線的事件,還得遍歷一下

onEvent() {
// 監聽到事件
    for (connectfd fd:  registerConnectFds) {
        if (fd.ready) {
            process();
        }
    }
}

可預見的,隨著連線數增加,耗時在正比增加。相比較與poll返回的是事件個數,epoll返回是有事件發生的connectFd陣列,這樣就避免了應用程式的輪詢。

onEvent() {
// 監聽到事件
    for (connectfd fd: readyConnectFds) {
       process();
    }
}

當然epoll的高效能不止是這個,還有邊緣觸發(edge-triggered),就不在本篇闡述了。

非阻塞IO+多路複用整理流程如下:

  1. select應用程式向系統核心空間,詢問資料是否準備好(因為有視窗大小限制,不是有資料,就可以讀),直接返回,非阻塞呼叫。

  2. 核心空間中有資料準備好了,傳送ready read給應用程式

  3. 應用程式讀取資料,進行decode,業務邏輯處理,最後encode,再傳送出去,返回給使用者端

執行緒池分工

上面我們主要是通過非阻塞+多路複用IO來解決區域性的selectread問題。我們再重新梳理下整體流程,看下整個資料處理過程可以如何進行分組。這個每個階段使用不同的執行緒池來處理,提高效率。
首先事件分兩種

  1. 連線事件accept動作來處理
  2. 傳輸事件 selectread,send 動作來處理。

連線事件處理流程比較固定,無額外邏輯,不需要進一步拆分。傳輸事件 readsend是相對比較固定的,每個連線的處理邏輯相似,可以放在一個執行緒池處理。而具體邏輯decode,logic,encode 各個連線處理邏輯不同。整體可以放在一個執行緒池處理。

伺服器端拆分成3部分

  1. reactor部分,統一處理事件,然後根據型別分發
  2. 連線事件分發給acceptor,資料傳輸事件分發給handler
  3. 如果是資料傳輸型別,handler read完再交給processorc處理

因為1,2處理都比較快,放線上程池處理,業務邏輯放在另外一個執行緒池處理。

以上就是大名鼎鼎的reactor高並行模型。