天天看點

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

無論是 Reactor,還是 Proactor,都是一種基于「事件分發」的網絡程式設計模式,差別在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式則是基于「已完成」的 I/O 事件。

​​​​摘要:無論是 Reactor,還是 Proactor,都是一種基于「事件分發」的網絡程式設計模式,差別在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式則是基于「已完成」的 I/O 事件。

本文分享自華為雲社群《高性能網絡架構:Reactor和 Proactor》,原文作者:小林 coding。

這次就來圖解 Reactor 和 Proactor 這兩個高性能網絡模式。

别小看這兩個東西,特别是 Reactor 模式,市面上常見的開源軟體很多都采用了這個方案,比如 Redis、Nginx、Netty 等等,是以學好這個模式設計的思想,不僅有助于我們了解很多開源軟體,而且也能在面試時吹逼。

發車!

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

演進

如果要讓伺服器服務多個用戶端,那麼最直接的方式就是為每一條連接配接建立線程。

其實建立程序也是可以的,原理是一樣的,程序和線程的差別在于線程比較輕量級些,線程的建立和線程間切換的成本要小些,為了描述簡述,後面都以線程為例。

處理完業務邏輯後,随着連接配接關閉後線程也同樣要銷毀了,但是這樣不停地建立和銷毀線程,不僅會帶來性能開銷,也會造成浪費資源,而且如果要連接配接幾萬條連接配接,建立幾萬個線程去應對也是不現實的。

要這麼解決這個問題呢?我們可以使用「資源複用」的方式。

也就是不用再為每個連接配接建立線程,而是建立一個「線程池」,将連接配接配置設定給線程,然後一個線程可以處理多個連接配接的業務。

不過,這樣又引來一個新的問題,線程怎樣才能高效地處理多個連接配接的業務?

當一個連接配接對應一個線程時,線程一般采用「read -> 業務處理 -> send」的處理流程,如果目前連接配接沒有資料可讀,那麼線程會阻塞在 read 操作上( socket 預設情況是阻塞 I/O),不過這種阻塞方式并不影響其他線程。

但是引入了線程池,那麼一個線程要處理多個連接配接的業務,線程在處理某個連接配接的 read 操作時,如果遇到沒有資料可讀,就會發生阻塞,那麼線程就沒辦法繼續處理其他連接配接的業務。

要解決這一個問題,最簡單的方式就是将 socket 改成非阻塞,然後線程不斷地輪詢調用 read 操作來判斷是否有資料,這種方式雖然該能夠解決阻塞的問題,但是解決的方式比較粗暴,因為輪詢是要消耗 CPU 的,而且随着一個 線程處理的連接配接越多,輪詢的效率就會越低。

上面的問題在于,線程并不知道目前連接配接是否有資料可讀,進而需要每次通過 read 去試探。

那有沒有辦法在隻有當連接配接上有資料的時候,線程才去發起讀請求呢?答案是有的,實作這一技術的就是 I/O 多路複用。

I/O 多路複用技術會用一個系統調用函數來監聽我們所有關心的連接配接,也就說可以在一個監控線程裡面監控很多的連接配接。

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

我們熟悉的 select/poll/epoll 就是核心提供給使用者态的多路複用系統調用,線程可以通過一個系統調用函數從核心中擷取多個事件。

PS:如果想知道 select/poll/epoll 的差別,可以看看小林之前寫的這篇文章:這次答應我,一舉拿下 I/O 多路複用!

select/poll/epoll 是如何擷取網絡事件的呢?

在擷取事件時,先把我們要關心的連接配接傳給核心,再由核心檢測:

  • 如果沒有事件發生,線程隻需阻塞在這個系統調用,而無需像前面的線程池方案那樣輪訓調用 read 操作來判斷是否有資料。
  • 如果有事件發生,核心會傳回産生了事件的連接配接,線程就會從阻塞狀态傳回,然後在使用者态中再處理這些連接配接對應的業務即可。

當下開源軟體能做到網絡高性能的原因就是 I/O 多路複用嗎?

是的,基本是基于 I/O 多路複用,用過 I/O 多路複用接口寫網絡程式的同學,肯定知道是面向過程的方式寫代碼的,這樣的開發的效率不高。

