Reactor模式詳解


在學習Reactor模式之前,我們需要對“I/O的四種模型”以及“什么是I/O多路復用”進行簡單的介紹,因為Reactor是一個使用了同步非阻塞的I/O多路復用機制的模式。

I/O的四種模型

I/0 操作 主要分成兩部分
① 數據准備,將數據加載到內核緩存
② 將內核緩存中的數據加載到用戶緩存

  • Synchronous blocking I/O

     
    Typical flow of the synchronous blocking I/O model

     

  • Synchronous non-blocking I/0

     
    Typical flow of the synchronous non-blocking I/O model

     

  • Asynchronous blocking I/0

     
    Typical flow of the asynchronous blocking I/O model (select)

     

  • Asynchronous non-blocking I/0

     
    Typical flow of the asynchronous non-blocking I/O model

     

堵塞、非堵塞的區別是在於第一階段,即數據准備階段。無論是堵塞還是非堵塞,都是用應用主動找內核要數據,而read數據的過程是‘堵塞’的,直到數據讀取完。
同步、異步的區別在於第二階段,若由請求者主動的去獲取數據,則為同步操作,需要說明的是:read/write操作也是‘堵塞’的,直到數據讀取完。
若數據的read都由kernel內核完成了(在內核read數據的過程中,應用進程依舊可以執行其他的任務),這就是異步操作。

換句話說,BIO里用戶最關心“我要讀”,NIO里用戶最關心"我可以讀了",在AIO模型里用戶更需要關注的是“讀完了”。
NIO一個重要的特點是:socket主要的讀、寫、注冊和接收函數,在等待就緒階段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。
NIO是一種同步非阻塞的I/O模型,也是I/O多路復用的基礎。

I/O多路復用

I/O多路復用是指使用一個線程來檢查多個文件描述符(Socket)的就緒狀態,比如調用select和poll函數,傳入多個文件描述符,如果有一個文件描述符就緒,則返回,否則阻塞直到超時。得到就緒狀態后進行真正的操作可以在同一個線程里執行,也可以啟動線程執行(比如使用線程池)。

一般情況下,I/O 復用機制需要事件分發器。 事件分發器的作用,將那些讀寫事件源分發給各讀寫事件的處理者。
涉及到事件分發器的兩種模式稱為:Reactor和Proactor。 Reactor模式是基於同步I/O的,而Proactor模式是和異步I/O相關的。本文主要介紹的就是 Reactor模式相關的知識。

經典的I/O服務設計 ———— BIO模式

 
 

👆這就是經典的每連接對應一個線程的同步阻塞I/O模式。

  • 流程:
    ① 服務器端的Server是一個線程,線程中執行一個死循環來阻塞的監聽客戶端的連接請求和通信。
    ② 當客戶端向服務器端發送一個連接請求后,服務器端的Server會接受客戶端的請求,ServerSocket.accept()從阻塞中返回,得到一個與客戶端連接相對於的Socket。
    ③ 構建一個handler,將Socket傳入該handler。創建一個線程並啟動該線程,在線程中執行handler,這樣與客戶端的所有的通信以及數據處理都在該線程中執行。當該客戶端和服務器端完成通信關閉連接后,線程就會被銷毀。
    ④ 然后Server繼續執行accept()操作等待新的連接請求。

  • 優點:
    ① 使用簡單,容易編程
    ② 在多核系統下,能夠充分利用了多核CPU的資源。即,當I/O阻塞系統,但CPU空閑的時候,可以利用多線程使用CPU資源。

  • 缺點:
    該模式的本質問題在於嚴重依賴線程,但線程Java虛擬機非常寶貴的資源。隨着客戶端並發訪問量的急劇增加,線程數量的不斷膨脹將服務器端的性能將急劇下降。
    ① 線程生命周期的開銷非常高。線程的創建與銷毀並不是沒有代價的。在Linux這樣的操作系統中,線程本質上就是一個進程,創建和銷毀都是重量級的系統函數。
    ② 資源消耗。內存:大量空閑的線程會占用許多內存,給垃圾回收器帶來壓力。;CPU:如果你已經擁有足夠多的線程使所有CPU保持忙碌狀態,那么再創建更過的線程反而會降低性能。
    ③ 穩定性。在可創建線程的數量上存在一個限制。這個限制值將隨着平台的不同而不同,並且受多個因素制約:a)JVM的啟動參數、b)Threa的構造函數中請求的棧大小、c)底層操作系統對線程的限制 等。如果破壞了這些限制,那么很可能拋出OutOfMemoryError異常。
    ④ 線程的切換成本是很高的。操作系統發生線程切換的時候,需要保留線程的上下文,然后執行系統調用。如果線程數過高,不僅會帶來許多無用的上下文切換,還可能導致執行線程切換的時間甚至會大於線程執行的時間,這時候帶來的表現往往是系統負載偏高、CPU sy(系統CPU)使用率特別高,導致系統幾乎陷入不可用的狀態。
    ⑤ 容易造成鋸齒狀的系統負載。一旦線程數量高但外部網絡環境不是很穩定,就很容易造成大量請求的結果同時返回,激活大量阻塞線程從而使系統負載壓力過大。
    ⑥ 若是長連接的情況下並且客戶端與服務器端交互並不頻繁的,那么客戶端和服務器端的連接會一直保留着,對應的線程也就一直存在在,但因為不頻繁的通信,導致大量線程在大量時間內都處於空置狀態。

  • 適用場景:如果你有少量的連接使用非常高的帶寬,一次發送大量的數據,也許典型的IO服務器實現可能非常契合。

