1. 程式人生 > 其它 >Proactor模式&Reactor模式詳解

Proactor模式&Reactor模式詳解

一、簡介

伺服器端程式設計經常需要構造高效能的IO模型,常見的IO模型有四種:

(1)同步阻塞IO(BlockingIO):即傳統的IO模型。

(2)同步非阻塞IO(Non-blockingIO):預設建立的socket都是阻塞的,非阻塞IO要求socket被設定為NONBLOCK。注意這裡所說的NIO並非Java的NIO(NewIO)庫。

(3)IO多路複用(IOMultiplexing):即經典的Reactor設計模式,有時也稱為非同步阻塞IO,Java中的Selector和Linux中的epoll都是這種模型。

(4)非同步IO(AsynchronousIO):即經典的Proactor設計模式,也稱為非同步非阻塞IO

  同步和非同步的概念描述的是使用者執行緒與核心的互動方式:同步是指使用者執行緒發起IO請求後需要等待或者輪詢核心IO操作完成後才能繼續執行;而非同步是指使用者執行緒發起IO請求後仍繼續執行,當核心IO操作完成後會通知使用者執行緒,或者呼叫使用者執行緒註冊的回撥函式

  阻塞和非阻塞的概念描述的是使用者執行緒呼叫核心IO操作的方式:阻塞是指IO操作需要徹底完成後才返回到使用者空間;而非阻塞是指IO操作被呼叫後立即返回給使用者一個狀態值,無需等到IO操作徹底完成

  另外,RichardStevens在《Unix網路程式設計》卷1中提到的基於訊號驅動的IO(SignalDrivenIO)模型

,由於該模型並不常用,本文不作涉及。接下來,我們詳細分析四種常見的IO模型的實現原理。為了方便描述,我們統一使用IO的讀操作作為示例。

二、同步阻塞IO

同步阻塞IO模型是最簡單的IO模型,使用者執行緒在核心進行IO操作時被阻塞。

  如圖1所示,使用者執行緒通過系統呼叫read發起IO讀操作,由使用者空間轉到核心空間。核心等到資料包到達後,然後將接收的資料拷貝到使用者空間,完成read操作。

使用者執行緒使用同步阻塞IO模型的虛擬碼描述為:

1 {
2     read(socket, buffer);
3     process(buffer);
4 }

  即使用者需要等待read將socket中的資料讀取到buffer後,才繼續處理接收的資料。整個IO請求的過程中,使用者執行緒是被阻塞的

,這導致使用者在發起IO請求時,不能做任何事情,對CPU的資源利用率不夠。

三、同步非阻塞IO

同步非阻塞IO是在同步阻塞IO的基礎上,將socket設定為NONBLOCK。這樣做使用者執行緒可以在發起IO請求後可以立即返回

  如圖2所示,由於socket是非阻塞的方式,因此使用者執行緒發起IO請求時立即返回。但並未讀取到任何資料,使用者執行緒需要不斷地發起IO請求,直到資料到達後,才真正讀取到資料,繼續執行

使用者執行緒使用同步非阻塞IO模型的虛擬碼描述為:

1 {
2     while(read(socket, buffer) != SUCCESS);
3     process(buffer);
4 }

  即使用者需要不斷地呼叫read,嘗試讀取socket中的資料,直到讀取成功後,才繼續處理接收的資料。整個IO請求的過程中,雖然使用者執行緒每次發起IO請求後可以立即返回,但是為了等到資料,仍需要不斷地輪詢、重複請求,消耗了大量的CPU的資源。一般很少直接使用這種模型,而是在其他IO模型中使用非阻塞IO這一特性。

