單機高並發模型設計

背景

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

經典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高並發模型。