Netty應用之入門實例

一、Linux五大網絡IO 模型

我們在學些netty我們需要了解下linuxIO模型,我們的javaIO模型也是在此基礎上搭建的。

1.1. 阻塞I/O模型

常用的I/O模型就是阻塞I/O模型,缺省情形下,所有文件操
作都是阻塞的。我們在使用套接字接口是,在進程空間中調用recvform,其系統調用直到數據包到達且被複制到應用進程的緩衝區或者發生錯誤才返回,期間一直會等待,進程從調用recvfrom開始到它返回的這段時間都是被阻塞的。

1.1.1. 優點:
  1. 能夠及時的返回數據,無延遲;
  2. 程序簡單,進程掛起基本不會消耗CPU時間;
1.1.2. 缺點:
  1. I/O等待對性能影響較大;
  2. 每個連接需要獨立的一個進程/線程處理,當併發請求量較大時爲了維護程序,內存、線程和CPU上下文切換開銷較大,因此較少在開發環境中使用。
1.2. 非阻塞I/O模型

recvfrom從應用層到內核的時候,如果該緩衝區沒有數據的話,
就直接返回一個EWOULDBLOCK錯誤,一般都對非阻塞I/O模型進行輪詢檢査這個狀態,
看內核是不是有數據到來。

1.2.1. 具體過程:

第二階段(非阻塞):

  1. 進程向內核發起IO調用請求,內核接收到進程的I/O調用後準備處理並返回EWOULDBLOCK的信息給進程;此後每隔一段時間進程都會想內核發起詢問是否已處理完,即輪詢,此過程稱爲爲忙等待。

  2. 內核收到進程的系統調用請求後,此時的數據包並未準備好,此時內核會給進程發送error信息,直到磁盤中的數據加載至內核緩衝區。

第二階段(阻塞):

  1. 內核再將內核緩衝區中的數據複製到用戶空間中的進程緩衝區中(真正執行IO過程的階段,進程阻塞),直到數據複製完成。
  2. 內核返回成功數據處理完成的指令給進程;進程在收到指令後再對數據包進程處理。
1.2.2. 優點:

進程在等待當前任務完成時,可以同時執行其他任務;進程不會被阻塞在內核等待數據過程,每次發起的I/O請求會立即返回,具有較好的實時性;

1.2. 3. 缺點:

不斷的輪詢將佔用大量的CPU時間,系統資源利用率大打折扣,影響性能,整體數據的吞吐量下降;該模型不適用web服務器;

1.3. I/O複用模型

I/O複用模型也叫作事件驅動I/O模型。這個模型中,每一個網絡連接,都是非阻塞的;進程會調用select()poll()epoll()發起系統調用請求,select()poll()epoll()相當於內核代理,進程所有請求都會先請求這幾個函數中的某一個;這個時候一個進程可以同時處理多個網絡連接I/O,這個幾個函數會不斷輪詢負責的所有的socket,當某一個socket有數據報準備好了,就會返回可讀信號通知給進程。

用戶進程調用select/poll/epoll後,進程實際上是被阻塞的,同時,內核會監視所有select/poll/epoll所負責的socket,當其中任意一個數據準備好了,就會通知進程。只不過進程是阻塞在select/poll/epoll之上,而不是被內核準備數據過程中阻塞。此時,進程再發起recvfrom系統調用,將數據中內核緩衝區拷貝到內核進程,這個過程是阻塞的。

雖然select/poll/epoll可以使得進程看起來是非阻塞的,因爲進程可以處理多個連接,但是最多隻有1024個網絡連接的I/O;本質上進程還是阻塞的,只不過它可以處理更多的網絡連接的I/O而已。

1.3.1. 從圖上我們可以看到:

第一階段(阻塞在select/poll之上):

  1. 進程向內核發起select/poll/epoll的系統調用,select將該調用通知內核開始準備數據,而內核不會返回任何通知消息給進程,但進程可以繼續處理更多的網絡連接I/O

  2. 內核收到進程的系統調用請求後,此時的數據包並未準備好,此時內核亦不會給進程發送任何消息,直到磁盤中的數據加載至內核緩衝區;而後通過select()/poll()函數將socket的可讀條件返回給進程

第二階段(阻塞):

  1. 進程在收到SIGIO信號程序之後,進程向內核發起系統調用(recvfrom);

  2. 內核再將內核緩衝區中的數據複製到用戶空間中的進程緩衝區中(真正執行IO過程的階段),直到數據複製完成。

  3. 內核返回成功數據處理完成的指令給進程;進程在收到指令後再對數據包進程處理;處理完成後,此時的進程解除不可中斷睡眠態,執行下一個I/O操作。

1.3.2. 優點:
  1. I/O複用技術的優勢在於,只需要使用一個線程就可以管理多個socket,系統不需要建立新的進程或者線程,也不必維護這些線程和進程,所以它也是很大程度上減少了資源佔用。
  2. 另外I/O複用技術還可以同時監聽不同協議的套接字
1.3.3. 缺點:
  1. 在只處理連接數較小的場合,使用select的服務器不一定比多線程+阻塞I/O模型效率高,可能延遲更大,因爲單個連接處理需要2次系統調用,佔用時間會有增加。
1.3.4. selectpollepoll區別

Linux 提供了selectpollepoll幫助我們。一個線程可以對多個 IO 端口進行監聽,當 socket 有讀寫事件時分發到具體的線程進行處理。

一個進程打開連接數 IO 效率 消息傳遞方式
select 32 位機器 1024 個,64 位 2048 個 IO 效率低 內核需要將消息傳遞到用戶空間,都需要內核拷貝動作
poll 無限制,原因基於鏈表存儲 IO 效率低 內核需要將消息傳遞到用戶空間,都需要內核拷貝動作
epoll 有上限,但很大,2G 內存 20W 左右 只有活躍的 socket 才調用 callback,IO 效率高 通過內核與用戶空間共享一塊內存來實現
1.4. 信號量驅動I/O模型

信號驅動式I/O是指進程預先告知內核,使得某個文件描述符上發生了變化時,內核使用信號通知該進程。

在信號驅動式I/O模型,進程使用socket進行信號驅動I/O,並建立一個SIGIO信號處理函數,當進程通過該信號處理函數向內核發起I/O調用時,內核並沒有準備好數據報,而是返回一個信號給進程,此時進程可以繼續發起其他I/O調用。也就是說,在第一階段內核準備數據的過程中,進程並不會被阻塞,會繼續執行。當數據報準備好之後,內核會遞交SIGIO信號,通知用戶空間的信號處理程序,數據已準備好;此時進程會發起recvfrom的系統調用,這一個階段與阻塞式I/O無異。也就是說,在第二階段內核複製數據到用戶空間的過程中,進程同樣是被阻塞的。

1.4.1. 整體過程

第一階段(非阻塞):

  1. 進程使用socket進行信號驅動I/O,建立SIGIO信號處理函數,向內核發起系統調用,內核在未準備好數據報的情況下返回一個信號給進程,此時進程可以繼續做其他事情;

  2. 內核將磁盤中的數據加載至內核緩衝區完成後,會遞交SIGIO信號給用戶空間的信號處理程序;

第二階段(阻塞):

  1. 進程在收到SIGIO信號程序之後,進程向內核發起系統調用(recvfrom);

  2. 內核再將內核緩衝區中的數據複製到用戶空間中的進程緩衝區中(真正執行I/O過程的階段),直到數據複製完成。

  3. 內核返回成功數據處理完成的指令給進程;進程在收到指令後再對數據包進程處理;處理完成後,此時的進程解除不可中斷睡眠態,執行下一個I/O操作。

1.4.2. 優點
  1. 很明顯,我們的線程並沒有在等待數據時被阻塞,可以提高資源的利用率
1.4.3. 缺點
  1. 信號I/O在大量IO操作時可能會因爲信號隊列溢出導致沒法通知——這個是一個非常嚴重的問題。
1.5. 異步I/O模型

我們在上面瞭解的4種I/O模型都可以劃分爲同步I/O方法,我們可以注意到,在數據從內核緩衝區複製到用戶緩衝區時,都需要進程顯示調用recvfrom,並且這個複製過程是阻塞的。
也就是說真正I/O過程(這裏的I/O有點狹義,指的是內核緩衝區到用戶緩衝區)是同步阻塞的,不同的是各個I/O模型在數據報準備好之前的動作不一樣。

異步I/O可以說是在信號驅動式I/O模型上改進而來。

在異步I/O模型中,進程會向內核請求air_read(異步讀)的系統調用操作,會把套接字描述符、緩衝區指針、緩衝區大小和文件偏移一起發給內核,當內核收到後會返回“已收到”的消息給進程,此時進程可以繼續處理其他I/O任務。也就是說,在第一階段內核準備數據的過程中,進程並不會被阻塞,會繼續執行。第二階段,當數據報準備好之後,內核會負責將數據報復制到用戶進程緩衝區,這個過程也是由內核完成,進程不會被阻塞。複製完成後,內核向進程遞交aio_read的指定信號,進程在收到信號後進行處理並處理數據報向外發送。

在進程發起I/O調用到收到結果的過程,進程都是非阻塞的。

1.5.1. 整體過程

第一階段(非阻塞):

  1. 進程向內核請求air_read(異步讀)的系統調用操作,會把套接字描述符、緩衝區指針、緩衝區大小和文件偏移一起發給內核,當內核收到後會返回“已收到”的消息給進程

  2. 內核將磁盤中的數據加載至內核緩衝區,直到數據報準備好;

第二階段(非阻塞):

  1. 內核開始複製數據,將準備好的數據報復制到進程內存空間,知道數據報復制完成

  2. 內核向進程遞交aio_read的返回指令信號,通知進程數據已複製到進程內存中;

1.5.2. 優點:
  1. 能充分利用DMA的特性,將I/O操作與計算重疊,提高性能、資源利用率與併發能力
1.5.3 缺點:
  1. 在程序的實現上比較困難;

  2. 要實現真正的異步I/O,操作系統需要做大量的工作。目前 Windows下通過 IOCP 實現了真正的異步 I/O。而在Linux系統下,Linux 2.6才引入,目前 AIO並不完善,因此在Linux下實現高併發網絡編程時都是以 複用式I/O模型爲主。

二、JavaI/O模型

2.1. 我們在Java中使用的是BIONIOAIO三種:
  • BIO:同步並阻塞,服務器實現模式爲一個連接一個線程,即客戶端有連接請求時服務器端就需要啓動一個線程並處理,如果這個連接不做任何事情會造成不必要的開銷,當然可以通過線程池機制改善。
  • NIO:同步非阻塞,服務器實現模式爲一個請求一個線程,即客戶端發送的連接請求都會註冊到多路複用器上,多路複用器輪詢到連接有IO請求時才啓動一個線程進行處理。
  • AIO:異步非阻塞,服務器實現模式爲一個有效請求一個線程,客戶端的I/O請求都是由OS先完成了再通知服務器應用去啓動線程進行處理
2.2. 三種模型的使用場景:
  • BIO:適用於連接數目比較小且固定的架構,這種方式對服務器資源要求比較高,併發侷限於應用中,JDK1.4以前的唯一選擇,但程序直觀簡單易理解。
  • NIO:適用於連接數目多且連接比較短(輕操作)的架構,比如聊天服務器,併發侷限於應用中,編程比較複雜,JDK1.4開始支持。
  • AIO:使用於連接數目多且連接比較長(重操作)的架構,比如文件服務器,充分調用OS參與併發操作,編程比較複雜,JDK7開始支持。

三、從NIONetty

一般可以根
據自己的需要來選擇合適的模式,一般來說,低負載、低併發的應用程序可以選擇同步阻
塞I/O以降低編程複雜度,但是對於高負載、高併發的網絡應用,需要使用NIO的非阻塞
模式進行開發。

3.1. 爲啥不使用nio
  1. NIO的類庫和API繁雜,使用麻煩,你需要熟練掌握SelectorServerSocketChannel
、SocketChannelByteBuffer

  2. 需要具備其他的額外技能做鋪墊,例如熟悉Java多線程編程。這是因爲NIO編程涉
及到Reactor模式,你必須對多線程和網路編程非常熟悉,才能編寫出高質量的NIO程序。

  3. 可靠性能力補齊,工作量和難度都非常大。例如客戶端面臨斷連重連、網絡閃斷、
半包讀寫、失敗緩存、網絡擁塞和異常碼流的處理等問題,NIO編程的特點是功能開發相
對容易,但是可靠性能力補齊的工作量和難度都非常大。

  4. JDK NIO的``BUG,例如臭名昭著的epoll bug,它會導致Selector空輪詢,最終導
致CPU 100%。官方聲稱在JDKL6版本的update 18修復了該問題,但是直到JDK1.7`版本
該問題仍舊存在,只不過該BUG發生概率降低了一些而已,它並沒有被根本解決。

3.2. 選擇netty的原因:
  1. API使用簡單,開發門檻低;
  2. 功能強大,預置了多種編解碼功能,支持多種主流協議
  3. 定製能力強,可以通過ChanneJHandler對通信框架進行靈活地擴展
  4. 性能高,通過與其他業界主流的NIO框架對比,Netty的綜合性能最優
  5. 成熟、穩定,Netty修復了已經發現的所有JDK NIO BUG,業務開發人員不需要
再爲NIOBUG而煩惱;
社區活躍,版本迭代週期短,發現的BUG可以被及時修復,同時,更多的新功
能會加入;
  6. 經歷了大規模的商業應用考驗,質量得到驗證,在互聯網、大數據、網絡遊戲、
企業應用、電信軟件等衆多行業得到成功商用,證明了它已經完全能夠滿足不同
行業的商業應用了。

四、netty入門demo

4.1. 服務器端
public class DemoServer {

	private final int port;

	public DemoServer(int port) {
		this.port = port;
	}

	public void start() throws InterruptedException {
		NioEventLoopGroup boss = new NioEventLoopGroup();
		NioEventLoopGroup worker = new NioEventLoopGroup();
		try {
			ServerBootstrap bootstrap = new ServerBootstrap();
			bootstrap.group(boss, worker)
					.channel(NioServerSocketChannel.class)
					.childHandler(new ChannelInitializer<SocketChannel>() {

						@Override
						protected void initChannel(SocketChannel socketChannel) throws Exception {
							socketChannel.pipeline().addLast(new ServerHandler());
						}
					});
			System.out.println(">>> 啓動服務器成功......");
			ChannelFuture future = bootstrap.bind(port).sync();
			future.channel().closeFuture().sync();

		} finally {
			worker.shutdownGracefully().sync();
			boss.shutdownGracefully().sync();
		}
	}

	public static void main(String[] args) throws InterruptedException {
		new DemoServer(9999).start();
	}
}
上面關鍵位置

NioEventLoopGroup 是處理I/O操作的線程池,其中boss主要用於處理客戶端連接,worker用於處理客戶端的數據讀寫工作。

ServerBootstrap是啓動NIO服務端的輔助啓動類,目的是爲了降低服務端的開發複雜度。

group會將兩個NIO線程組當做入參傳遞到ServerBootstrap中。

channel指定所使用的NIO傳輸 Channel

ServerHandler用戶處理I/O事件處理,例如日誌、編碼和解碼等。

bind用於綁定監聽端口,然後調用同步阻塞方法等待綁定完成,完成之後會返回一個ChannelFuture對象,主要用戶異步通知回調。

closeFuture等待服務端鏈路關閉之後主線程才退出。

shutdownGracefully將會釋放跟其關聯的資源。

public class ServerHandler extends ChannelInboundHandlerAdapter {

	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		ByteBuf byteBuf = (ByteBuf) msg;
		System.out.println("服務器接收的消息:" + byteBuf.toString(CharsetUtil.UTF_8));
		ctx.write(byteBuf);
	}

	@Override
	public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
		ctx.writeAndFlush(Unpooled.EMPTY_BUFFER)
				.addListener(ChannelFutureListener.CLOSE);
	}

	@Override
	public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
		cause.printStackTrace();
		ctx.close();
	}
}

ServerHandler繼承ChannelInboundHandlerAdapter,它用於對網絡事件進行讀寫操作。一般來說只需要關注channelReadexceptionCaught

channelRead:接受消息,做處理

channelReadComplete:channelRead()執行完成後,關閉channel連接。

exceptionCaught:發生異常之後,打印堆棧,關閉通道。

4.2. 客戶端
public class DemoClient {

	private final String host;

	private final int post;

	public DemoClient(String host, int post) {
		this.host = host;
		this.post = post;
	}

	public void start() throws InterruptedException {
		EventLoopGroup group = new NioEventLoopGroup(); // 1 第一步
		try {
			Bootstrap bootstrap = new Bootstrap();      // 2 第二部
			bootstrap.group(group)
					.channel(NioSocketChannel.class)
					.handler(new ChannelInitializer<SocketChannel>() {  // 第三步

						@Override
						protected void initChannel(SocketChannel socketChannel) throws Exception {
							socketChannel.pipeline().addLast(new ClientHandler());
						}
					});
			ChannelFuture future = bootstrap.connect(host, post).sync(); // 第四步
			future.channel().closeFuture().sync();   // 第五步
		} finally {
			group.shutdownGracefully().sync();      // 第六步
		}
	}

	public static void main(String[] args) throws InterruptedException {
		new DemoClient("127.0.0.1", 9999).start();
	}
}
客戶端創建過程:

第一步,首先創建客戶端處理I/O讀寫的NioEventLoop
Group線程組,然後繼續創建客戶端輔助啓動類Bootstrap,隨後需要對其進行配置。

第二部,將Channel需要設置爲NioSocketChanneL然後爲其添加handler

第三步,此處
創建匿名內部類,實現initChannel方法,其作用是當創建NioSocketChannel成功之後,在初始化它的時候將它的ChannelHandler設置到ChannelPipeline中,用於處理
網絡I/O事件。

第四步,客戶端啓動輔助類設置完成之後,調用connect方法發起異步連接,然後調用同步方法等待連接成功。

第五步,當客戶端連接關閉之後,客戶端主函數退出,在退出之前,釋放NIO線程組的相關資源。

public class ClientHandler extends SimpleChannelInboundHandler<ByteBuf> {

	@Override
	protected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {
		System.out.println("客戶端收到消息:" + byteBuf.toString());
	}

	@Override
	public void channelActive(ChannelHandlerContext ctx) throws Exception {
		ctx.writeAndFlush(Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8));
	}

	@Override
	public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
		cause.printStackTrace();
		ctx.close();
	}
}

具體處理I/OClientHandler,裏面有三個重要的方法,channelRead0channelActiveexceptionCaught

當客戶端
和服務端TCP鏈路建立成功之後,NettyNIO線程會調用channelActive方法,發送Hello world指令給服務端,調用ChannelHandlerContextwriteAndFlush方法將請求消息發送給服務器端。

接着當服務器端返回應答信息的時候,channelRead0將被調用。如果發生異常,exceptionCaught將被調用。

4.3. 結果

五、相關參考:

Linux系統I/O模型詳解 https://blog.51cto.com/ccschan/2357207

Linux下的I/O模型以及各自的優缺點 https://www.linuxidc.com/Linux/2017-09/146682.htm

netty權威指南

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章