四、IO多路複用

  IO多路複用模型是建立在核心提供的多路分離函式select、poll以及epoll基礎之上的,使用這些函式可以避免同步非阻塞IO模型中輪詢等待的問題因為使用者執行緒將這個輪詢的過程將給核心來執行,而自己則表現為阻塞態

  如圖3所示,使用者首先將需要進行IO操作的socket新增到select中,然後阻塞等待select系統呼叫返回。當資料到達時,socket被啟用,select函式返回。使用者執行緒正式發起read請求,讀取資料並繼續執行

  從流程上來看,使用select函式進行IO請求和同步阻塞模型沒有太大的區別,甚至還多了新增監視socket,以及呼叫select函式的額外操作,效率更差。但是,使用select以後最大的優勢是使用者可以在一個執行緒內同時處理多個socket的IO請求。使用者可以註冊多個socket,然後不斷地呼叫select讀取被啟用的socket,即可達到在同一個執行緒內同時處理多個IO請求的目的。而在同步阻塞模型中,必須通過多執行緒的方式才能達到這個目的

使用者執行緒使用select函式的虛擬碼描述為:

 1 {
 2     select(socket);
 3     while(1) 
 4     {
 5         sockets = select();
 6         for(socket in sockets) {
 7             if(can_read(socket)) {
 8                 read(socket, buffer);
 9                 process(buffer);
10             }
11         }
12     }
13 }

  其中while迴圈前將socket新增到select監視中,然後在while內一直呼叫select獲取被啟用的socket,一旦socket可讀,便呼叫read函式將socket中的資料讀取出來。

  然而,使用select函式的優點並不僅限於此。雖然上述方式允許單執行緒內處理多個IO請求,但是每個IO請求的過程還是阻塞的(在select函式上阻塞),平均時間甚至比同步阻塞IO模型還要長。如果使用者執行緒只註冊自己感興趣的socket或者IO請求,然後去做自己的事情,等到資料到來時再進行處理,則可以提高CPU的利用率。

  IO多路複用模型使用了Reactor設計模式實現了這一機制。

圖4Reactor設計模式

  如圖4所示,EventHandler抽象類表示IO事件處理器,它擁有IO檔案控制代碼Handle(通過get_handle獲取),以及對Handle的操作handle_event(讀/寫等)。繼承於EventHandler的子類可以對事件處理器的行為進行定製。Reactor類用於管理EventHandler(註冊、刪除等),並使用handle_events實現事件迴圈,不斷呼叫同步事件多路分離器(一般是核心)的多路分離函式select,只要某個檔案控制代碼被啟用(可讀/寫等),select就返回(阻塞),handle_events就會呼叫與檔案控制代碼關聯的事件處理器的handle_event進行相關操作

  如圖5所示,通過Reactor的方式,可以將使用者執行緒輪詢IO操作狀態的工作統一交給handle_events事件迴圈進行處理。使用者執行緒註冊事件處理器之後可以繼續執行做其他的工作(非同步),而Reactor執行緒負責呼叫核心的select函式檢查socket狀態。當有socket被啟用時,則通知相應的使用者執行緒(或執行使用者執行緒的回撥函式),執行handle_event進行資料讀取、處理的工作。由於select函式是阻塞的,因此多路IO複用模型也被稱為非同步阻塞IO模型。注意,這裡的所說的阻塞是指select函式執行時執行緒被阻塞,而不是指socket。一般在使用IO多路複用模型時,socket都是設定為NONBLOCK的,不過這並不會產生影響,因為使用者發起IO請求時,資料已經到達了,使用者執行緒(相當於工作執行緒)一定不會被阻塞

  使用者執行緒使用IO多路複用模型的虛擬碼描述為:

1 voidUserEventHandler::handle_event(){
2     if(can_read(socket)){
3         read(socket,buffer);
4         process(buffer);
5     }
6 }
7 {
8     Reactor.register(newUserEventHandler(socket));
9 } 

  使用者需要重寫EventHandler的handle_event函式進行讀取資料、處理資料的工作,使用者執行緒只需要將自己的EventHandler註冊到Reactor即可。Reactor中handle_events事件迴圈的虛擬碼大致如下。

