Netty快速上手:Netty沒有你想象的那麼難

簡相傑3rkV9發表於2020-05-20
該文章是Netty相關文章。目的是讓讀者能夠快速的瞭解netty的相關知識以及開發方法。因此本文章在正式介紹Netty開發前先介紹了Netty的前置相關內容:執行緒模型,JavaNIO,零拷貝等。本文章以大綱框架的形式整體介紹了Netty,希望對讀者有些幫助。文中圖片多來自於百度網路,如果有侵權,可以聯絡我進行刪除。內容若有不當歡迎在評論區指出。

Netty

netty是由JBOSS提供的一個Java開源框架,是一個非同步的,基於事件驅動的網路應用框架,用以快速開發高效能,高可靠性的網路IO程式.

NIO模型

  1. 阻塞IO:發起請求就一直等待,直到資料返回。在IO執行的兩個階段都被block了

  1. 非阻塞IO:應用程式不斷在一個迴圈裡呼叫recvfrom,輪詢核心,看是否準備好了資料,比較浪費CPU

  1. io複用:一個或一組執行緒處理多個連線可以同時對多個讀/寫操作的IO函式進行輪詢檢測,直到有資料可讀或可寫時,才真正呼叫IO操作函式

  1. 訊號驅動IO:事先發出一個請求,當有資料後會返回一個標識回撥,然後通過recvfrmo去請求資料

  1. 非同步io:發出請求就返回,剩下的事情會非同步自動完成,不需要做任何處理

非同步 I/O 與訊號驅動 I/O 的區別在於,非同步 I/O 的訊號是通知應用程式 I/O 完成,而訊號驅動 I/O 的訊號是通知應用程式可以開始 I/O。

Java NIO

  1. 三大核心Channel(通道),Buffer(緩衝區),Selector(選擇器)。資料總是從通道讀取到緩衝區中,或者從緩衝區寫入到通道中,Selector用於監聽多個通道的事件。
  2. Channel:是雙向的,既可以用來進行讀操作,又可以用來進行寫操作

    • FileChannel 檔案IO,不支援非阻塞模式,無法同Selector一同使用。
    • DatagramChannel 用於處理UDP的連線。
    • SocketChannel 用於處理TCP客戶端的連線。
    • ServerSocketChannel 用於處理TCP服務端的連線。
  3. Buffer:它通過幾個變數來儲存這個資料的當前位置狀態:

    • capacity:緩衝區陣列的總長度
    • position:下一個要操作的資料元素的位置
    • limit:緩衝區陣列中不可操作的下一個元素的位置
  4. 向Buffer中寫資料:

    • 從Channel寫到Buffer (fileChannel.read(buf))
    • 通過Buffer的put()方法 (buf.put(…))
  5. 從Buffer中讀取資料:

    • 從Buffer讀取到Channel (channel.write(buf))
    • 使用get()方法從Buffer中讀取資料 (buf.get())
  6. Buffer常用方法

    1. flip():寫模式下呼叫flip()之後,Buffer從寫模式變成讀模式。limit設定為position,position將被設回0
    2. clear()方法:position將被設回0,limit設定成capacity,Buffer被清空了,但Buffer中的資料並未被清除。
    3. compact():將所有未讀的資料拷貝到Buffer起始處。然後將position設到最後一個未讀元素正後面,limit設定成capacity,準備繼續寫入。讀模式變成寫模式
    4. Buffer.rewind()方法將position設回0,所以你可以重讀Buffer中的所有資料
  7. Selector:Selector一起使用時,Channel必須處於非阻塞模式下。通過channel.register,將channel登記到Selector上,同時新增關注的事件(SelectionKey),常用方法如下:

    • select()阻塞到至少有一個通道在你註冊的事件上就緒了。
    • select(long timeout)和select()一樣,除了最長會阻塞timeout毫秒(引數)。
    • selectNow()不會阻塞,不管什麼通道就緒都立刻返回
    • selectedKeys()方法訪問就緒的通道。Selector不會自己從已選擇鍵集中移除SelectionKey例項。

NIO其他功能:

  1. MappedByteBuffer是NIO引入的檔案記憶體對映方案,讀寫效能極高。
  2. transferFrom & transferTo:FileChannel的transferFrom()方法可以將資料從源通道傳輸到FileChannel中.
  3. 分散(scatter)從Channel中讀取是指在讀操作時將讀取的資料寫入多個buffer中。因此,Channel將從Channel中讀取的資料“分散(scatter)”到多個Buffer中。
  4. 聚集(gather)寫入Channel是指在寫操作時將多個buffer的資料寫入同一個Channel,因此,Channel 將多個Buffer中的資料“聚集(gather)”後傳送到Channel。