于是,大佬們基于面向對象的思想,對 I/O 多路複用作了一層封裝,讓使用者不用考慮底層網絡 API 的細節,隻需要關注應用代碼的編寫。

大佬們還為這種模式取了個讓人第一時間難以了解的名字:Reactor 模式。

Reactor 翻譯過來的意思是「反應堆」,可能大家會聯想到實體學裡的核反應堆,實際上并不是的這個意思。

這裡的反應指的是「對事件反應」,也就是來了一個事件,Reactor 就有相對應的反應/響應。

事實上,Reactor 模式也叫 Dispatcher 模式,我覺得這個名字更貼合該模式的含義,即 I/O 多路複用監聽事件,收到事件後,根據事件類型配置設定(Dispatch)給某個程序 / 線程。

Reactor 模式主要由 Reactor 和處理資源池這兩個核心部分組成,它倆負責的事情如下:

  • Reactor 負責監聽和分發事件,事件類型包含連接配接事件、讀寫事件;
  • 處理資源池負責處理事件,如 read -> 業務邏輯 -> send;

Reactor 模式是靈活多變的,可以應對不同的業務場景,靈活在于:

  • Reactor 的數量可以隻有一個,也可以有多個;
  • 處理資源池可以是單個程序 / 線程,也可以是多個程序 /線程;

将上面的兩個因素排列組設一下,理論上就可以有 4 種方案選擇:

  • 單 Reactor 單程序 / 線程;
  • 單 Reactor 多程序 / 線程;
  • 多 Reactor 單程序 / 線程;
  • 多 Reactor 多程序 / 線程;

其中,「多 Reactor 單程序 / 線程」實作方案相比「單 Reactor 單程序 / 線程」方案,不僅複雜而且也沒有性能優勢,是以實際中并沒有應用。

剩下的 3 個方案都是比較經典的,且都有應用在實際的項目中:

  • 單 Reactor 多線程 / 程序;

方案具體使用程序還是線程,要看使用的程式設計語言以及平台有關:

  • Java 語言一般使用線程,比如 Netty;
  • C 語言使用程序和線程都可以,例如 Nginx 使用的是程序,Memcache 使用的是線程。

接下來,分别介紹這三個經典的 Reactor 方案。

Reactor

單 Reactor 單程序 / 線程

一般來說,C 語言實作的是「單 Reactor 單程序」的方案,因為 C 語編寫完的程式,運作後就是一個獨立的程序,不需要在程序中再建立線程。

而 Java 語言實作的是「單 Reactor 單線程」的方案,因為 Java 程式是跑在 Java 虛拟機這個程序上面的,虛拟機中有很多線程,我們寫的 Java 程式隻是其中的一個線程而已。

我們來看看「單 Reactor 單程序」的方案示意圖:

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

可以看到程序裡有 Reactor、Acceptor、Handler 這三個對象:

  • Reactor 對象的作用是監聽和分發事件;
  • Acceptor 對象的作用是擷取連接配接;
  • Handler 對象的作用是處理業務;

對象裡的 select、accept、read、send 是系統調用函數,dispatch 和 「業務處理」是需要完成的操作,其中 dispatch 是分發事件操作。

接下來,介紹下「單 Reactor 單程序」這個方案:

  • Reactor 對象通過 select (IO 多路複用接口) 監聽事件,收到事件後通過 dispatch 進行分發,具體分發給 Acceptor 對象還是 Handler 對象,還要看收到的事件類型;
  • 如果是連接配接建立的事件,則交由 Acceptor 對象進行處理,Acceptor 對象會通過 accept 方法 擷取連接配接,并建立一個 Handler 對象來處理後續的響應事件;
  • 如果不是連接配接建立事件, 則交由目前連接配接對應的 Handler 對象來進行響應;
  • Handler 對象通過 read -> 業務處理 -> send 的流程來完成完整的業務流程。

單 Reactor 單程序的方案因為全部工作都在同一個程序内完成,是以實作起來比較簡單,不需要考慮程序間通信,也不用擔心多程序競争。

但是,這種方案存在 2 個缺點:

  • 第一個缺點,因為隻有一個程序,無法充分利用 多核 CPU 的性能;
  • 第二個缺點,Handler 對象在業務處理時,整個程序是無法處理其他連接配接的事件的,如果業務處理耗時比較長,那麼就造成響應的延遲;