Reactor模式

Reactor模式(反應器模式)是一種處理一個或多個客戶端並發交付服務請求的事件設計模式。當請求抵達后,服務處理程序使用I/O多路復用策略,然后同步地派發這些請求至相關的請求處理程序。

Reactor結構

 

 
 


Reactor模式的角色構成(Reactor模式一共有5中角色構成):

 

  • Handle(句柄或描述符,在Windows下稱為句柄,在Linux下稱為描述符):本質上表示一種資源(比如說文件描述符,或是針對網絡編程中的socket描述符),是由操作系統提供的;該資源用於表示一個個的事件,事件既可以來自於外部,也可以來自於內部;外部事件比如說客戶端的連接請求,客戶端發送過來的數據等;內部事件比如說操作系統產生的定時事件等。它本質上就是一個文件描述符,Handle是事件產生的發源地。
  • Synchronous Event Demultiplexer(同步事件分離器):它本身是一個系統調用,用於等待事件的發生(事件可能是一個,也可能是多個)。調用方在調用它的時候會被阻塞,一直阻塞到同步事件分離器上有事件產生為止。對於Linux來說,同步事件分離器指的就是常用的I/O多路復用機制,比如說select、poll、epoll等。在Java NIO領域中,同步事件分離器對應的組件就是Selector;對應的阻塞方法就是select方法。
  • Event Handler(事件處理器):本身由多個回調方法構成,這些回調方法構成了與應用相關的對於某個事件的反饋機制。在Java NIO領域中並沒有提供事件處理器機制讓我們調用或去進行回調,是由我們自己編寫代碼完成的。Netty相比於Java NIO來說,在事件處理器這個角色上進行了一個升級,它為我們開發者提供了大量的回調方法,供我們在特定事件產生時實現相應的回調方法進行業務邏輯的處理,即,ChannelHandler。ChannelHandler中的方法對應的都是一個個事件的回調。
  • Concrete Event Handler(具體事件處理器):是事件處理器的實現。它本身實現了事件處理器所提供的各種回調方法,從而實現了特定於業務的邏輯。它本質上就是我們所編寫的一個個的處理器實現。
  • Initiation Dispatcher(初始分發器):實際上就是Reactor角色。它本身定義了一些規范,這些規范用於控制事件的調度方式,同時又提供了應用進行事件處理器的注冊、刪除等設施。它本身是整個事件處理器的核心所在,Initiation Dispatcher會通過Synchronous Event Demultiplexer來等待事件的發生。一旦事件發生,Initiation Dispatcher首先會分離出每一個事件,然后調用事件處理器,最后調用相關的回調方法來處理這些事件。Netty中ChannelHandler里的一個個回調方法都是由bossGroup或workGroup中的某個EventLoop來調用的。

Reactor模式流程

