1. 并發網絡服務器設計方案
下表是陳碩總結的 12 種常見方案。其中“多連接互通”指的是如果開發 chat 服務,多個客戶連接之間是否能方便的交換數據。“順序性” 指客戶端順序發送多個請求,那么服務器計算得到的多個響應是否按相同的順序發還給客戶端。
其中比較實用的有5種方案
表中 N 表示并發連接數目,C1 和 C2 是與連接數無關、與 CPU 數目有關的常數。
2. Reactor模式
Reactor模式(反應器模式):是一個使用了同步非阻塞的 I/O 多路復用機制的模式,即 “non-blocking IO + IO multiplexing” 這種模型(本質是 event-driven 事件驅動一種實現方式)。
使用 IO multiplexing,也就是 select / poll / epoll 這一系列的 “多路選擇器”,讓一個 thread-of-control 能處理多個連接。“IO 復用” 其實復用的不是 IO 連接,而是復用線程。使用 IO multiplexing 幾乎肯定要配合 non-blocking IO,而使用 non-blocking IO 肯定要使用應用層 buffer。原因如下
為什么 non-blocking 幾乎總是和 IO-multiplexing 一起使用?
- 沒有人真的會用輪詢(busy-pooling)來檢查某個 non-blocking IO 操作是否完成,這樣太浪費 CPU cycles.
- IO-multiplex 一般不和 blocking IO 用在一起,因為 blocking IO 中的 read() / write() / accept() / connect() 都有可能阻塞當前線程,這樣當前線程就沒有辦法處理其他 socket 上的 IO 事件了。比如某個 socket 接收緩沖區有新數據分節到達,然后 select 報告這個 socket 描述符可讀,但隨后協議檢查發現有錯誤的校驗然后這個分節被丟棄,這時調用 read 則無數據可讀就會一直阻塞。再比如,多個線程對某個 socket 進行監聽,當新的連接到達后,多個線程的 select 都會被喚醒,但是最后只有1個線程可以 accept,其他線程都將被阻塞(一般來說不會用多個線程去監聽新的連接到來,這里只是用于舉例說明而已)。
為什么 non-blocking 網絡編程中應用層 buffer 是必須的?
需要輸出緩沖區(output buffer)的原因:
比如一個場景,程序想通過 TCP 連接發送 100k 字節的數據,但是在 write() 調用中,操作系統只接受了 80k,肯定不能一直等待,因為不知道會等多久。這時程序需要盡快交出控制器,返回 event loop。剩余的 20k 字節數據應該由網絡庫接管,把它保存在該 TCP connection 的輸出緩沖區(output buffer)里,然后注冊 POLLOUT 事件,一旦 socket 變得可寫立馬就立刻發送數據。如果寫完了 20k 字節,網絡庫應該停止關注 POLLOUT,以免造成 busy loop。-
需要輸入緩沖區(input buffer)的原因:
TCP 是一個無邊界的字節流協議,接收方必須要處理 “收到的數據尚不構成一條完整的消息(俗稱半包)” 和 “一次收到兩條消息的數據(俗稱粘包)” 等等情況。比如一個場景是,發送方 send 了兩條 10k 字節的消息(共20k),接收方收到數據的情況可能是:- 一次性收到 20k 數據
- 分兩次收到,第一次 5k,第二次 15k
- 分兩次收到,第一次 15k,第二次 5k
- 分兩次收到,第一次 10k,第二次 10k
- 分三次收到,第一次 6k,第二次 8k,第三次 6k
- 其他任何可能
那么網絡庫必然要應對 “數據不完整” 的情況,收到的數據先放到 input buffer 里,等構成一條完整的消息再通知程序的業務邏輯層。這通常是編解碼層的職責。網絡庫在處理 “socket 可讀” 事件的時候,必須一次性把 socket 里的數據讀完(從操作系統 buffer 搬到應用層 buffer),否則會反復出發 POLLIN 事件,造成 busy-loop。
Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系統調用上,這樣可以最大限度的復用 thread-of-control,讓一個線程能服務于多個 socket 連接。 IO 線程只能阻塞在 IO-multiplexing 函數上,如 select() / poll() / epoll_wait()。這樣一來應用層的緩沖區是必須的,每個 TCP socket 都要有 input buffer 和 output buffer。
在 “non-blocking IO + IO multiplexing” 這種模型下,程序的基本結構是一個事件循環(event loop),偽代碼如下
while (!done)
{
int timeout_ms = max(1000, getNextTimedCallback());
int retval = ::poll(fds, nfds, timeout_ms);
if (retval < 0) {
//錯誤處理
} else {
//處理到期的 timers
if (retval > 0) {
//處理 IO 事件
}
}
}
當然, select / poll 有很多不足,Linux 下可替換為 epoll,它們之間的區別見《關于 select、poll、epoll 的區別》,其他操作系統也有對應的高性能替代品。
Douglas C. Schmidt 為我們總結的 Recator 模式:
Recator 結構
Recator模式的角色構成
- Handle:(Windows 下句柄或者 Linux 文件描述符)是由操作系統提供的資源,是事件產生的發源地。事件既可來自于外部(比如客戶端的連接請求,客隊發送過來的數據等),也可以來自于內部(比如操作系統產生的定時事件等)。
- Synchronous Event Demultiplexer:(同步事件分離器)它是一個系統調用,用于等待事件的發生,該方法在調用時會被阻塞,一直阻塞到同步事件分離器上有事件產生為止。在 Linux 下,同步事件分離器就是常用的 I/O 多路復用機制(IO multiplexing)select、poll、epoll 等。在 Java NIO 中,同步事件分離器對應的組件就是 Selector,對應的阻塞方法就是 select 方法。
- Event Handler:(事件處理器)由多個回調方法構成,這些回調構成了與應用相關的對于某個事件的反饋機制。
- Concrete Event Handler:(具體事件處理器)是事件處理器的實現。它本身實現了事件處理器的各種回調方法,從而實現了特定業務的邏輯。
- Initiation Dispatcher:(初始分發器)實際上就是 Reactor 角色。它本身定義了一些規范,這些規范用于控制事件的調度,同時又提供了應用進行事件處理器的注冊、刪除等方法。它是整個事件處理器的核心所在,它會通過同步事件分離器來等待事件的發生。一旦事件發生,分發器首先會分離出每個事件,然后調用事件處理器,最后調用相關的回調方法來處理這些事件。回調方法都是由 IO 線程里的 EventLoop 來調用的。
Reactor 模式處理流程
- 1 應用初始化 Initiation Dispatcher。
- 2 當應用向 Initiation Dispatcher 注冊 Concrete Event Handler 時,應用會標識出該事件處理器希望 Initiation Dispatcher 在某種類型的事件發生時向其通知,事件與 Handle 關聯。
- 3 Initiation Dispatcher 要求注冊在其上面的 Concrete Event Handler 傳遞內部關聯的 Handle,該 Handle 向操作系統標識了事件處理器。
- 4 當所有的 Concrete Event Handler 都注冊到 Initiation Dispatcher 上后,應用會調用 handle_events 方法來啟動 Initiation Dispatcher 的事件循環,這時 Initiation Dispatcher 會將每個 Concrete Event Handler 的 Handle 合并起來,并使用 Synchronous Event Demultiplexer 來等待這些 Handle 上事件的發生。
- 5 當與某個事件源對應的 Handle 變為 ready 時(比如 socket 變為 ready for reading),Synchronous Event Demultiplexer 便會通知 Initiation Dispatcher。
- 6 Initiation Dispatcher 會觸發事件處理器的回調方法。從而響應這個處于 ready 狀態的 Handle。當事件發生時,Initiation Dispatcher 會將被事件源激活的 Handle 作為 “key” 來尋找并分發恰當的事件處理器回調方法。
- 7 Initiation Dispatcher 調用特定的 Concrete Event Handler 的 handle_event(event_type) 回調方法來響應其關聯的 Handle 上發生的事件。所發生的事件類型可以作為該方法參數并被該方法內部使用來執行額外的特定于服務的分離與分發。
3. 對五種實用網絡服務器方案的解讀
方案2:thread-per-connection
經典的每個連接對應一個線程的同步阻塞 I/O 模式。
這是傳統的 Java 網絡編程方案 thread-per-connection, 在 Java1.4 引入 NIO 之前, Java 網絡服務器程序多采用這種方案。這種方案的伸縮性受到線程數的限制,一兩百個還行,幾千個的話對操作系統的 scheduler 恐怕是個不小的負擔。
流程:
① 服務端的 Server 是一個線程,線程中執行一個死循環來阻塞的監聽客戶端的連接請求和通信。
② 當客戶端向服務端發送一個連接請求后,服務端的 Server 會接受客戶端的請求,accept() 從阻塞中返回,得到一個與客戶端連接 socket。
③ 創建一個線程并啟動該線程,構建一個 handler,將socket傳入該 handler。在線程中執行 handler,這樣與客戶端的所有的通信以及數據處理都在該線程中執行。當該客戶端和服務器端完成通信關閉連接后,線程就會被銷毀。
④ Server 繼續執行 accept() 操作等待新的連接請求。
優點:使用簡單,容易編程。
缺點:并發性不高,伸縮性受到線程數的限制。
適用場景:如果只有少量的連接使用非常高的帶寬,一次發送大量的數據,也許該方案比較適合。
方案5:單線程 reactor
單線程 Reactor 模式
流程:
① 服務端的 Reactor 是一個線程對象,該線程會啟動事件循環(EventLoop)。注冊一個 Acceptor 事件處理器到 Reactor 中, Acceptor 事件處理器所關注的事件是 ACCEPT 事件,這樣 Reactor 會監聽客戶端向服務器發起的連接請求事件(ACCEPT 事件)。
② 客戶端向服務器發起一個連接請求后,Reactor 監聽到 ACCEPT 事件的發生并將該 ACCEPT 事件派發到相應的 Acceptor 處理器來進行處理,通過 accept() 方法得到與這個客戶端對應的連接,然后將該連接所關注的 READ / WRITE 事件以及它們對應的事件處理器注冊到 Reactor 上。
③ 當 Reactor 監聽到有讀或者寫事件發生時,將相關的事件派發給對應的處理器進行處理。
④ 每當處理完所有就緒的感興趣的 I/O 事件后,Reactor 線程會再次執行 select/poll/epoll_wait 阻塞等待新的事件就緒并將其分派給對應的處理器進行處理。
單線程 Reactor 的程序執行順序(下圖左)。在沒有事件的時候,線程等待在select/poll/epoll_wait 函數上。由于只有一個線程,因此事件是有順序處理的。從“poll 返回之后” 到 “下一次調用 poll 進入等待之前” 指段時間內,線程不會被其他連接上了的數據或事件搶占(下圖右)。
適用場景:在單核服務器中使用該模型比較適合
優點:由網絡庫搞定數據的收發,程序只需關心業務邏輯的處理
缺點:不適合CPU密集計算的應用,難發揮多核的威力。由于非I/O的業務操作也在該線程上進行處理了,這可能會大大延遲I/O請求的響應。
方案8:reactor + 線程池
一個 reactor 線程 + 業務線程池 模式
與單線程Reactor模式不同的是,添加了一個工作者線程池,并將非 I/O 操作從 Reactor 線程中移出轉交給工作者線程池來執行。這樣能夠提高 Reactor 線程的網絡 I/O 響應,不至于因為一些耗時的業務邏輯而延遲對后面 I/O 請求的處理。
程序執行的順序圖如下:
應用場景:計算任務彼此獨立,而且IO壓力不大,非常適合此方案。
如果 IO 的壓力比較大,一個 reactor 忙不過來,可以試試 multiple reactors 的方案9。
方案9:one loop per thread
multiple reactors 模式
這是 muduo 內置的多線程方案,也是 netty 內置的多線程方案。這種方案的特點是 one loop per thread,有一個 main reactor 負責 accept 連接,然后把連接掛在某個 sub reactor 中(muduo 采用 round-robin 的方式來選擇 sub reactor),這樣該連接的所有操作都在那個 sub reactor 所處的線程中完成。多個連接可能被分派到多個線程中,以充分利用 CPU。Muduo 采用的是固定大小的 reactor pool,池子的大小通常根據 CPU 核數確定,也就是說線程數是固定的,這樣程序的總體處理能力不會隨連接數增加二下降。另外,由于一個連接完全由一個線程管理,那么請求的順序性有保證。這種方案把 IO 分派給多個線程,防止出現一個 reactor 的處理能力飽和。與方案8的線程池相比,方案9減少了進出 thread pool 的兩次上下文切換,在把多個連接分散到多個 reactor 線程之后,小規模計算可以在當前 IO 線程完成然后發回結果,從而降低相應的延遲。
這是一個適應性很強的多線程 IO 模型。
方案11:one loop per thread + 線程池
multiple reactors + 業務線程池 模式
把方案8和方案9混合,既使用多個 reactors 來處理 IO,又使用線程池來處理業務邏輯計算。這種方案適合既有突發 IO (利用多線程處理多個連接上的 IO),又有突發計算的應用(利用線程池把一個連接上的計算任務分配到多個線程去做)。
3. 總結
總結起來,推薦的多線程服務端編程模式為:event loop per thread + thread pool。
- event loop 用作 non blocking IO 和定時器。
- 線程池用來做業務邏輯計算,具體可以是任務隊列或者消費者-生產者隊列。
這種模式能夠很好的處理高并發和高吞吐量,而且編程非常清晰,main reactor 處理客戶端的 connect 請求, sub reactors 處理所有的 TCP 連接的 I/O 讀寫,thread pool 處理具體的業務邏輯(對請求數據的具體處理)。
一個程序到底是使用一個 event loop 還是使用多個 event loop 呢? ZeroMQ 的手冊給出的建議是,按照每千兆比特每秒的吞吐量配一個 event loop 的比例來設置 event loop 的數目。依據這條經驗規則,在編寫運行與千兆以太網上網絡程序時,用一個 event loop 就足以應付網絡 IO。
很多語言都有基于 Reactor 模式的高質量的網絡庫。
C:libevent / libev ,C++:muduo , Java:netty 等等。
對于學習 C++ Linux網絡編程,特別推薦陳碩寫的 muduo 網絡庫 。