【死磕 NIO】— Reactor 模式就一定意味著高性能嗎?

大家好,我是大明哥,我又來了。

為什麼是 Reactor

一般所有的網路服務,一般分為如下幾個步驟:

  • 讀請求(read request)

  • 讀解析(read decode)

  • 處理程式(process service)

  • 應答編碼 (encode reply)

  • 發送應答(send reply)

接下來,大明哥就來分析解決這個問題的最佳實踐。

單執行緒模式

對於很多小夥伴來說,最簡單,最傳統的方式就是一個方法來處理所有的請求,這種實現方式最簡單,也是最保險的方式。

這種方式實現起來雖然簡單,但是性能不行,如果其中有一個請求因為某種原因阻塞了,則他後面的所有請求都會阻塞在那裡,同時他也沒法利用多 CPU 的性能,性能嚴重不足。

多執行緒模式

單執行緒的性能肯定不行,那就調整為多執行緒方式。

每來一個請求就會創建一個執行緒來處理,這種方式雖然不會像 單執行緒模式 一樣,一個執行緒會阻塞所有的請求,但是他依然很大的問題:

  • 當客戶端多,並發大的時候,需要創建大量執行緒來處理,執行緒的創建和銷毀也很消耗資源,會導致整個系統的的資源佔用較大

  • 同樣無法應對高性能和高並發

執行緒池模式

既然多執行緒模式需要創建這麼多執行緒,那麼我們控制創建執行緒的個數,採用資源復用 執行緒池 的方式,也就是我們不需要再為每一個連接創建一個執行緒,而是創建一個執行緒池,將連接分配給執行緒,然後一個執行緒可以處理多個鏈接。

這種執行緒池的方式雖然解決了系統資源佔用的問題,但是他依然帶了了一個新的問題,每一個執行緒如何高效地處理請求呢?在上篇文章中 【死磕NIO】— 阻塞IO,非阻塞IO,IO復用,訊號驅動IO,非同步IO,這你真的分的清楚嗎?我們提到過在單個執行緒中如果當前連接在進行read操作時,如果沒有數據可讀,則會發生阻塞,那麼執行緒就沒有辦法繼續處理其他連接的業務了。那麼怎麼解決?將 read 操作改為非阻塞的方式,既然改為了非阻塞方式,那執行緒如何知道read 操作有數據可讀了呢?

  • 第一種方式,則是不斷的去輪詢,但是輪詢要消耗 CPU的,而且隨著輪詢的執行緒多了,輪詢的效率會越來越低

  • 第二種方式,事件驅動。當執行緒關心的事件發生了,比如read 有數據可讀了,則通知相對應的執行緒進行處理

Reactor 模式

第二種方式就是 I/O多路復用。I/O多路復用就是通過一種機制,一個執行緒可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知執行緒進行相應的讀寫操作。目前支援 IO多路復用技術有:

  • Linux:selectpollepoll

  • MAC:kqueue

  • Windows:select

監聽執行緒幫助我們監聽哪些執行緒的事件已發生,發生後則通知相對應的執行緒進行處理,這樣就可以避免進行很多無用的操作。對處理執行緒而言,整個處理過程只有調用 selectpollepoll 的時候才會阻塞,其他時段,他可以處理其他的事情,這樣整個執行緒會被充分利用起來,這樣就高效很多了。

什麼是 Reactor模式

上面講了 Reactor 模式的演變,那什麼是 Reactor 模式呢?

wiki上是這樣定義的:

Reactor 模式也叫做反應器設計模式,它是一種為處理服務請求並發提交到一個或者多個服務處理程式的事件設計模式。當請求抵達後,服務處理程式使用解多路分配策略,然後同步地派發這些請求至相關的請求處理程式。

簡要概括就是: 將消息放到了一個隊列中,通過非同步執行緒池對其進行消費。暫時理解成下面這個樣子:

對於Reactor模式來說,他並沒隊列,每當有一個 Event 輸入到 Server端時,Service Handler 會將其轉發(dispatch)相對應的handler進行處理。

Reactor的組件主要包括三個:

  • Reactor:派發器,將 client端的事件分發給相對應的Handler

  • Acceptor:請求連接器,Reactor 接收到 client 連接事件後,會將其轉發給 Acceptor,Acceptor 則會接受 Client 的連接,建立對應的Handler,並向 Reactor註冊此Handler

  • Handler:請求處理器,負責事件的處理。

模型大致如下圖:

Reactor 模式