① 初始化Initiation Dispatcher,然后將若干個Concrete Event Handler注冊到Initiation Dispatcher中。當應用向Initiation Dispatcher注冊Concrete Event Handler時,會在注冊的同時指定感興趣的事件,即,應用會標識出該事件處理器希望Initiation Dispatcher在某些事件發生時向其發出通知,事件通過Handle來標識,而Concrete Event Handler又持有該Handle。這樣,事件 ————> Handle ————> Concrete Event Handler 就關聯起來了。
② Initiation Dispatcher 會要求每個事件處理器向其傳遞內部的Handle。該Handle向操作系統標識了事件處理器。
③ 當所有的Concrete Event Handler都注冊完畢后,應用會調用handle_events方法來啟動Initiation Dispatcher的事件循環。這是,Initiation Dispatcher會將每個注冊的Concrete Event Handler的Handle合並起來,並使用Synchronous Event Demultiplexer(同步事件分離器)同步阻塞的等待事件的發生。比如說,TCP協議層會使用select同步事件分離器操作來等待客戶端發送的數據到達連接的socket handler上。
比如,在Java中通過Selector的select()方法來實現這個同步阻塞等待事件發生的操作。在Linux操作系統下,select()的實現中 a)會將已經注冊到Initiation Dispatcher的事件調用epollCtl(epfd, opcode, fd, events)注冊到linux系統中,這里fd表示Handle,events表示我們所感興趣的Handle的事件;b)通過調用epollWait方法同步阻塞的等待已經注冊的事件的發生。不同事件源上的事件可能同時發生,一旦有事件被觸發了,epollWait方法就會返回;c)最后通過發生的事件找到相關聯的SelectorKeyImpl對象,並設置其發生的事件為就緒狀態,然后將SelectorKeyImpl放入selectedSet中。這樣一來我們就可以通過Selector.selectedKeys()方法得到事件就緒的SelectorKeyImpl集合了。
④ 當與某個事件源對應的Handle變為ready狀態時(比如說,TCP socket變為等待讀狀態時),Synchronous Event Demultiplexer就會通知Initiation Dispatcher。
⑤ Initiation Dispatcher會觸發事件處理器的回調方法,從而響應這個處於ready狀態的Handle。當事件發生時,Initiation Dispatcher會將被事件源激活的Handle作為『key』來尋找並分發恰當的事件處理器回調方法。
⑥ Initiation Dispatcher會回調事件處理器的handle_event(type)回調方法來執行特定於應用的功能(開發者自己所編寫的功能),從而相應這個事件。所發生的事件類型可以作為該方法參數並被該方法內部使用來執行額外的特定於服務的分離與分發。

Reactor模式的實現方式

單線程Reactor模式
 
 

 

流程:
① 服務器端的Reactor是一個線程對象,該線程會啟動事件循環,並使用Selector來實現IO的多路復用。注冊一個Acceptor事件處理器到Reactor中,Acceptor事件處理器所關注的事件是ACCEPT事件,這樣Reactor會監聽客戶端向服務器端發起的連接請求事件(ACCEPT事件)。
② 客戶端向服務器端發起一個連接請求,Reactor監聽到了該ACCEPT事件的發生並將該ACCEPT事件派發給相應的Acceptor處理器來進行處理。Acceptor處理器通過accept()方法得到與這個客戶端對應的連接(SocketChannel),然后將該連接所關注的READ事件以及對應的READ事件處理器注冊到Reactor中,這樣一來Reactor就會監聽該連接的READ事件了。或者當你需要向客戶端發送數據時,就向Reactor注冊該連接的WRITE事件和其處理器。
③ 當Reactor監聽到有讀或者寫事件發生時,將相關的事件派發給對應的處理器進行處理。比如,讀處理器會通過SocketChannel的read()方法讀取數據,此時read()操作可以直接讀取到數據,而不會堵塞與等待可讀的數據到來。
④ 每當處理完所有就緒的感興趣的I/O事件后,Reactor線程會再次執行select()阻塞等待新的事件就緒並將其分派給對應處理器進行處理。

注意,Reactor的單線程模式的單線程主要是針對於I/O操作而言,也就是所以的I/O的accept()、read()、write()以及connect()操作都在一個線程上完成的。

但在目前的單線程Reactor模式中,不僅I/O操作在該Reactor線程上,連非I/O的業務操作也在該線程上進行處理了,這可能會大大延遲I/O請求的響應。所以我們應該將非I/O的業務邏輯操作從Reactor線程上卸載,以此來加速Reactor線程對I/O請求的響應。

改進:使用工作者線程池
 
 

與單線程Reactor模式不同的是,添加了一個工作者線程池,並將非I/O操作從Reactor線程中移出轉交給工作者線程池來執行。這樣能夠提高Reactor線程的I/O響應,不至於因為一些耗時的業務邏輯而延遲對后面I/O請求的處理。

使用線程池的優勢:
① 通過重用現有的線程而不是創建新線程,可以在處理多個請求時分攤在線程創建和銷毀過程產生的巨大開銷。
② 另一個額外的好處是,當請求到達時,工作線程通常已經存在,因此不會由於等待創建線程而延遲任務的執行,從而提高了響應性。
③ 通過適當調整線程池的大小,可以創建足夠多的線程以便使處理器保持忙碌狀態。同時還可以防止過多線程相互競爭資源而使應用程序耗盡內存或失敗。

注意,在上圖的改進的版本中,所以的I/O操作依舊由一個Reactor來完成,包括I/O的accept()、read()、write()以及connect()操作。
對於一些小容量應用場景,可以使用單線程模型。但是對於高負載、大並發或大數據量的應用場景卻不合適,主要原因如下:
① 一個NIO線程同時處理成百上千的鏈路,性能上無法支撐,即便NIO線程的CPU負荷達到100%,也無法滿足海量消息的讀取和發送;
② 當NIO線程負載過重之后,處理速度將變慢,這會導致大量客戶端連接超時,超時之后往往會進行重發,這更加重了NIO線程的負載,最終會導致大量消息積壓和處理超時,成為系統的性能瓶頸;

