在微服務架構下,我們習慣使用多機器、分散式存儲、緩存去支持一個高併發的請求模型,而忽略了單機高併發模型是如何工作的。這篇文章通過解構客戶端與服務端的建立連接和數據傳輸過程,闡述下如何進行單機高併發模型設計。 ...
背景
在微服務架構下,我們習慣使用多機器、分散式存儲、緩存去支持一個高併發的請求模型,而忽略了單機高併發模型是如何工作的。這篇文章通過解構客戶端與服務端的建立連接和數據傳輸過程,闡述下如何進行單機高併發模型設計。
經典C10K問題
如何在一臺物理機上同時服務10K用戶,及10000個用戶,對於java程式員來說,這不是什麼難事,使用netty就能構建出支持併發超過10000的服務端程式。那麼netty是如何實現的?首先我們忘掉netty,從頭開始分析。
每個用戶一個連接,對於服務端就是兩件事
- 管理這10000個連接
- 處理10000個連接的數據傳輸
TCP連接與數據傳輸
連接建立
我們以常見TCP連接為例。
一張很熟悉的圖。這篇重點在服務端分析,所以先忽略客戶端細節。
伺服器端通過創建socket,bind埠,listen準備好了。最後通過accept和客戶端建立連接。得到一個connectFd,即連接套接字(在Linux都是文件描述符),用來唯一標識一個連接。之後數據傳輸都基於這個。
數據傳輸
為了進行數據傳輸,服務端開闢一個線程處理數據。具體過程如下
-
select
應用程式向系統內核空間,詢問數據是否準備好(因為有視窗大小限制,不是有數據,就可以讀),數據未準備好,應用程式一直阻塞,等待應答。 -
read
內核判斷數據準備好了,將數據從內核拷貝到應用程式,完成後,成功返回。 -
應用程式進行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+多路復用整理流程如下:
-
select
應用程式向系統內核空間,詢問數據是否準備好(因為有視窗大小限制,不是有數據,就可以讀),直接返回,非阻塞調用。 -
內核空間中有數據準備好了,發送ready read給應用程式
-
應用程式讀取數據,進行decode,業務邏輯處理,最後encode,再發送出去,返回給客戶端
線程池分工
上面我們主要是通過非阻塞+多路復用IO來解決局部的select
和read
問題。我們再重新梳理下整體流程,看下整個數據處理過程可以如何進行分組。這個每個階段使用不同的線程池來處理,提高效率。
首先事件分兩種
- 連接事件
accept
動作來處理 - 傳輸事件
select
,read
,send
動作來處理。
連接事件處理流程比較固定,無額外邏輯,不需要進一步拆分。傳輸事件 read
,send
是相對比較固定的,每個連接的處理邏輯相似,可以放在一個線程池處理。而具體邏輯decode
,logic
,encode
各個連接處理邏輯不同。整體可以放在一個線程池處理。
服務端拆分成3部分
- reactor部分,統一處理事件,然後根據類型分發
- 連接事件分發給acceptor,數據傳輸事件分發給handler
- 如果是數據傳輸類型,handler read完再交給processorc處理
因為1,2處理都比較快,放線上程池處理,業務邏輯放在另外一個線程池處理。
以上就是大名鼎鼎的reactor高併發模型。