是以,單 Reactor 單程序的方案不适用計算機密集型的場景,隻适用于業務處理非常快速的場景。

Redis 是由 C 語言實作的,它采用的正是「單 Reactor 單程序」的方案,因為 Redis 業務處理主要是在記憶體中完成,操作的速度是很快的,性能瓶頸不在 CPU 上,是以 Redis 對于指令的處理是單程序的方案。

單 Reactor 多線程 / 多程序

如果要克服「單 Reactor 單線程 / 程序」方案的缺點,那麼就需要引入多線程 / 多程序,這樣就産生了單 Reactor 多線程/ 多程序的方案。

聞其名不如看其圖,先來看看「單 Reactor 多線程」方案的示意圖如下:

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

詳細說一下這個方案:

上面的三個步驟和單 Reactor 單線程方案是一樣的,接下來的步驟就開始不一樣了:

  • Handler 對象不再負責業務處理,隻負責資料的接收和發送,Handler 對象通過 read 讀取到資料後,會将資料發給子線程裡的 Processor 對象進行業務處理;
  • 子線程裡的 Processor 對象就進行業務處理,處理完後,将結果發給主線程中的 Handler 對象,接着由 Handler 通過 send 方法将響應結果發送給 client;

單 Reator 多線程的方案優勢在于能夠充分利用多核 CPU 的能,那既然引入多線程,那麼自然就帶來了多線程競争資源的問題。

例如,子線程完成業務處理後,要把結果傳遞給主線程的 Reactor 進行發送,這裡涉及共享資料的競争。

要避免多線程由于競争共享資源而導緻資料錯亂的問題,就需要在操作共享資源前加上互斥鎖,以保證任意時間裡隻有一個線程在操作共享資源,待該線程操作完釋放互斥鎖後,其他線程才有機會操作共享資料。

聊完單 Reactor 多線程的方案,接着來看看單 Reactor 多程序的方案。

事實上,單 Reactor 多程序相比單 Reactor 多線程實作起來很麻煩,主要因為要考慮子程序 <-> 父程序的雙向通信,并且父程序還得知道子程序要将資料發送給哪個用戶端。

而多線程間可以共享資料,雖然要額外考慮并發問題,但是這遠比程序間通信的複雜度低得多,是以實際應用中也看不到單 Reactor 多程序的模式。

另外,「單 Reactor」的模式還有個問題,因為一個 Reactor 對象承擔所有事件的監聽和響應,而且隻在主線程中運作,在面對瞬間高并發的場景時,容易成為性能的瓶頸的地方。

多 Reactor 多程序 / 線程

要解決「單 Reactor」的問題,就是将「單 Reactor」實作成「多 Reactor」,這樣就産生了第 多 Reactor 多程序 / 線程的方案。

老規矩,聞其名不如看其圖。多 Reactor 多程序 / 線程方案的示意圖如下(以線程為例):

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

方案詳細說明如下:

  • 主線程中的 MainReactor 對象通過 select 監控連接配接建立事件,收到事件後通過 Acceptor 對象中的 accept 擷取連接配接,将新的連接配接配置設定給某個子線程;
  • 子線程中的 SubReactor 對象将 MainReactor 對象配置設定的連接配接加入 select 繼續進行監聽,并建立一個 Handler 用于處理連接配接的響應事件。
  • 如果有新的事件發生時,SubReactor 對象會調用目前連接配接對應的 Handler 對象來進行響應。

多 Reactor 多線程的方案雖然看起來複雜的,但是實際實作時比單 Reactor 多線程的方案要簡單的多,原因如下:

  • 主線程和子線程分工明确,主線程隻負責接收新連接配接,子線程負責完成後續的業務處理。
  • 主線程和子線程的互動很簡單,主線程隻需要把新連接配接傳給子線程,子線程無須傳回資料,直接就可以在子線程将處理結果發送給用戶端。

大名鼎鼎的兩個開源軟體 Netty 和 Memcache 都采用了「多 Reactor 多線程」的方案。

采用了「多 Reactor 多程序」方案的開源軟體是 Nginx,不過方案與标準的多 Reactor 多程序有些差異。