Linux的NIO:

  1. select:阻塞地同時探測一組支援非阻塞的IO裝置,直至某一個裝置觸發了事件或者超過了指定的等待時間。當select函式返回後可以遍歷檔案描述符,找到就緒的描述符

缺點:

1. 單程式所開啟的FD是具有一定限制的,
2. 套接字比較多的時候,每次select()都要通過遍歷Socket來完成排程,不管哪個Socket是活躍的,都遍歷一遍。這會浪費很多CPU時間
3. 每次都需要把fd集合從⽤使用者態拷貝到核心態,這個開銷在fd很多時會很⼤大
  1. poll:本質上和select沒有區別,fd使用連結串列實現,沒有最大連線數的限制。

    • 缺點:

      1. 大量的fd陣列都需要從使用者態拷貝到核心態。
      2. poll的“水平觸發”:如果報告了fd後,沒有被處理,則下次poll還會再次報告該fd。
  2. epoll:
    epoll有EPOLLLT和EPOLLET兩種觸發模式,LT是預設的模式,ET是“高速”模式。

    • LT(水平觸發)模式下,只要這個檔案描述符還有資料可讀,每次 epoll都會返回它的事件,提醒使用者程式去操作;
    • ET(邊緣觸發)模式下,對於每一個被通知的檔案描述符,如可讀,則必須將該檔案描述符一直讀到空,否則下次的 epoll不會返回餘下的資料,會丟掉事件(只通知一次)。
**epoll底層原理**:呼叫epoll_create後,核心cache裡建了個紅黑樹用於儲存以後epoll_ctl傳來的socket,建立一個rdllist雙向連結串列,用於儲存準備就緒的事件。在epoll_wait呼叫時,僅僅觀察這個rdllist雙向連結串列裡有沒有資料即可。有資料就返回,沒有資料就阻塞。

零拷貝:

對一個作業系統程式來說,它既有核心空間(與其他程式共享),也有使用者空間(程式私有),它們都是處於虛擬地址空間中。程式無法直接操作I/O裝置,必須通過作業系統呼叫請求核心來協助完成I/O動作。將靜態檔案展示給使用者需要先將靜態內容從磁碟中拷貝出來放到記憶體buf中,然後再將這個buf通過socket發給使用者
問題:經歷了4次copy過程,4次核心切換

1. 使用者態到核心態:呼叫read,檔案copy到核心態記憶體
2. 核心態到使用者態:核心態記憶體資料copy到使用者態記憶體
3. 使用者態到核心態:呼叫writer:使用者態記憶體資料到核心態socket的buffer記憶體中
4. 最後核心模式下的socket模式下的buffer資料copy到網路卡裝置中傳送
5. 從核心態回到使用者態執行下一個迴圈

Linux:零拷貝技術消除傳輸資料在儲存器之間不必要的中間拷貝次數,減少了使用者程式地址空間和核心地址空間之間因為上下文切換而帶來的開銷。

常見零拷貝技術

  • mmap():應用程式呼叫mmap(),磁碟上的資料會通過DMA被拷貝到核心緩衝區,然後作業系統會把這段核心緩衝區與應用程式共享,這樣就不需要把核心緩衝區的內容往使用者空間拷貝。資料向網路中寫時,只需要把資料從這塊共享的核心緩衝區中拷貝到socket緩衝區中去就行了,這些操作都發生在核心態.
  • sendfile():DMA將磁碟資料複製到kernel buffer,然後將核心中的kernel buffer直接拷貝到socket buffer;一旦資料全都拷貝到socket buffer,sendfile()系統呼叫將會return、代表資料轉化的完成。
  • splice():從磁碟讀取到核心buffer後,在核心空間直接與socket buffer建立pipe管道,不需要核心支援。
  • DMA scatter/gather:批量copy

零拷貝不僅僅帶來更少的資料複製,還能帶來其他的效能優勢,例如更少的上下文切換,更少的 CPU 快取偽共享以及無 CPU 校驗和計算。

netty 介紹

Netty 對 JDK 自帶的 NIO 的 API 進行了封裝,解決了上述問題。

  1. 設計優雅:適用於各種傳輸型別的統一 API 阻塞和非阻塞 Socket;基於靈活且可擴充套件的事件模型,可以清晰地分離關注點;
  2. 高度可定製的執行緒模型 - 單執行緒,一個或多個執行緒池.
  3. 使用方便:詳細記錄的 Javadoc,使用者指南和示例;沒有其他依賴項,JDK 5(Netty 3.x)或 6(Netty 4.x)就 足夠了。
  4. 高效能、吞吐量更高:延遲更低;減少資源消耗;最小化不必要的記憶體複製。
  5. 安全:完整的 SSL/TLS 和 StartTLS 支援。
  6. 社群活躍、不斷更新:社群活躍,版本迭代週期短,發現的 Bug 可以被及時修復,同時,更多的新功能會被加入
  7. Java原生NIO使用起碼麻煩需要自己管理執行緒,Netty對JDK自帶的NIO的api進行了封裝,提供了更簡單優雅的實現方式。由於netty5使用ForkJoinPool增加了複雜性,並且沒有顯示出明顯的效能優勢,所以netty5現在被廢棄掉了。