多Reactor線程模式
 
 

Reactor線程池中的每一Reactor線程都會有自己的Selector、線程和分發的事件循環邏輯。
mainReactor可以只有一個,但subReactor一般會有多個。mainReactor線程主要負責接收客戶端的連接請求,然后將接收到的SocketChannel傳遞給subReactor,由subReactor來完成和客戶端的通信。

流程:
① 注冊一個Acceptor事件處理器到mainReactor中,Acceptor事件處理器所關注的事件是ACCEPT事件,這樣mainReactor會監聽客戶端向服務器端發起的連接請求事件(ACCEPT事件)。啟動mainReactor的事件循環。
② 客戶端向服務器端發起一個連接請求,mainReactor監聽到了該ACCEPT事件並將該ACCEPT事件派發給Acceptor處理器來進行處理。Acceptor處理器通過accept()方法得到與這個客戶端對應的連接(SocketChannel),然后將這個SocketChannel傳遞給subReactor線程池。
③ subReactor線程池分配一個subReactor線程給這個SocketChannel,即,將SocketChannel關注的READ事件以及對應的READ事件處理器注冊到subReactor線程中。當然你也注冊WRITE事件以及WRITE事件處理器到subReactor線程中以完成I/O寫操作。Reactor線程池中的每一Reactor線程都會有自己的Selector、線程和分發的循環邏輯。
④ 當有I/O事件就緒時,相關的subReactor就將事件派發給響應的處理器處理。注意,這里subReactor線程只負責完成I/O的read()操作,在讀取到數據后將業務邏輯的處理放入到線程池中完成,若完成業務邏輯后需要返回數據給客戶端,則相關的I/O的write操作還是會被提交回subReactor線程來完成。

注意,所以的I/O操作(包括,I/O的accept()、read()、write()以及connect()操作)依舊還是在Reactor線程(mainReactor線程 或 subReactor線程)中完成的。Thread Pool(線程池)僅用來處理非I/O操作的邏輯。

多Reactor線程模式將“接受客戶端的連接請求”和“與該客戶端的通信”分在了兩個Reactor線程來完成。mainReactor完成接收客戶端連接請求的操作,它不負責與客戶端的通信,而是將建立好的連接轉交給subReactor線程來完成與客戶端的通信,這樣一來就不會因為read()數據量太大而導致后面的客戶端連接請求得不到即時處理的情況。並且多Reactor線程模式在海量的客戶端並發請求的情況下,還可以通過實現subReactor線程池來將海量的連接分發給多個subReactor線程,在多核的操作系統中這能大大提升應用的負載和吞吐量。

Netty 與 Reactor模式

Netty的線程模式就是一個實現了Reactor模式的經典模式。

    • 結構對應:
      NioEventLoop ———— Initiation Dispatcher
      Synchronous EventDemultiplexer ———— Selector
      Evnet Handler ———— ChannelHandler
      ConcreteEventHandler ———— 具體的ChannelHandler的實現

    • 模式對應:
      Netty服務端使用了“多Reactor線程模式”
      mainReactor ———— bossGroup(NioEventLoopGroup) 中的某個NioEventLoop
      subReactor ———— workerGroup(NioEventLoopGroup) 中的某個NioEventLoop
      acceptor ———— ServerBootstrapAcceptor
      ThreadPool ———— 用戶自定義線程池

    • 流程:
      ① 當服務器程序啟動時,會配置ChannelPipeline,ChannelPipeline中是一個ChannelHandler鏈,所有的事件發生時都會觸發Channelhandler中的某個方法,這個事件會在ChannelPipeline中的ChannelHandler鏈里傳播。然后,從bossGroup事件循環池中獲取一個NioEventLoop來現實服務端程序綁定本地端口的操作,將對應的ServerSocketChannel注冊到該NioEventLoop中的Selector上,並注冊ACCEPT事件為ServerSocketChannel所感興趣的事件。
      ② NioEventLoop事件循環啟動,此時開始監聽客戶端的連接請求。
      ③ 當有客戶端向服務器端發起連接請求時,NioEventLoop的事件循環監聽到該ACCEPT事件,Netty底層會接收這個連接,通過accept()方法得到與這個客戶端的連接(SocketChannel),然后觸發ChannelRead事件(即,ChannelHandler中的channelRead方法會得到回調),該事件會在ChannelPipeline中的ChannelHandler鏈中執行、傳播。
      ④ ServerBootstrapAcceptor的readChannel方法會該SocketChannel(客戶端的連接)注冊到workerGroup(NioEventLoopGroup) 中的某個NioEventLoop的Selector上,並注冊READ事件為SocketChannel所感興趣的事件。啟動SocketChannel所在NioEventLoop的事件循環,接下來就可以開始客戶端和服務器端的通信了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM