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

chenssy發表於2021-10-22

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

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

參考資料

相關文章