netty執行緒模型

Reactor模式:是事件驅動的,多個併發輸入源。它有一個服務處理器,有多個請求處理器;這個服務處理器會同步的將輸入的客戶端請求事件多路複用的分發給相應的請求處理器。

單Reactor單執行緒:多路複用、事件分發和訊息的處理都是在一個Reactor執行緒上完成。

* 優點:
    * 模型簡單,實現方便
* 缺點:
    
    * 效能差:單執行緒無法發揮多核效能,
    * 可靠性差:執行緒意外終止或死迴圈,則整個模組不可用

單Reactor多執行緒
一個Reactor執行緒負責監聽服務端的連線請求和接收客戶端的TCP讀寫請求;NIO執行緒池負責訊息的讀取、解碼、編碼和傳送

優點:可以充分的利用多核cpu的處理能

缺點:Reactor處理所有事件的監聽和響應,在單執行緒執行,在高併發場景容易出現效能瓶頸.

主從 Reactor 多執行緒
MainReactor負責監聽服務端的連線請求,接收到客戶端的連線後,將SocketChannel從MainReactor上移除,重新註冊到SubReactor執行緒池的執行緒上。SubReactor處理I/O的讀寫操作,NIO執行緒池負責訊息的讀取、解碼、編碼和傳送。

netty工作原理圖

NioEventLoopGroup:主要管理 eventLoop 的生命週期,可以理解為一個執行緒池,內部維護了一組執行緒,每個執行緒(NioEventLoop)負責處理多個 Channel 上的事件,而一個 Channel 只對應於一個執行緒
ChannelHandler用於處理Channel對應的事件
示例程式碼

public class NettyServer {
    public static void main(String[] args) throws Exception {

        //bossGroup和workerGroup分別對應mainReactor和subReactor
        NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workGroup = new NioEventLoopGroup();

        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workGroup)
                //用來指定一個Channel工廠,mainReactor用來包裝SocketChannel.
                .channel(NioServerSocketChannel.class)
                //用於指定TCP相關的引數以及一些Netty自定義的引數
                .option(ChannelOption.SO_BACKLOG, 100)
                //childHandler()用於指定subReactor中的處理器,類似的,handler()用於指定mainReactor的處理器
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    //ChannelInitializer,它是一個特殊的Handler,功能是初始化多個Handler。完成初始化工作後,netty會將ChannelInitializer從Handler鏈上刪除。
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        ChannelPipeline pipeline = socketChannel.pipeline();
                        //addLast(Handler)方法中不指定執行緒池那麼將使用預設的subReacor即woker執行緒池執行處理器中的業務邏輯程式碼。
                        pipeline.addLast(new StringDecoder());
                        pipeline.addLast(new StringEncoder());
                        pipeline.addLast(new MyServerHandler());
                    }
                });
        //sync() 同步阻塞直到bind成功
        ChannelFuture f = bootstrap.bind(8888).sync();
        //sync()同步阻塞直到netty工作結束
        f.channel().closeFuture().sync();

    }
}

執行緒組

  • NioEventLoopGroup:

    1. NioEventLoopGroup初始化時未指定執行緒數,那麼會使用預設執行緒數。
    2. 每個NioEventLoopGroup物件內部都有一組可執行的NioEventLoop陣列。
    3. 當有IO事件來時,需要從執行緒池中選擇一個執行緒出來執行,這時候的NioEventLoop選擇策略是由EventExecutorChooser實現的,並呼叫該類的next()方法。
    4. 每個NioEventLoopGroup物件都有一個NioEventLoop選擇器與之對應,其會根據NioEventLoop的個數,EventExecutorChooser(如果是2的冪次方,則按位運算,否則使用普通的輪詢)
  • NioEventLoop
    NioEventLoop 肩負著兩種任務:

    1. 作為 IO 執行緒, 執行與 Channel 相關的 IO 操作, 包括 呼叫 select 等待就緒的 IO 事件、讀寫資料與資料的處理等;
    2. 作為任務佇列, 執行 taskQueue 中的任務, 例如使用者呼叫 eventLoop.schedule 提交的定時任務也是這個執行緒執行的

BootStrap和ServerBootstrap

ServerBootstrap是一個工具類,用來配置netty

  1. channel():提供一個ChannelFactory來建立channel,不同協議的連線有不同的 Channel 型別與之對應,常見的Channel型別:

    • NioSocketChannel, 代表非同步的客戶端 TCP Socket 連線.
    • NioServerSocketChannel, 非同步的伺服器端 TCP Socket 連線.
    • NioDatagramChannel, 非同步的 UDP 連線
  2. group():配置工作執行緒組,用於處理channel的事件
  3. ChannelHandler():使用者自定義的事件處理器