Reactor 模型中的Reactor可以是多個也可以是單個,Handler同樣可以是單執行緒也可以是多執行緒,所以組合的模式大致有如下四種:

  • 單Reactor單執行緒/進程

  • 單Reactor多執行緒/進程

  • 多Reactor單執行緒/進程

  • 多Reactor多執行緒/進程

其中第三種多Reactor單執行緒並沒有什麼實際的意思,所以大明哥重點介紹第一、二、四種。

單Reactor單執行緒/進程

  • Reactor 執行緒通過 select (IO多路復用介面)監聽事件,收到事件後通過Dispatch 來分發事件,事件會分發給Acceptor和Handler 兩個組件,具體是哪個組件要看事件的類型。

  • 如果事件類型為建立連接,則將事件分發給Acceptor,Acceptor會通過 accept 方法 獲取連接,並創建一個 Handler 對象來處理後續的響應事件。

  • 如果時間類型不是建立連接,則將該事件交由當前連接的Handler來處理。

優缺點

  • 優點:該模型是將所有處理邏輯放在一個執行緒中實現,模型簡單,沒有多執行緒、進程通訊、競爭的問題

  • 缺點

    • 由於只有一個執行緒,無法充分利用CPU,性能堪憂。同時Handler 在處理某個連接上的業務時,整個進程無法處理其他連接事件,很容易導致性能瓶頸。

    • 還有一個比較嚴重的可靠性問題,如果執行緒意外終止,或者進入死循環,則會導致整個執行緒都無法接受和處理事件了,造成節點故障。

單Reactor多執行緒/進程

單執行緒存在性能瓶頸,那我們就引入多執行緒方案。

Reactor 接受請求後,根據請求類型來進行分發,分發邏輯與 單Reactor單執行緒 模型一樣,不同之處在於Handler不在進行業務處理了,它只負責接受和發送,Handler接受數據後,會將數據發送給 Worker 執行緒池中的執行緒處理,該執行緒才是處理業務的真正執行緒,執行緒將業務處理完成後,將數據發送給Handler,然後Handler 再send出去。

優缺點

  • 優點:由於Handler使用了多執行緒模式,則可以利用充分利用CPU的性能

  • 缺點:

    • Handler使用多執行緒模式,則會涉及到數據共享的問題,需要考慮互斥,實現肯定比 單Reactor單執行緒模式複雜一些

    • 單Reactor,一個執行緒處理事件監聽、分發、響應,對於高並發場景,容易造成性能瓶頸

多Reactor多執行緒/進程

單Reactor多執行緒模式解決了Handler單執行緒的性能問題,但是Reactor還是單執行緒的,對於高並發場景還是會有性能瓶頸,所以需要對Reactor調整為 多執行緒模式

  • 主執行緒中的MainReactor對象通過select監聽事件,接收到事件後通過Dispatch進行分發,如果事件類型為建立連接則將事件分發給Acceptor 進行連接建立

  • 如果收到的事件不是連接,則他將事件分發個某個SubReactor,SubrReactor 將連接加入到連接隊列進行監聽,並創建Handler進行各種事件處理

  • 如果有新的事件發生,SubReactor 則會調用當前連接的Handler來進行處理。Handler 通過read 讀取數據後,將數據發送給Worker執行緒進行處理,Worker執行緒池則會分配執行緒進行業務處理,處理完成後返回結果,Handler接受結果後,通過send發送給客戶端

優缺點

  • 優點:該模式主執行緒和子執行緒分工明確,主執行緒只負責接收新連接,子執行緒負責完成後續的業務處理,同時主執行緒和子執行緒的交互也很簡單,子執行緒接收主執行緒的連接後,只管業務處理即可,無須關注主執行緒

  • 缺點:模型複雜

這種模式適用於高並發場景,廣泛運用於各種項目中,如大名鼎鼎的Netty。

Reactor 優缺點

Reactor模式有如下優點:

  • 響應快,不必為單個同步時間所阻塞

  • 可以最大程度的避免複雜的多執行緒及同步問題,並且避免了多執行緒/進程的切換開銷

  • 擴展性好,可以方便的通過增加 Reactor 實例個數來充分利用 CPU 資源

  • 復用性好,Reactor 模型本身與具體事件處理邏輯無關,具有很高的復用性

雖然Reactor有諸多優點,但是由於他的IO讀寫數據時還是在同一個執行緒中實現的,如果當前執行緒出現了一個長時間的IO數據讀寫,則會影響其他的client。那怎麼解決呢?請靜候下一篇文章。

參考資料