小林,來了。
這次就來圖解 Reactor 和 Proactor 這兩個高性能網(wǎng)絡(luò)模式。
別小看這兩個東西,特別是 Reactor 模式,市面上常見的開源軟件很多都采用了這個方案,比如 Redis、Nginx、Netty 等等,所以學好這個模式設(shè)計的思想,不僅有助于我們理解很多開源軟件,而且也能在面試時吹逼。
發(fā)車!
演進
如果要讓服務器服務多個客戶端,那么最直接的方式就是為每一條連接創(chuàng)建線程。
其實創(chuàng)建進程也是可以的,原理是一樣的,進程和線程的區(qū)別在于線程比較輕量級些,線程的創(chuàng)建和線程間切換的成本要小些,為了描述簡述,后面都以線程為例。
處理完業(yè)務邏輯后,隨著連接關(guān)閉后線程也同樣要銷毀了,但是這樣不停地創(chuàng)建和銷毀線程,不僅會帶來性能開銷,也會造成浪費資源,而且如果要連接幾萬條連接,創(chuàng)建幾萬個線程去應對也是不現(xiàn)實的。
要這么解決這個問題呢?我們可以使用「資源復用」的方式。
也就是不用再為每個連接創(chuàng)建線程,而是創(chuàng)建一個「線程池」,將連接分配給線程,然后一個線程可以處理多個連接的業(yè)務。
不過,這樣又引來一個新的問題,線程怎樣才能高效地處理多個連接的業(yè)務?
當一個連接對應一個線程時,線程一般采用「read -> 業(yè)務處理 -> send」的處理流程,如果當前連接沒有數(shù)據(jù)可讀,那么線程會阻塞在 read
操作上( socket 默認情況是阻塞 I/O),不過這種阻塞方式并不影響其他線程。
但是引入了線程池,那么一個線程要處理多個連接的業(yè)務,線程在處理某個連接的 read
操作時,如果遇到?jīng)]有數(shù)據(jù)可讀,就會發(fā)生阻塞,那么線程就沒辦法繼續(xù)處理其他連接的業(yè)務。
要解決這一個問題,最簡單的方式就是將 socket 改成非阻塞,然后線程不斷地輪詢調(diào)用 read
操作來判斷是否有數(shù)據(jù),這種方式雖然該能夠解決阻塞的問題,但是解決的方式比較粗暴,因為輪詢是要消耗 CPU 的,而且隨著一個 線程處理的連接越多,輪詢的效率就會越低。
上面的問題在于,線程并不知道當前連接是否有數(shù)據(jù)可讀,從而需要每次通過 read
去試探。
那有沒有辦法在只有當連接上有數(shù)據(jù)的時候,線程才去發(fā)起讀請求呢?答案是有的,實現(xiàn)這一技術(shù)的就是 I/O 多路復用。
I/O 多路復用技術(shù)會用一個系統(tǒng)調(diào)用函數(shù)來監(jiān)聽我們所有關(guān)心的連接,也就說可以在一個監(jiān)控線程里面監(jiān)控很多的連接。
我們熟悉的 select/poll/epoll 就是內(nèi)核提供給用戶態(tài)的多路復用系統(tǒng)調(diào)用,線程可以通過一個系統(tǒng)調(diào)用函數(shù)從內(nèi)核中獲取多個事件。
PS:如果想知道 select/poll/epoll 的區(qū)別,可以看看小林之前寫的這篇文章:這次答應我,一舉拿下 I/O 多路復用!
select/poll/epoll 是如何獲取網(wǎng)絡(luò)事件的呢?
在獲取事件時,先把我們要關(guān)心的連接傳給內(nèi)核,再由內(nèi)核檢測:
如果沒有事件發(fā)生,線程只需阻塞在這個系統(tǒng)調(diào)用,而無需像前面的線程池方案那樣輪訓調(diào)用 read 操作來判斷是否有數(shù)據(jù)。
如果有事件發(fā)生,內(nèi)核會返回產(chǎn)生了事件的連接,線程就會從阻塞狀態(tài)返回,然后在用戶態(tài)中再處理這些連接對應的業(yè)務即可。
當下開源軟件能做到網(wǎng)絡(luò)高性能的原因就是 I/O 多路復用嗎?
是的,基本是基于 I/O 多路復用,用過 I/O 多路復用接口寫網(wǎng)絡(luò)程序的同學,肯定知道是面向過程的方式寫代碼的,這樣的開發(fā)的效率不高。
于是,大佬們基于面向?qū)ο蟮乃枷?,?I/O 多路復用作了一層封裝,讓使用者不用考慮底層網(wǎng)絡(luò) API 的細節(jié),只需要關(guān)注應用代碼的編寫。
大佬們還為這種模式取了個讓人第一時間難以理解的名字:Reactor 模式。
Reactor 翻譯過來的意思是「反應堆」,可能大家會聯(lián)想到物理學里的核反應堆,實際上并不是的這個意思。
這里的反應指的是「對事件反應」,也就是來了一個事件,Reactor 就有相對應的反應/響應。
事實上,Reactor 模式也叫 Dispatcher
模式,我覺得這個名字更貼合該模式的含義,即 I/O 多路復用監(jiān)聽事件,收到事件后,根據(jù)事件類型分配(Dispatch)給某個進程 / 線程。
Reactor 模式主要由 Reactor 和處理資源池這兩個核心部分組成,它倆負責的事情如下:
Reactor 負責監(jiān)聽和分發(fā)事件,事件類型包含連接事件、讀寫事件;
處理資源池負責處理事件,如 read -> 業(yè)務邏輯 -> send;
Reactor 模式是靈活多變的,可以應對不同的業(yè)務場景,靈活在于:
Reactor 的數(shù)量可以只有一個,也可以有多個;
處理資源池可以是單個進程 / 線程,也可以是多個進程 /線程;
將上面的兩個因素排列組設(shè)一下,理論上就可以有 4 種方案選擇:
單 Reactor 單進程 / 線程;
單 Reactor 多進程 / 線程;
多 Reactor 單進程 / 線程;
多 Reactor 多進程 / 線程;
其中,「多 Reactor 單進程 / 線程」實現(xiàn)方案相比「單 Reactor 單進程 / 線程」方案,不僅復雜而且也沒有性能優(yōu)勢,因此實際中并沒有應用。
剩下的 3 個方案都是比較經(jīng)典的,且都有應用在實際的項目中:
單 Reactor 單進程 / 線程;
單 Reactor 多線程 / 進程;
多 Reactor 多進程 / 線程;
方案具體使用進程還是線程,要看使用的編程語言以及平臺有關(guān):
Java 語言一般使用線程,比如 Netty;
C 語言使用進程和線程都可以,例如 Nginx 使用的是進程,Memcache 使用的是線程。
接下來,分別介紹這三個經(jīng)典的 Reactor 方案。
Reactor
單 Reactor 單進程 / 線程
一般來說,C 語言實現(xiàn)的是「單 Reactor 單進程」的方案,因為 C 語編寫完的程序,運行后就是一個獨立的進程,不需要在進程中再創(chuàng)建線程。
而 Java 語言實現(xiàn)的是「單 Reactor 單線程」的方案,因為 Java 程序是跑在 Java 虛擬機這個進程上面的,虛擬機中有很多線程,我們寫的 Java 程序只是其中的一個線程而已。
我們來看看「單 Reactor 單進程」的方案示意圖:
可以看到進程里有 Reactor、Acceptor、Handler 這三個對象:
Reactor 對象的作用是監(jiān)聽和分發(fā)事件;
Acceptor 對象的作用是獲取連接;
Handler 對象的作用是處理業(yè)務;
對象里的 select、accept、read、send 是系統(tǒng)調(diào)用函數(shù),dispatch 和 「業(yè)務處理」是需要完成的操作,其中 dispatch 是分發(fā)事件操作。
接下來,介紹下「單 Reactor 單進程」這個方案:
Reactor 對象通過 select (IO 多路復用接口) 監(jiān)聽事件,收到事件后通過 dispatch 進行分發(fā),具體分發(fā)給 Acceptor 對象還是 Handler 對象,還要看收到的事件類型;
如果是連接建立的事件,則交由 Acceptor 對象進行處理,Acceptor 對象會通過 accept 方法 獲取連接,并創(chuàng)建一個 Handler 對象來處理后續(xù)的響應事件;
如果不是連接建立事件, 則交由當前連接對應的 Handler 對象來進行響應;
Handler 對象通過 read -> 業(yè)務處理 -> send 的流程來完成完整的業(yè)務流程。
單 Reactor 單進程的方案因為全部工作都在同一個進程內(nèi)完成,所以實現(xiàn)起來比較簡單,不需要考慮進程間通信,也不用擔心多進程競爭。
但是,這種方案存在 2 個缺點:
第一個缺點,因為只有一個進程,無法充分利用 多核 CPU 的性能;
第二個缺點,Handler 對象在業(yè)務處理時,整個進程是無法處理其他連接的事件的,如果業(yè)務處理耗時比較長,那么就造成響應的延遲;
所以,單 Reactor 單進程的方案不適用計算機密集型的場景,只適用于業(yè)務處理非常快速的場景。
Redis 是由 C 語言實現(xiàn)的,它采用的正是「單 Reactor 單進程」的方案,因為 Redis 業(yè)務處理主要是在內(nèi)存中完成,操作的速度是很快的,性能瓶頸不在 CPU 上,所以 Redis 對于命令的處理是單進程的方案。
單 Reactor 多線程 / 多進程
如果要克服「單 Reactor 單線程 / 進程」方案的缺點,那么就需要引入多線程 / 多進程,這樣就產(chǎn)生了單 Reactor 多線程 / 多進程的方案。
聞其名不如看其圖,先來看看「單 Reactor 多線程」方案的示意圖如下:
詳細說一下這個方案:
Reactor 對象通過 select (IO 多路復用接口) 監(jiān)聽事件,收到事件后通過 dispatch 進行分發(fā),具體分發(fā)給 Acceptor 對象還是 Handler 對象,還要看收到的事件類型;
如果是連接建立的事件,則交由 Acceptor 對象進行處理,Acceptor 對象會通過 accept 方法 獲取連接,并創(chuàng)建一個 Handler 對象來處理后續(xù)的響應事件;
如果不是連接建立事件, 則交由當前連接對應的 Handler 對象來進行響應;
上面的三個步驟和單 Reactor 單線程方案是一樣的,接下來的步驟就開始不一樣了:
Handler 對象不再負責業(yè)務處理,只負責數(shù)據(jù)的接收和發(fā)送,Handler 對象通過 read 讀取到數(shù)據(jù)后,會將數(shù)據(jù)發(fā)給子線程里的 Processor 對象進行業(yè)務處理;
子線程里的 Processor 對象就進行業(yè)務處理,處理完后,將結(jié)果發(fā)給主線程中的 Handler 對象,接著由 Handler 通過 send 方法將響應結(jié)果發(fā)送給 client;
單 Reator 多線程的方案優(yōu)勢在于能夠充分利用多核 CPU 的能,那既然引入多線程,那么自然就帶來了多線程競爭資源的問題。
例如,子線程完成業(yè)務處理后,要把結(jié)果傳遞給主線程的 Reactor 進行發(fā)送,這里涉及共享數(shù)據(jù)的競爭。
要避免多線程由于競爭共享資源而導致數(shù)據(jù)錯亂的問題,就需要在操作共享資源前加上互斥鎖,以保證任意時間里只有一個線程在操作共享資源,待該線程操作完釋放互斥鎖后,其他線程才有機會操作共享數(shù)據(jù)。
聊完單 Reactor 多線程的方案,接著來看看單 Reactor 多進程的方案。
事實上,單 Reactor 多進程相比單 Reactor 多線程實現(xiàn)起來很麻煩,主要因為要考慮子進程 <-> 父進程的雙向通信,并且父進程還得知道子進程要將數(shù)據(jù)發(fā)送給哪個客戶端。
而多線程間可以共享數(shù)據(jù),雖然要額外考慮并發(fā)問題,但是這遠比進程間通信的復雜度低得多,因此實際應用中也看不到單 Reactor 多進程的模式。
另外,「單 Reactor」的模式還有個問題,因為一個 Reactor 對象承擔所有事件的監(jiān)聽和響應,而且只在主線程中運行,在面對瞬間高并發(fā)的場景時,容易成為性能的瓶頸的地方。
多 Reactor 多進程 / 線程
要解決「單 Reactor」的問題,就是將「單 Reactor」實現(xiàn)成「多 Reactor」,這樣就產(chǎn)生了第 多 Reactor 多進程 / 線程的方案。
老規(guī)矩,聞其名不如看其圖。多 Reactor 多進程 / 線程方案的示意圖如下(以線程為例):
方案詳細說明如下:
主線程中的 MainReactor 對象通過 select 監(jiān)控連接建立事件,收到事件后通過 Acceptor 對象中的 accept 獲取連接,將新的連接分配給某個子線程;
子線程中的 SubReactor 對象將 MainReactor 對象分配的連接加入 select 繼續(xù)進行監(jiān)聽,并創(chuàng)建一個 Handler 用于處理連接的響應事件。
如果有新的事件發(fā)生時,SubReactor 對象會調(diào)用當前連接對應的 Handler 對象來進行響應。
Handler 對象通過 read -> 業(yè)務處理 -> send 的流程來完成完整的業(yè)務流程。
多 Reactor 多線程的方案雖然看起來復雜的,但是實際實現(xiàn)時比單 Reactor 多線程的方案要簡單的多,原因如下:
主線程和子線程分工明確,主線程只負責接收新連接,子線程負責完成后續(xù)的業(yè)務處理。
主線程和子線程的交互很簡單,主線程只需要把新連接傳給子線程,子線程無須返回數(shù)據(jù),直接就可以在子線程將處理結(jié)果發(fā)送給客戶端。
大名鼎鼎的兩個開源軟件 Netty 和 Memcache 都采用了「多 Reactor 多線程」的方案。
采用了「多 Reactor 多進程」方案的開源軟件是 Nginx,不過方案與標準的多 Reactor 多進程有些差異。
具體差異表現(xiàn)在主進程中僅僅用來初始化 socket,并沒有創(chuàng)建 mainReactor 來 accept 連接,而是由子進程的 Reactor 來 accept 連接,通過鎖來控制一次只有一個子進程進行 accept(防止出現(xiàn)驚群現(xiàn)象),子進程 accept 新連接后就放到自己的 Reactor 進行處理,不會再分配給其他子進程。
Proactor
前面提到的 Reactor 是非阻塞同步網(wǎng)絡(luò)模式,而 Proactor 是異步網(wǎng)絡(luò)模式。
這里先給大家復習下阻塞、非阻塞、同步、異步 I/O 的概念。
先來看看阻塞 I/O,當用戶程序執(zhí)行 read
,線程會被阻塞,一直等到內(nèi)核數(shù)據(jù)準備好,并把數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到應用程序的緩沖區(qū)中,當拷貝過程完成,read
才會返回。
注意,阻塞等待的是「內(nèi)核數(shù)據(jù)準備好」和「數(shù)據(jù)從內(nèi)核態(tài)拷貝到用戶態(tài)」這兩個過程。過程如下圖:
阻塞 I/O
知道了阻塞 I/O ,來看看非阻塞 I/O,非阻塞的 read 請求在數(shù)據(jù)未準備好的情況下立即返回,可以繼續(xù)往下執(zhí)行,此時應用程序不斷輪詢內(nèi)核,直到數(shù)據(jù)準備好,內(nèi)核將數(shù)據(jù)拷貝到應用程序緩沖區(qū),read
調(diào)用才可以獲取到結(jié)果。過程如下圖:
非阻塞 I/O
注意,這里最后一次 read 調(diào)用,獲取數(shù)據(jù)的過程,是一個同步的過程,是需要等待的過程。這里的同步指的是內(nèi)核態(tài)的數(shù)據(jù)拷貝到用戶程序的緩存區(qū)這個過程。
舉個例子,如果 socket 設(shè)置了 O_NONBLOCK
標志,那么就表示使用的是非阻塞 I/O 的方式訪問,而不做任何設(shè)置的話,默認是阻塞 I/O。
因此,無論 read 和 send 是阻塞 I/O,還是非阻塞 I/O 都是同步調(diào)用。因為在 read 調(diào)用時,內(nèi)核將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間的過程都是需要等待的,也就是說這個過程是同步的,如果內(nèi)核實現(xiàn)的拷貝效率不高,read 調(diào)用就會在這個同步過程中等待比較長的時間。
而真正的異步 I/O 是「內(nèi)核數(shù)據(jù)準備好」和「數(shù)據(jù)從內(nèi)核態(tài)拷貝到用戶態(tài)」這兩個過程都不用等待。
當我們發(fā)起 aio_read
(異步 I/O) 之后,就立即返回,內(nèi)核自動將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間,這個拷貝過程同樣是異步的,內(nèi)核自動完成的,和前面的同步操作不一樣,應用程序并不需要主動發(fā)起拷貝動作。過程如下圖:
異步 I/O
舉個你去飯?zhí)贸燥埖睦樱愫帽葢贸绦?,飯?zhí)煤帽炔僮飨到y(tǒng)。
阻塞 I/O 好比,你去飯?zhí)贸燥?,但是飯?zhí)玫牟诉€沒做好,然后你就一直在那里等啊等,等了好長一段時間終于等到飯?zhí)冒⒁贪巡硕肆顺鰜恚〝?shù)據(jù)準備的過程),但是你還得繼續(xù)等阿姨把菜(內(nèi)核空間)打到你的飯盒里(用戶空間),經(jīng)歷完這兩個過程,你才可以離開。
非阻塞 I/O 好比,你去了飯?zhí)?,問阿姨菜做好了沒有,阿姨告訴你沒,你就離開了,過幾十分鐘,你又來飯?zhí)脝柊⒁?,阿姨說做好了,于是阿姨幫你把菜打到你的飯盒里,這個過程你是得等待的。
異步 I/O 好比,你讓飯?zhí)冒⒁虒⒉俗龊貌巡舜虻斤埡欣锖?,把飯盒送到你面前,整個過程你都不需要任何等待。
很明顯,異步 I/O 比同步 I/O 性能更好,因為異步 I/O 在「內(nèi)核數(shù)據(jù)準備好」和「數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間」這兩個過程都不用等待。
Proactor 正是采用了異步 I/O 技術(shù),所以被稱為異步網(wǎng)絡(luò)模型。
現(xiàn)在我們再來理解 Reactor 和 Proactor 的區(qū)別,就比較清晰了。
Reactor 是非阻塞同步網(wǎng)絡(luò)模式,感知的是就緒可讀寫事件。在每次感知到有事件發(fā)生(比如可讀就緒事件)后,就需要應用進程主動調(diào)用 read 方法來完成數(shù)據(jù)的讀取,也就是要應用進程主動將 socket 接收緩存中的數(shù)據(jù)讀到應用進程內(nèi)存中,這個過程是同步的,讀取完數(shù)據(jù)后應用進程才能處理數(shù)據(jù)。
Proactor 是異步網(wǎng)絡(luò)模式, 感知的是已完成的讀寫事件。在發(fā)起異步讀寫請求時,需要傳入數(shù)據(jù)緩沖區(qū)的地址(用來存放結(jié)果數(shù)據(jù))等信息,這樣系統(tǒng)內(nèi)核才可以自動幫我們把數(shù)據(jù)的讀寫工作完成,這里的讀寫工作全程由操作系統(tǒng)來做,并不需要像 Reactor 那樣還需要應用進程主動發(fā)起 read/write 來讀寫數(shù)據(jù),操作系統(tǒng)完成讀寫工作后,就會通知應用進程直接處理數(shù)據(jù)。
因此,Reactor 可以理解為「來了事件操作系統(tǒng)通知應用進程,讓應用進程來處理」,而 Proactor 可以理解為「來了事件操作系統(tǒng)來處理,處理完再通知應用進程」。這里的「事件」就是有新連接、有數(shù)據(jù)可讀、有數(shù)據(jù)可寫的這些 I/O 事件這里的「處理」包含從驅(qū)動讀取到內(nèi)核以及從內(nèi)核讀取到用戶空間。
舉個實際生活中的例子,Reactor 模式就是快遞員在樓下,給你打電話告訴你快遞到你家小區(qū)了,你需要自己下樓來拿快遞。而在 Proactor 模式下,快遞員直接將快遞送到你家門口,然后通知你。
無論是 Reactor,還是 Proactor,都是一種基于「事件分發(fā)」的網(wǎng)絡(luò)編程模式,區(qū)別在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式則是基于「已完成」的 I/O 事件。
接下來,一起看看 Proactor 模式的示意圖:
介紹一下 Proactor 模式的工作流程:
Proactor Initiator 負責創(chuàng)建 Proactor 和 Handler 對象,并將 Proactor 和 Handler 都通過
Asynchronous Operation Processor 注冊到內(nèi)核;
Asynchronous Operation Processor 負責處理注冊請求,并處理 I/O 操作;
Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
Proactor 根據(jù)不同的事件類型回調(diào)不同的 Handler 進行業(yè)務處理;
Handler 完成業(yè)務處理;
可惜的是,在 Linux 下的異步 I/O 是不完善的,aio
系列函數(shù)是由 POSIX 定義的異步操作接口,不是真正的操作系統(tǒng)級別支持的,而是在用戶空間模擬出來的異步,并且僅僅支持基于本地文件的 aio 異步操作,網(wǎng)絡(luò)編程中的 socket 是不支持的,這也使得基于 Linux 的高性能網(wǎng)絡(luò)程序都是使用 Reactor 方案。
而 Windows 里實現(xiàn)了一套完整的支持 socket 的異步編程接口,這套接口就是 IOCP
,是由操作系統(tǒng)級別實現(xiàn)的異步 I/O,真正意義上異步 I/O,因此在 Windows 里實現(xiàn)高性能網(wǎng)絡(luò)程序可以使用效率更高的 Proactor 方案。
總結(jié)
常見的 Reactor 實現(xiàn)方案有三種。
第一種方案單 Reactor 單進程 / 線程,不用考慮進程間通信以及數(shù)據(jù)同步的問題,因此實現(xiàn)起來比較簡單,這種方案的缺陷在于無法充分利用多核 CPU,而且處理業(yè)務邏輯的時間不能太長,否則會延遲響應,所以不適用于計算機密集型的場景,適用于業(yè)務處理快速的場景,比如 Redis 采用的是單 Reactor 單進程的方案。
第二種方案單 Reactor 多線程,通過多線程的方式解決了方案一的缺陷,但它離高并發(fā)還差一點距離,差在只有一個 Reactor 對象來承擔所有事件的監(jiān)聽和響應,而且只在主線程中運行,在面對瞬間高并發(fā)的場景時,容易成為性能的瓶頸的地方。
第三種方案多 Reactor 多進程 / 線程,通過多個 Reactor 來解決了方案二的缺陷,主 Reactor 只負責監(jiān)聽事件,響應事件的工作交給了從 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多線程」的方案,Nginx 則采用了類似于 「多 Reactor 多進程」的方案。
Reactor 可以理解為「來了事件操作系統(tǒng)通知應用進程,讓應用進程來處理」,而 Proactor 可以理解為「來了事件操作系統(tǒng)來處理,處理完再通知應用進程」。
因此,真正的大殺器還是 Proactor,它是采用異步 I/O 實現(xiàn)的異步網(wǎng)絡(luò)模型,感知的是已完成的讀寫事件,而不需要像 Reactor 感知到事件后,還需要調(diào)用 read 來從內(nèi)核中獲取數(shù)據(jù)。
不過,無論是 Reactor,還是 Proactor,都是一種基于「事件分發(fā)」的網(wǎng)絡(luò)編程模式,區(qū)別在于 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