【死磕 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。那怎麼解決呢?請靜候下一篇文章。

參考資料