Netty 那些事兒 ——— Reactor模式詳解

Netty 那些事兒 ——— Reactor模式詳解

https://www.jianshu.com/p/1ccbc6a348dbhtml

本文是Netty文集中「Netty 那些事兒」系列的文章。主要結合在開發實戰中,咱們遇到的一些「奇奇怪怪」的問題,以及如何正確且更好的使用Netty框架,並會對Netty中涉及的重要設計理念進行介紹。react

在學習Reactor模式以前,咱們須要對「I/O的四種模型」以及「什麼是I/O多路複用」進行簡單的介紹,由於Reactor是一個使用了同步非阻塞的I/O多路複用機制的模式。linux

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的事件循環,接下來就能夠開始客戶端和服務器端的通訊了。

後記

本文主要對Reactor模式進行詳細的解析,Netty中正是應用Reactor模式來實現異步事件驅動網絡應用框架的,因此對於Reactor模式的掌握在Netty的學習是相當重要的。
若文章有任何錯誤,望你們不吝指教:)

參考

聖思園《精通併發與Netty》
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
https://tech.meituan.com/nio.html
http://www.infoq.com/cn/articles/netty-threading-model
http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf Netty 權威指南 Java併發編程實戰

做者:tomas家的小撥浪鼓 連接:https://www.jianshu.com/p/1ccbc6a348db 來源:簡書 簡書著做權歸做者全部,任何形式的轉載都請聯繫做者得到受權並註明出處。
相關文章
相關標籤/搜索