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

該文章是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

Buffer:它通過幾個變量來保存這個數據的當前位置狀態:

capacity:緩衝區數組的總長度

position:下一個要操作的數據元素的位置

limit:緩衝區數組中不可操作的下一個元素的位置

  1. 向Buffer中寫數據:

從Channel寫到Buffer (fileChannel.read(buf))

通過Buffer的put()方法 (buf.put(…))

從Buffer中讀取數據:

從Buffer讀取到Channel (channel.write(buf))

使用get()方法從Buffer中讀取數據 (buf.get())

  1. flip():寫模式下調用flip()之後,Buffer從寫模式變成讀模式。limit設置爲position,position將被設回0

clear()方法:position將被設回0,limit設置成capacity,Buffer被清空了,但Buffer中的數據並未被清除。

compact():將所有未讀的數據拷貝到Buffer起始處。然後將position設到最後一個未讀元素正後面,limit設置成capacity,準備繼續寫入。讀模式變成寫模式

Buffer.rewind()方法將position設回0,所以你可以重讀Buffer中的所有數據

  1. Selector:Selector一起使用時,Channel必須處於非阻塞模式下。通過channel.register,將channel登記到Selector上,同時添加關注的事件(SelectionKey)

select()阻塞到至少有一個通道在你註冊的事件上就緒了。

select(long timeout)和select()一樣,除了最長會阻塞timeout毫秒(參數)。

selectNow()不會阻塞,不管什麼通道就緒都立刻返回

selectedKeys()方法訪問就緒的通道。Selector不會自己從已選擇鍵集中移除SelectionKey實例。

NIO其他功能:

MappedByteBuffer是NIO引入的文件內存映射方案,讀寫性能極高。

transferFrom & transferTo:FileChannel的transferFrom()方法可以將數據從源通道傳輸到FileChannel中.

分散(scatter)從Channel中讀取是指在讀操作時將讀取的數據寫入多個buffer中。因此,Channel將從Channel中讀取的數據“分散(scatter)”到多個Buffer中。

聚集(gather)寫入Channel是指在寫操作時將多個buffer的數據寫入同一個Channel,因此,Channel 將多個Buffer中的數據“聚集(gather)”後發送到Channel。

Linux的NIO:

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

缺點:

1.單進程所打開的FD是具有一定限制的,

2.套接字比較多的時候,每次select()都要通過遍歷Socket來完成調度,不管哪個Socket是活躍的,都遍歷一遍。這會浪費很多CPU時間

3.每次都需要把fd集合從⽤用戶態拷貝到內核態,這個開銷在fd很多時會很⼤大

poll:本質上和select沒有區別,fd使用鏈表實現,沒有最大連接數的限制。

缺點:大量的fd數組都需要從用戶態拷貝到內核態。poll的“水平觸發”:如果報告了fd後,沒有被處理,則下次poll還會再次報告該fd

  1. epoll:

epoll有EPOLLLT和EPOLLET兩種觸發模式,LT是默認的模式,ET是“高速”模式。

LT(水平觸發)模式下,只要這個文件描述符還有數據可讀,每次 epoll都會返回它的事件,提醒用戶程序去操作;

ET(邊緣觸發)模式下,對於每一個被通知的文件描述符,如可讀,則必須將該文件描述符一直讀到空,否則下次的 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的零拷貝

完全是在用戶態的,更多的是數據操作的優化

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

 DirectByteBuf通過直接在堆外分配內存的方式,避免了數據從堆內拷貝到堆外的過程

 通過組合ByteBuf類:即CompositeByteBuf,將多個ByteBuf合併爲一個邏輯上的ByteBuf, 而不需要進行數據拷貝

 通過各種包裝方法, 將 byte[]、ByteBuffer等包裝成一個ByteBuf對象,而不需要進行數據的拷貝

 通過slice方法, 將一個ByteBuf分解爲多個共享同一個存儲區域的ByteBuf, 避免了內存的拷貝,這在需要進行拆包操作時非常管用

 通過FileRegion包裝的FileChannel.tranferTo方法進行文件傳輸時, 可以直接將文件緩衝區的數據發送到目標Channel, 減少了通過循環write方式導致的內存拷貝。但是這種方式是需要得到操作系統的零拷貝的支持的,如果netty所運行的操作系統不支持零拷貝的特性,則netty仍然無法做到零拷貝。

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

  1. 設計優雅:適用於各種傳輸類型的統一 API 阻塞和非阻塞 Socket;基於靈活且可擴展的事件模型,可以清晰

地分離關注點;高度可定製的線程模型 - 單線程,一個或多個線程池.

  1. 使用方便:詳細記錄的 Javadoc,用戶指南和示例;沒有其他依賴項,JDK 5(Netty 3.x)或 6(Netty 4.x)就 足夠了。
  2. 高性能、吞吐量更高:延遲更低;減少資源消耗;最小化不必要的內存複製。
  3. 安全:完整的 SSL/TLS 和 StartTLS 支持。
  4. 社區活躍、不斷更新:社區活躍,版本迭代週期短,發現的 Bug 可以被及時修復,同時,更多的新功能會被

加入

Java原生NIO使用起碼麻煩需要自己管理線程,Netty對JDK自帶的NIO的api進行了封裝,提供了更簡單優雅的實現方式。由於netty5使用ForkJoinPool增加了複雜性,並且沒有顯示出明顯的性能優勢,所以netty5現在被廢棄掉了。

netty線程模型

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

  1. 單Reactor單線程:多路複用、事件分發和消息的處理都是在一個Reactor線程上完成。

優點:模型簡單,實現方便

缺點:性能差:單線程無法發揮多核性能,

可靠性差:線程意外終止或死循環,則整個模塊不可用

  1. 單Reactor多線程:

一個Reactor線程負責監聽服務端的連接請求和接收客戶端的TCP讀寫請求;NIO線程池負責消息的讀取、解碼、編碼和發送

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

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

  1. 主從 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來間接管理。

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可指定長度解決。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章