1 Reactor::handle_events(){
2     while(1){
3         sockets=select();
4         for(socketinsockets){
5             get_event_handler(socket).handle_event();
6         }
7     }
8 } 

  事件迴圈不斷地呼叫select獲取被啟用的socket,然後根據獲取socket對應的EventHandler,執行器handle_event函式即可。IO多路複用是最常使用的IO模型,但是其非同步程度還不夠“徹底”,因為它使用了會阻塞執行緒的select系統呼叫。因此IO多路複用只能稱為非同步阻塞IO而非真正的非同步IO

五、非同步IO

  “真正”的非同步IO需要作業系統更強的支援。在IO多路複用模型中,事件迴圈將檔案控制代碼的狀態事件通知給使用者執行緒,由使用者執行緒自行讀取資料、處理資料。而在非同步IO模型中,當用戶執行緒收到通知時,資料已經被核心讀取完畢,並放在了使用者執行緒指定的緩衝區內,核心在IO完成後通知使用者執行緒直接使用即可。

  非同步IO模型使用了Proactor設計模式實現了這一機制。

  如圖6,Proactor模式和Reactor模式在結構上比較相似,不過在使用者(Client)使用方式上差別較大。Reactor模式中,使用者執行緒通過向Reactor物件註冊感興趣的事件監聽,然後事件觸發時呼叫事件處理函式。而Proactor模式中,使用者執行緒將AsynchronousOperation(讀/寫等)、Proactor以及操作完成時的CompletionHandler註冊到AsynchronousOperationProcessor。AsynchronousOperationProcessor使用Facade模式提供了一組非同步操作API(讀/寫等)供使用者使用,當用戶執行緒呼叫非同步API後,便繼續執行自己的任務。AsynchronousOperationProcessor會開啟獨立的核心執行緒執行非同步操作,實現真正的非同步。當非同步IO操作完成時,AsynchronousOperationProcessor將使用者執行緒與AsynchronousOperation一起註冊的Proactor和CompletionHandler取出,然後將CompletionHandler與IO操作的結果資料一起轉發給Proactor,Proactor負責回撥每一個非同步操作的事件完成處理函式handle_event。雖然Proactor模式中每個非同步操作都可以繫結一個Proactor物件,但是一般在作業系統中,Proactor被實現為Singleton模式,以便於集中化分發操作完成事件。

  如圖7所示,非同步IO模型中,使用者執行緒直接使用核心提供的非同步IOAPI發起read請求,且發起後立即返回,繼續執行使用者執行緒程式碼。不過此時使用者執行緒已經將呼叫的AsynchronousOperation和CompletionHandler註冊到核心,然後作業系統開啟獨立的核心執行緒去處理IO操作。當read請求的資料到達時,由核心負責讀取socket中的資料,並寫入使用者指定的緩衝區中最後核心將read的資料和使用者執行緒註冊的CompletionHandler分發給內部Proactor,Proactor將IO完成的資訊通知給使用者執行緒(一般通過呼叫使用者執行緒註冊的完成事件處理函式),完成非同步IO

使用者執行緒使用非同步IO模型的虛擬碼描述為:

1 voidUserCompletionHandler::handle_event(buffer){
2     process(buffer);
3 }
4 {
5     aio_read(socket,newUserCompletionHandler);
6 } 

  使用者需要重寫CompletionHandler的handle_event函式進行處理資料的工作,引數buffer表示Proactor已經準備好的資料,使用者執行緒直接呼叫核心提供的非同步IOAPI,並將重寫的CompletionHandler註冊即可。

  相比於IO多路複用模型,非同步IO並不十分常用,不少高效能併發服務程式使用IO多路複用模型+多執行緒任務處理的架構基本可以滿足需求。況且目前作業系統對非同步IO的支援並非特別完善,更多的是採用IO多路複用模型模擬非同步IO的方式(IO事件觸發時不直接通知使用者執行緒而是將資料讀寫完畢後放到使用者指定的緩衝區中)。Java7之後已經支援了非同步IO,感興趣的讀者可以嘗試使用。

六、reactor總結