出站和入站:

ChannelHandler下主要是兩個子介面

  1. ChannelInboundHandler(入站): 處理輸入資料和Channel狀態型別改變。

    • 介面卡: ChannelInboundHandlerAdapter(介面卡設計模式)
    • 常用的: SimpleChannelInboundHandler
  2. ChannelOutboundHandler(出站): 處理輸出資料

    • 介面卡: ChannelOutboundHandlerAdapter

ChannelPipeline 是一個 Handler 的集合,它負責處理和攔截 inbound 或者 outbound 的事件和操作,一個貫穿 Netty 的鏈。每個新的通道Channel,Netty都會建立一個新的ChannelPipeline,並將器pipeline附加到channel中。DefaultChinnelPipeline它的Handel頭部和尾部的Handel是固定的,我們所新增的Handel是新增在這個頭和尾之前的Handel。

ChannelHandlerContext:ChannelPipeline並不是直接管理ChannelHandler,而是通過ChannelHandlerContext來間接管理。

image

Netty編碼器

網路中都是以位元組碼的資料形式來傳輸資料的,伺服器編碼資料後傳送到客戶端,客戶端需要對資料進行解碼

  • encoder 負責把業務資料轉換成位元組碼資料
  • decoder 負責把位元組碼資料轉換成業務資料

Netty提供了一些預設的編碼器:
StringEncoder:對字串資料進行編碼
ObjectEncoder:對 Java 物件進行編碼
StringDecoder:對字串資料進行解碼
ObjectDecoder:對 Java 物件進行解碼

抽象解碼器

  1. ByteToMessageDecoder: 用於將位元組轉為訊息,需要檢查緩衝區是否有足夠的位元組
  2. ReplayingDecoder: 繼承ByteToMessageDecoder,不需要檢查緩衝區是否有足夠的位元組,但是ReplayingDecoder速度略慢於ByteToMessageDecoder,同時不是所有的ByteBuf都支援。

    • 選擇:專案複雜性高則使用ReplayingDecoder,否則使用 ByteToMessageDecoder
  3. MessageToMessageDecoder: 用於從一種訊息解碼為另外一種訊息

TCP粘包:

UDP是基於幀的,包的首部有資料包文的長度.TCP是基於位元組流,沒有邊界的。TCP的首部沒有表示資料長度的欄位。

  • 發生TCP粘包或拆包的原因:

    1. 要傳送的資料大於TCP傳送緩衝區剩餘空間大小,將會發生拆包。
    2. 待傳送資料大於MSS(最大報文長度),TCP在傳輸前將進行拆包。
    3. 要傳送的資料小於TCP傳送緩衝區的大小,TCP將多次寫入緩衝區的資料一次傳送出去,將會發生粘包。
    4. 接收資料端的應用層沒有及時讀取接收緩衝區中的資料,將發生粘包。
  • 解決方式:
  1. 傳送定長訊息,如果位置不夠,填充特殊字元
  2. 在每一個包的尾部加一個特殊分割符
  3. 傳送端給每個資料包新增包首部,首部中應該至少包含資料包的長度。
  • Netty 已經提供了編碼器用於解決粘包。

    1. LineBasedFrameDecoder 可以基於換行符解決。
    2. DelimiterBasedFrameDecoder可基於分隔符解決。
    3. FixedLengthFrameDecoder可指定長度解決。

netty的零拷貝

Netty完全工作在使用者態的,Netty的零拷貝更多的對資料操作的優化。

Netty的零拷貝(或者說ByteBuf的複用)主要體現在以下幾個方面:

  1. DirectByteBuf通過直接在堆外分配記憶體的方式,避免了資料從堆內拷貝到堆外的過程
  2. 通過組合ByteBuf類:即CompositeByteBuf,將多個ByteBuf合併為一個邏輯上的ByteBuf, 而不需要進行資料拷貝
  3. 通過各種包裝方法, 將 byte[]、ByteBuffer等包裝成一個ByteBuf物件,而不需要進行資料的拷貝
  4. 通過slice方法, 將一個ByteBuf分解為多個共享同一個儲存區域的ByteBuf, 避免了記憶體的拷貝,這在需要進行拆包操作時非常管用
  5. 通過FileRegion包裝的FileChannel.tranferTo方法進行檔案傳輸時, 可以直接將檔案緩衝區的資料傳送到目標Channel, 減少了通過迴圈write方式導致的記憶體拷貝。但是這種方式是需要得到作業系統的零拷貝的支援的,如果netty所執行的作業系統不支援零拷貝的特性,則netty仍然無法做到零拷貝。

相關文章