一、Linux
五大網絡IO
模型
我們在學些netty
我們需要了解下linux
的IO
模型,我們的java
的IO
模型也是在此基礎上搭建的。
1.1. 阻塞I/O
模型
常用的I/O模型就是阻塞I/O模型,缺省情形下,所有文件操
作都是阻塞的。我們在使用套接字接口是,在進程空間中調用recvform
,其系統調用直到數據包到達且被複制到應用進程的緩衝區或者發生錯誤才返回,期間一直會等待,進程從調用recvfrom
開始到它返回的這段時間都是被阻塞的。
1.1.1. 優點:
- 能夠及時的返回數據,無延遲;
- 程序簡單,進程掛起基本不會消耗
CPU
時間;
1.1.2. 缺點:
I/O
等待對性能影響較大;- 每個連接需要獨立的一個進程/線程處理,當併發請求量較大時爲了維護程序,內存、線程和CPU上下文切換開銷較大,因此較少在開發環境中使用。
1.2. 非阻塞I/O
模型
recvfrom
從應用層到內核的時候,如果該緩衝區沒有數據的話,
就直接返回一個EWOULDBLOCK
錯誤,一般都對非阻塞I/O模型進行輪詢檢査這個狀態,
看內核是不是有數據到來。
1.2.1. 具體過程:
第二階段(非阻塞):
-
進程向內核發起IO調用請求,內核接收到進程的I/O調用後準備處理並返回
EWOULDBLOCK
的信息給進程;此後每隔一段時間進程都會想內核發起詢問是否已處理完,即輪詢,此過程稱爲爲忙等待。 -
內核收到進程的系統調用請求後,此時的數據包並未準備好,此時內核會給進程發送error信息,直到磁盤中的數據加載至內核緩衝區。
第二階段(阻塞):
- 內核再將內核緩衝區中的數據複製到用戶空間中的進程緩衝區中(真正執行IO過程的階段,進程阻塞),直到數據複製完成。
- 內核返回成功數據處理完成的指令給進程;進程在收到指令後再對數據包進程處理。
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
之上):
-
進程向內核發起
select/poll/epoll
的系統調用,select
將該調用通知內核開始準備數據,而內核不會返回任何通知消息給進程,但進程可以繼續處理更多的網絡連接I/O
; -
內核收到進程的系統調用請求後,此時的數據包並未準備好,此時內核亦不會給進程發送任何消息,直到磁盤中的數據加載至內核緩衝區;而後通過
select()/poll()
函數將socket
的可讀條件返回給進程
第二階段(阻塞):
-
進程在收到
SIGIO
信號程序之後,進程向內核發起系統調用(recvfrom
); -
內核再將內核緩衝區中的數據複製到用戶空間中的進程緩衝區中(真正執行
IO
過程的階段),直到數據複製完成。 -
內核返回成功數據處理完成的指令給進程;進程在收到指令後再對數據包進程處理;處理完成後,此時的進程解除不可中斷睡眠態,執行下一個
I/O
操作。
1.3.2. 優點:
- I/O複用技術的優勢在於,只需要使用一個線程就可以管理多個socket,系統不需要建立新的進程或者線程,也不必維護這些線程和進程,所以它也是很大程度上減少了資源佔用。
- 另外I/O複用技術還可以同時監聽不同協議的套接字
1.3.3. 缺點:
- 在只處理連接數較小的場合,使用select的服務器不一定比多線程+阻塞I/O模型效率高,可能延遲更大,因爲單個連接處理需要2次系統調用,佔用時間會有增加。
1.3.4. select
、poll
和epoll
區別
Linux 提供了select
、poll
和epoll
幫助我們。一個線程可以對多個 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. 整體過程
第一階段(非阻塞):
-
進程使用
socket
進行信號驅動I/O
,建立SIGIO
信號處理函數,向內核發起系統調用,內核在未準備好數據報的情況下返回一個信號給進程,此時進程可以繼續做其他事情; -
內核將磁盤中的數據加載至內核緩衝區完成後,會遞交
SIGIO
信號給用戶空間的信號處理程序;
第二階段(阻塞):
-
進程在收到
SIGIO
信號程序之後,進程向內核發起系統調用(recvfrom
); -
內核再將內核緩衝區中的數據複製到用戶空間中的進程緩衝區中(真正執行
I/O
過程的階段),直到數據複製完成。 -
內核返回成功數據處理完成的指令給進程;進程在收到指令後再對數據包進程處理;處理完成後,此時的進程解除不可中斷睡眠態,執行下一個
I/O
操作。
1.4.2. 優點
- 很明顯,我們的線程並沒有在等待數據時被阻塞,可以提高資源的利用率
1.4.3. 缺點
- 信號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. 整體過程
第一階段(非阻塞):
-
進程向內核請求
air_read
(異步讀)的系統調用操作,會把套接字描述符、緩衝區指針、緩衝區大小和文件偏移一起發給內核,當內核收到後會返回“已收到”的消息給進程 -
內核將磁盤中的數據加載至內核緩衝區,直到數據報準備好;
第二階段(非阻塞):
-
內核開始複製數據,將準備好的數據報復制到進程內存空間,知道數據報復制完成
-
內核向進程遞交
aio_read
的返回指令信號,通知進程數據已複製到進程內存中;
1.5.2. 優點:
- 能充分利用
DMA
的特性,將I/O
操作與計算重疊,提高性能、資源利用率與併發能力
1.5.3 缺點:
-
在程序的實現上比較困難;
-
要實現真正的異步
I/O
,操作系統需要做大量的工作。目前Windows
下通過IOCP
實現了真正的異步I/O
。而在Linux
系統下,Linux 2.6
才引入,目前AIO
並不完善,因此在Linux
下實現高併發網絡編程時都是以 複用式I/O
模型爲主。
二、Java
的I/O
模型
2.1. 我們在Java
中使用的是BIO
、NIO
和AIO
三種:
BIO
:同步並阻塞,服務器實現模式爲一個連接一個線程,即客戶端有連接請求時服務器端就需要啓動一個線程並處理,如果這個連接不做任何事情會造成不必要的開銷,當然可以通過線程池機制改善。NIO
:同步非阻塞,服務器實現模式爲一個請求一個線程,即客戶端發送的連接請求都會註冊到多路複用器上,多路複用器輪詢到連接有IO請求時才啓動一個線程進行處理。AIO
:異步非阻塞,服務器實現模式爲一個有效請求一個線程,客戶端的I/O請求都是由OS先完成了再通知服務器應用去啓動線程進行處理
2.2. 三種模型的使用場景:
BIO
:適用於連接數目比較小且固定的架構,這種方式對服務器資源要求比較高,併發侷限於應用中,JDK1.4以前的唯一選擇,但程序直觀簡單易理解。NIO
:適用於連接數目多且連接比較短(輕操作)的架構,比如聊天服務器,併發侷限於應用中,編程比較複雜,JDK1.4
開始支持。AIO
:使用於連接數目多且連接比較長(重操作)的架構,比如文件服務器,充分調用OS參與併發操作,編程比較複雜,JDK7
開始支持。
三、從NIO
到Netty
一般可以根
據自己的需要來選擇合適的模式,一般來說,低負載、低併發的應用程序可以選擇同步阻
塞I/O以降低編程複雜度,但是對於高負載、高併發的網絡應用,需要使用NIO
的非阻塞
模式進行開發。
3.1. 爲啥不使用nio
-
NIO
的類庫和API
繁雜,使用麻煩,你需要熟練掌握Selector
、ServerSocketChannel
、SocketChannel
、ByteBuffer
等 -
需要具備其他的額外技能做鋪墊,例如熟悉Java多線程編程。這是因爲
NIO
編程涉 及到Reactor
模式,你必須對多線程和網路編程非常熟悉,才能編寫出高質量的NIO
程序。 -
可靠性能力補齊,工作量和難度都非常大。例如客戶端面臨斷連重連、網絡閃斷、 半包讀寫、失敗緩存、網絡擁塞和異常碼流的處理等問題,
NIO
編程的特點是功能開發相 對容易,但是可靠性能力補齊的工作量和難度都非常大。 -
JDK NIO
的``BUG,例如臭名昭著的
epoll bug,它會導致
Selector空輪詢,最終導 致CPU 100%。官方聲稱在
JDKL6版本的
update 18修復了該問題,但是直到
JDK1.7`版本 該問題仍舊存在,只不過該BUG發生概率降低了一些而已,它並沒有被根本解決。
3.2. 選擇netty
的原因:
API
使用簡單,開發門檻低;- 功能強大,預置了多種編解碼功能,支持多種主流協議
- 定製能力強,可以通過
ChanneJHandler
對通信框架進行靈活地擴展 - 性能高,通過與其他業界主流的
NIO
框架對比,Netty的綜合性能最優 - 成熟、穩定,Netty修復了已經發現的所有
JDK NIO BUG
,業務開發人員不需要 再爲NIO
的BUG
而煩惱; 社區活躍,版本迭代週期短,發現的BUG可以被及時修復,同時,更多的新功 能會加入; - 經歷了大規模的商業應用考驗,質量得到驗證,在互聯網、大數據、網絡遊戲、 企業應用、電信軟件等衆多行業得到成功商用,證明了它已經完全能夠滿足不同 行業的商業應用了。
四、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
,它用於對網絡事件進行讀寫操作。一般來說只需要關注channelRead
和exceptionCaught
。
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/O
的ClientHandler
,裏面有三個重要的方法,channelRead0
、channelActive
和exceptionCaught
。
當客戶端
和服務端TCP
鏈路建立成功之後,Netty
的NIO
線程會調用channelActive
方法,發送Hello world
指令給服務端,調用ChannelHandlerContext
的writeAndFlush
方法將請求消息發送給服務器端。
接着當服務器端返回應答信息的時候,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
權威指南