6.1 背景

  如果要讓伺服器服務多個客戶端,那麼最直接的方式就是為每一條連線建立執行緒

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

  處理完業務邏輯後,隨著連線關閉後執行緒也同樣要銷燬了,但是這樣不停地建立和銷燬執行緒,不僅會帶來效能開銷,也會造成浪費資源,而且如果要連線幾萬條連線,建立幾萬個執行緒去應對也是不現實的。

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

  也就是不用再為每個連線建立執行緒,而是建立一個「執行緒池」,將連線分配給執行緒,然後一個執行緒可以處理多個連線的業務。

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

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

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

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

  上面的問題在於,執行緒並不知道當前連線是否有資料可讀,從而需要每次通過read去試探。

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

6.2 IO多路複用

  我們熟悉的 select/poll/epoll 就是核心提供給使用者態的多路複用系統呼叫,執行緒可以通過一個系統呼叫函式從核心中獲取多個事件。

  select/poll/epoll 是如何獲取網路事件的呢?

  在獲取事件時,先把我們要關心的連線傳給核心,再由核心檢測

    • 如果沒有事件發生,執行緒只需阻塞在這個系統呼叫,而無需像前面的執行緒池方案那樣輪訓呼叫 read 操作來判斷是否有資料。

    • 如果有事件發生,核心會返回產生了事件的連線,執行緒就會從阻塞狀態返回,然後在使用者態中再處理這些連線對應的業務即可。

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

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

  於是,大佬們基於面向物件的思想,對 I/O 多路複用作了一層封裝,讓使用者不用考慮底層網路 API 的細節,只需要關注應用程式碼的編寫。大佬們還為這種模式取了個讓人第一時間難以理解的名字:Reactor 模式

6.3 簡介

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

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

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

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

  • Reactor 負責監聽和分發事件,事件型別包含連線事件、讀寫事件

  • 處理資源池負責處理事件,如 read -> 業務邏輯 -> send;

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

  • Reactor 的數量可以只有一個,也可以有多個;

  • 處理資源池可以是單個程序 / 執行緒,也可以是多個程序 /執行緒;

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

  • 單 Reactor 單程序 / 執行緒;

  • 單 Reactor 多程序 / 執行緒;

  • 多 Reactor 單程序 / 執行緒;

  • 多 Reactor 多程序 / 執行緒;

其中,「多 Reactor 單程序 / 執行緒」實現方案相比「單 Reactor 單程序 / 執行緒」方案,不僅複雜而且也沒有效能優勢,因此實際中並沒有應用。

方案具體使用程序還是執行緒,要看使用的程式語言以及平臺有關:

  • Java 語言一般使用執行緒,比如 Netty;

  • C 語言使用程序和執行緒都可以,例如 Nginx 使用的是程序,Memcache 使用的是執行緒。

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

6.4 單 Reactor 單程序 / 執行緒

6.4.1 流程圖

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

  而 Java 語言實現的是「單 Reactor單執行緒」的方案,因為 Java 程式是跑在 Java 虛擬機器這個程序上面的,虛擬機器中有很多執行緒,我們寫的 Java 程式只是其中的一個執行緒而已。

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

6.4.2 流程圖分析

可以看到程序裡有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 單程序的方案因為全部工作都在同一個程序內完成,所以實現起來比較簡單,不需要考慮程序間通訊,也不用擔心多程序競爭

6.4.3 缺點

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

  • 第一個缺點,因為只有一個程序,無法充分利用 多核 CPU 的效能

  • 第二個缺點,Handler 物件在業務處理時,整個程序是無法處理其他連線的事件的,如果業務處理耗時比較長,那麼就造成響應的延遲

6.4.4 應用場景和例項

  所以,單 Reactor 單程序的方案不適用計算機密集型的場景,只適用於業務處理非常快速的場景

  Redis 是由 C 語言實現的,它採用的正是「單 Reactor 單程序」的方案,因為 Redis 業務處理主要是在記憶體中完成,操作的速度是很快的,效能瓶頸不在 CPU 上,所以 Redis 對於命令的處理是單程序的方案。Redis的瓶頸最有可能是機器記憶體的大小或者網路頻寬

  Redis將資料存放在記憶體當中,這也就意味著,Redis在操作資料時,不需要進行磁碟I/O。磁碟I/O是一個比較耗時的操作,所以對於需要進行磁碟I/O的程式,我們可以使用多執行緒,在某個執行緒進行I/O時,CPU切換到當前程式的其他執行緒執行,以此減少CPU的等待時間。而Redis直接操作記憶體中的資料,所以使用多執行緒並不能有效提升效率,相反,使用多執行緒反倒會因為需要進行執行緒的切換而降低效率。

  除此之外,使用多執行緒的話,多個執行緒間進行同步,保證執行緒的安全,也是需要開銷的。尤其是Redis的資料結構都是一些實現較為簡單的集合結構,若使用多執行緒,將會頻繁地發生執行緒衝突,執行緒的競爭頻率較高,反倒會拖慢Redis的響應速度。

  綜上所述,Redis為了保持簡單和高效,自然而然地就使用了單執行緒。

6.5 單 Reactor 單程序 / 執行緒

6.5.1 流程圖

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

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

6.5.2 流程圖分析

詳細說一下這個方案:

  • Reactor 物件通過 select (IO 多路複用介面) 監聽事件,收到事件後通過 dispatch 進行分發,具體分發給 Acceptor 物件還是 Handler 對象,還要看收到的事件型別;

  • 如果是連線建立的事件,則交由 Acceptor 物件進行處理,Acceptor 物件會通過 accept 方法 獲取連線,並建立一個 Handler 物件來處理後續的響應事件;

  • 如果不是連線建立事件, 則交由當前連線對應的 Handler 物件來進行響應

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

  • Handler 物件不再負責業務處理,只負責資料的接收和傳送,Handler 物件通過 read 讀取到資料後,會將資料發給子執行緒裡的 Processor 物件進行業務處理;

  • 子執行緒裡的 Processor 物件就進行業務處理,處理完後,將結果發給主執行緒中的 Handler 物件,接著由 Handler 通過 send 方法將響應結果傳送給 client

6.5.3 優點和缺點

6.5.3.1 優點

  單 Reator 多執行緒的方案優勢在於能夠充分利用多核 CPU 的能,那既然引入多執行緒,那麼自然就帶來了多執行緒競爭資源的問題。

6.5.3.2 缺點

  • 資源共享導致的競爭

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

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

  • 單 Reactor 多程序的通訊(資源共享)

  聊完單 Reactor 多執行緒的方案,接著來看看單 Reactor 多程序的方案。

  事實上,單 Reactor 多程序相比單 Reactor 多執行緒實現起來很麻煩,主要因為要考慮子程序 <-> 父程序的雙向通訊,並且父程序還得知道子程序要將資料傳送給哪個客戶端

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

  • 單 Reactor 的壓力

  另外,「單 Reactor」的模式還有個問題,因為一個 Reactor 物件承擔所有事件的監聽和響應,而且只在主執行緒中執行,在面對瞬間高併發的場景時,容易成為效能的瓶頸的地方

6.6 多 Reactor 多程序 / 執行緒

6.6.1 流程圖

  要解決「單 Reactor」的問題,就是將「單 Reactor」實現成「多 Reactor」,這樣就產生了第多 Reactor 多程序 / 執行緒的方案。

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

6.6.2 流程圖分析

方案詳細說明如下:

  • 主執行緒中的 MainReactor 物件通過 select 監控連線建立事件,收到事件後通過 Acceptor 物件中的 accept 獲取連線,將新的連線分配給某個子執行緒

  • 子執行緒中的 SubReactor 物件將 MainReactor 物件分配的連線加入 select 繼續進行監聽,並建立一個 Handler 用於處理連線的響應事件。

  • 如果有新的事件發生時,SubReactor 物件會呼叫當前連線對應的 Handler 物件來進行響應

  • Handler 物件通過 read -> 業務處理 -> send 的流程來完成完整的業務流程。