具體差異表現在主程序中僅僅用來初始化 socket,并沒有建立 mainReactor 來 accept 連接配接,而是由子程序的 Reactor 來 accept 連接配接,通過鎖來控制一次隻有一個子程序進行 accept(防止出現驚群現象),子程序 accept 新連接配接後就放到自己的 Reactor 進行處理,不會再配置設定給其他子程序。

Proactor

前面提到的 Reactor 是非阻塞同步網絡模式,而 Proactor 是異步網絡模式。

這裡先給大家複習下阻塞、非阻塞、同步、異步 I/O 的概念。

先來看看阻塞 I/O,當使用者程式執行 read,線程會被阻塞,一直等到核心資料準備好,并把資料從核心緩沖區拷貝到應用程式的緩沖區中,當拷貝過程完成,read 才會傳回。

注意,阻塞等待的是「核心資料準備好」和「資料從核心态拷貝到使用者态」這兩個過程。過程如下圖:

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

知道了阻塞 I/O ,來看看非阻塞 I/O,非阻塞的 read 請求在資料未準備好的情況下立即傳回,可以繼續往下執行,此時應用程式不斷輪詢核心,直到資料準備好,核心将資料拷貝到應用程式緩沖區,read 調用才可以擷取到結果。過程如下圖:

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

注意,這裡最後一次 read 調用,擷取資料的過程,是一個同步的過程,是需要等待的過程。這裡的同步指的是核心态的資料拷貝到使用者程式的緩存區這個過程。

舉個例子,如果 socket 設定了 O_NONBLOCK 标志,那麼就表示使用的是非阻塞 I/O 的方式通路,而不做任何設定的話,預設是阻塞 I/O。

是以,無論 read 和 send 是阻塞 I/O,還是非阻塞 I/O 都是同步調用。因為在 read 調用時,核心将資料從核心空間拷貝到使用者空間的過程都是需要等待的,也就是說這個過程是同步的,如果核心實作的拷貝效率不高,read 調用就會在這個同步過程中等待比較長的時間。

而真正的異步 I/O 是「核心資料準備好」和「資料從核心态拷貝到使用者态」這兩個過程都不用等待。

當我們發起 aio_read(異步 I/O) 之後,就立即傳回,核心自動将資料從核心空間拷貝到使用者空間,這個拷貝過程同樣是異步的,核心自動完成的,和前面的同步操作不一樣,應用程式并不需要主動發起拷貝動作。過程如下圖:

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

舉個你去飯堂吃飯的例子,你好比應用程式,飯堂好比作業系統。

阻塞 I/O 好比,你去飯堂吃飯,但是飯堂的菜還沒做好,然後你就一直在那裡等啊等,等了好長一段時間終于等到飯堂阿姨把菜端了出來(資料準備的過程),但是你還得繼續等阿姨把菜(核心空間)打到你的飯盒裡(使用者空間),經曆完這兩個過程,你才可以離開。

非阻塞 I/O 好比,你去了飯堂,問阿姨菜做好了沒有,阿姨告訴你沒,你就離開了,過幾十分鐘,你又來飯堂問阿姨,阿姨說做好了,于是阿姨幫你把菜打到你的飯盒裡,這個過程你是得等待的。

異步 I/O 好比,你讓飯堂阿姨将菜做好并把菜打到飯盒裡後,把飯盒送到你面前,整個過程你都不需要任何等待。

很明顯,異步 I/O 比同步 I/O 性能更好,因為異步 I/O 在「核心資料準備好」和「資料從核心空間拷貝到使用者空間」這兩個過程都不用等待。

Proactor 正是采用了異步 I/O 技術,是以被稱為異步網絡模型。

現在我們再來了解 Reactor 和 Proactor 的差別,就比較清晰了。

  • Reactor 是非阻塞同步網絡模式,感覺的是就緒可讀寫事件。在每次感覺到有事件發生(比如可讀就緒事件)後,就需要應用程序主動調用 read 方法來完成資料的讀取,也就是要應用程序主動将 socket 接收緩存中的資料讀到應用程序記憶體中,這個過程是同步的,讀取完資料後應用程序才能處理資料。 
  • Proactor 是異步網絡模式, 感覺的是已完成的讀寫事件。在發起異步讀寫請求時,需要傳入資料緩沖區的位址(用來存放結果資料)等資訊,這樣系統核心才可以自動幫我們把資料的讀寫工作完成,這裡的讀寫工作全程由作業系統來做,并不需要像 Reactor 那樣還需要應用程序主動發起 read/write 來讀寫資料,作業系統完成讀寫工作後,就會通知應用程序直接處理資料。

