在微服務架構下,我們習慣使用多機器、分散式儲存、快取去支援一個高並行的請求模型,而忽略了單機高並行模型是如何工作的。這篇文章通過解構使用者端與伺服器端的建立連線和資料傳輸過程,闡述下如何進行單機高並行模型設計。
如何在一臺物理機上同時服務10K使用者,及10000個使用者,對於java程式設計師來說,這不是什麼難事,使用netty就能構建出支援並行超過10000的伺服器端程式。那麼netty是如何實現的?首先我們忘掉netty,從頭開始分析。
每個使用者一個連線,對於伺服器端就是兩件事
我們以常見TCP連線為例。
一張很熟悉的圖。這篇重點在伺服器端分析,所以先忽略使用者端細節。
伺服器端通過建立socket,bind埠,listen準備好了。最後通過accept和使用者端建立連線。得到一個connectFd,即連線通訊端(在Linux都是檔案描述符),用來唯一標識一個連線。之後資料傳輸都基於這個。
為了進行資料傳輸,伺服器端開闢一個執行緒處理資料。具體過程如下
select
應用程式向系統核心空間,詢問資料是否準備好(因為有視窗大小限制,不是有資料,就可以讀),資料未準備好,應用程式一直阻塞,等待應答。
read
核心判斷資料準備好了,將資料從核心拷貝到應用程式,完成後,成功返回。
應用程式進行decode,業務邏輯處理,最後encode,再傳送出去,返回給使用者端
因為是一個執行緒處理一個連線資料,對應的執行緒模型是這樣
因為一個連線傳輸,一個執行緒,需要的執行緒數太多,佔用的資源比較多。同時連線結束,資源銷燬。又得重新建立連線。所以一個自然而然的想法是複用執行緒。即多個連線使用同一個執行緒。這樣就引發一個問題,
原本我們進行資料傳輸的入口處,,假設執行緒正在處理某個連線的資料,但是資料又一直沒有好時,因為select
是阻塞的,這樣即使其他連線有資料可讀,也讀不到。所以不能是阻塞的,否則多個連線沒法共用一個執行緒。所以必須是非阻塞的。
改成非阻塞後,應用程式就需要不斷輪詢核心空間,判斷某個連線是否ready.
for (connectfd fd: connectFds) {
if (fd.ready) {
process();
}
}
輪詢這種方式效率比較低,非常耗CPU,所以一種常見的做法就是被呼叫方發事件通知告知呼叫方,而不是呼叫方一直輪詢。這就是IO多路複用,一路指的就是標準輸入和連線通訊端。通過提前註冊一批通訊端到某個分組中,當這個分組中有任意一個IO事件時,就去通知阻塞物件準備好了。
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+多路複用整理流程如下:
select
應用程式向系統核心空間,詢問資料是否準備好(因為有視窗大小限制,不是有資料,就可以讀),直接返回,非阻塞呼叫。
核心空間中有資料準備好了,傳送ready read給應用程式
應用程式讀取資料,進行decode,業務邏輯處理,最後encode,再傳送出去,返回給使用者端
上面我們主要是通過非阻塞+多路複用IO來解決區域性的select
和read
問題。我們再重新梳理下整體流程,看下整個資料處理過程可以如何進行分組。這個每個階段使用不同的執行緒池來處理,提高效率。
首先事件分兩種
accept
動作來處理select
,read
,send
動作來處理。連線事件處理流程比較固定,無額外邏輯,不需要進一步拆分。傳輸事件 read
,send
是相對比較固定的,每個連線的處理邏輯相似,可以放在一個執行緒池處理。而具體邏輯decode
,logic
,encode
各個連線處理邏輯不同。整體可以放在一個執行緒池處理。
伺服器端拆分成3部分
因為1,2處理都比較快,放線上程池處理,業務邏輯放在另外一個執行緒池處理。
以上就是大名鼎鼎的reactor高並行模型。