多 Reactor 多執行緒的方案雖然看起來複雜的,但是實際實現時比單 Reactor 多執行緒的方案要簡單的多,原因如下:

  • 主執行緒和子執行緒分工明確,主執行緒只負責接收新連線,子執行緒負責完成後續的業務處理

  • 主執行緒和子執行緒的互動很簡單,主執行緒只需要把新連線傳給子執行緒,子執行緒無須返回資料,直接就可以在子執行緒將處理結果傳送給客戶端

6.6.3 應用場景和例項

  大名鼎鼎的兩個開源軟體 Netty 和 Memcache 都採用了「多 Reactor 多執行緒」的方案。

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

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

七、Proactor總結

7.1 背景

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

7.1.1阻塞 I/O分析

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

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

7.1.2阻塞 I/O分析

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

  注意,這裡最後一次 read 呼叫,獲取資料的過程,是一個同步的過程,是需要等待的過程。這裡的同步指的是核心態的資料拷貝到使用者程式的快取區這個過程。

7.1.3 同步和非同步分析

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

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

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

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

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

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

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

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

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

7.2 Proactor

7.2.1 Proactor和Reactor對比

  Proactor 正是採用了非同步 I/O 技術,所以被稱為非同步網路模型。現在我們再來理解 Reactor 和 Proactor 的區別,就比較清晰了。

    • Reactor 是非阻塞同步網路模式,感知的是就緒可讀寫事件。需要注意的是,這裡所屬的非阻塞是指使用的socket是非阻塞的,但是使用者程序依然是阻塞的,與前面的分析並不衝突,在每次感知到有事件發生(比如可讀就緒事件)後,就需要應用程序主動呼叫 read 方法來完成資料的讀取,也就是要應用程序主動將 socket 接收快取中的資料讀到應用程序記憶體中,這個過程是同步的,讀取完資料後應用程序才能處理資料。

    • Proactor 是非同步網路模式, 感知的是已完成的讀寫事件。在發起非同步讀寫請求時,需要傳入資料緩衝區的地址(用來存放結果資料)等資訊,這樣系統核心才可以自動幫我們把資料的讀寫工作完成,這裡的讀寫工作全程由作業系統來做,並不需要像 Reactor 那樣還需要應用程序主動發起 read/write 來讀寫資料,作業系統完成讀寫工作後,就會通知應用程序直接處理資料。

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

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

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

7.2.2Proactor 模式的示意圖

7.2.3 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 完成業務處理;

7.3 Proactor 模式的問題

  可惜的是,在 Linux 下的非同步 I/O 是不完善的,aio系列函式是由 POSIX 定義的非同步操作介面,不是真正的作業系統級別支援的,而是在使用者空間模擬出來的非同步,並且僅僅支援基於本地檔案的 aio 非同步操作,網路程式設計中的 socket 是不支援的,這也使得基於 Linux 的高效能網路程式都是使用 Reactor 方案,linux也有核心級別的非同步IO操作函式libaio,但是存在著一定的缺陷,所有的檔案開啟的時候必須包含書O_DIRECT標誌,並非所有的檔案系統都支援該類介面,如果不支援,IO操作就會變成阻塞的,當然,如果你不新增O_DIRECT標誌,它鎖使用的IO操作也是阻塞的

  而 Windows 裡實現了一套完整的支援 socket 的非同步程式設計介面,這套介面就是IOCP,是由作業系統級別實現的非同步 I/O,真正意義上非同步 I/O,因此在 Windows 裡實現高效能網路程式可以使用效率更高的 Proactor 方案。

7.4 小結

常見的 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 事件,這個完成指的是資料的讀取已經完成,實際上單Reactor多執行緒就是一種模擬的Proactor。

八、參考文章

https://mp.weixin.qq.com/s/iHAMwuWk1XZUnM66FUIY_w

本文來自部落格園,作者:Mr-xxx,轉載請註明原文連結:https://www.cnblogs.com/MrLiuZF/p/15127013.html