是以,Reactor 可以了解為「來了事件作業系統通知應用程序,讓應用程序來處理」,而 Proactor 可以了解為「來了事件作業系統來處理,處理完再通知應用程序」。這裡的「事件」就是有新連接配接、有資料可讀、有資料可寫的這些 I/O 事件這裡的「處理」包含從驅動讀取到核心以及從核心讀取到使用者空間。

舉個實際生活中的例子,Reactor 模式就是快遞員在樓下,給你打電話告訴你快遞到你家小區了,你需要自己下樓來拿快遞。而在 Proactor 模式下,快遞員直接将快遞送到你家門口,然後通知你。

無論是 Reactor,還是 Proactor,都是一種基于「事件分發」的網絡程式設計模式,差別在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式則是基于「已完成」的 I/O 事件。

接下來,一起看看 Proactor 模式的示意圖:

帶你徹底搞懂高性能網絡模式Reactor 和 Proactor

介紹一下 Proactor 模式的工作流程:

  • Proactor Initiator 負責建立 Proactor 和 Handler 對象,并将 Proactor 和 Handler 都通過
  • Asynchronous Operation Processor 注冊到核心;
  • Asynchronous Operation Processor 負責處理注冊請求,并處理 I/O 操作;
  • Asynchronous Operation Processor 完成 I/O 操作後通知 Proactor;
  • Proactor 根據不同的事件類型回調不同的 Handler 進行業務處理;
  • Handler 完成業務處理;

可惜的是,在 Linux 下的異步 I/O 是不完善的,aio 系列函數是由 POSIX 定義的異步操作接口,不是真正的作業系統級别支援的,而是在使用者空間模拟出來的異步,并且僅僅支援基于本地檔案的 aio 異步操作,網絡程式設計中的 socket 是不支援的,這也使得基于 Linux 的高性能網絡程式都是使用 Reactor 方案。

而 Windows 裡實作了一套完整的支援 socket 的異步程式設計接口,這套接口就是 IOCP,是由作業系統級别實作的異步 I/O,真正意義上異步 I/O,是以在 Windows 裡實作高性能網絡程式可以使用效率更高的 Proactor 方案。

總結

常見的 Reactor 實作方案有三種。

第一種方案單 Reactor 單程序 / 線程,不用考慮程序間通信以及資料同步的問題,是以實作起來比較簡單,這種方案的缺陷在于無法充分利用多核 CPU,而且處理業務邏輯的時間不能太長,否則會延遲響應,是以不适用于計算機密集型的場景,适用于業務處理快速的場景,比如 Redis 采用的是單 Reactor 單程序的方案。

第二種方案單 Reactor 多線程,通過多線程的方式解決了方案一的缺陷,但它離高并發還差一點距離,差在隻有一個 Reactor 對象來承擔所有事件的監聽和響應,而且隻在主線程中運作,在面對瞬間高并發的場景時,容易成為性能的瓶頸的地方。

第三種方案多 Reactor 多程序 / 線程,通過多個 Reactor 來解決了方案二的缺陷,主 Reactor 隻負責監聽事件,響應事件的工作交給了從 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多線程」的方案,Nginx 則采用了類似于 「多 Reactor 多程序」的方案。

Reactor 可以了解為「來了事件作業系統通知應用程序,讓應用程序來處理」,而 Proactor 可以了解為「來了事件作業系統來處理,處理完再通知應用程序」。

是以,真正的大殺器還是 Proactor,它是采用異步 I/O 實作的異步網絡模型,感覺的是已完成的讀寫事件,而不需要像 Reactor 感覺到事件後,還需要調用 read 來從核心中擷取資料。

不過,無論是 Reactor,還是 Proactor,都是一種基于「事件分發」的網絡程式設計模式,差別在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式則是基于「已完成」的 I/O 事件。

參考資料

https://cloud.tencent.com/developer/article/1373468

https://blog.csdn.net/qq_27788177/article/details/98108466

https://time.geekbang.org/column/article/8805

https://www.cnblogs.com/crazymakercircle/p/9833847.html

點選關注,第一時間了解華為雲新鮮技術~

繼續閱讀