Netty學習基礎:BIO、NIO、AIO
其實我的重點呢,是來和大家一起學習接下來的Netty篇。
然而嘞,這個Netty又不太合適直接講,為啥呢,我們學習一門技術必須知道這門技術的由來的初衷是啥,對吧。
先來給大家簡單的介紹一下Netty是什么
Netty是一個提供異步事件驅動的網絡應用程序框架,用以快速開發高性能、高可靠的網絡服務器和客戶端程序。
Netty簡化了網絡程序的開發,屬于BIO、NIO、AIO的演變中的產物,屬于一種NIO框架。
在我們平時使用的很多中間件中,很多底層通信都是采用的Netty,比如rocketmq、dubbo,這些我們最常見的底層通信都是用的netty,足以可見這個的性能是多么的優秀了。
ok,接下來再來理解一下同步、異步、阻塞、非阻塞這四個概念。
從簡單的開始,我們以經典的讀取文件的模型舉例。(對操作系統而言,所有的輸入輸出設備都被抽象成文件。)
在發起讀取文件的請求時,應用層會調用系統內核的I/O接口。
阻塞和非阻塞
如果應用層調用的是阻塞型I/O,那么在調用之后,應用層即刻被掛起,一處于等待數據返回的狀態,直到系統內核從磁盤讀取完數據并返回給應用層,應用層才用獲得的數據進行接下來的其他操作。
如果應用層調用的是非阻塞I/O,那么調用后,系統內核會立即返回(雖然還沒有文件內容的數據),應用層并不會被掛起,它可以做其他任意它想做的操作。(至于文件內容數據如何返回給應用層,這已經超出了阻塞和非阻塞的辨別范疇。)
這便是(脫離同步和異步來說之后)阻塞和非阻塞的區別。總結來說,是否是阻塞還是非阻塞,關注的是接口調用(發出請求)后等待數據返回時的狀態。被掛起無法執行其他操作的則是阻塞型的,可以被立即「抽離」去完成其他「任務」的則是非阻塞型的。
同步和異步
阻塞和非阻塞解決了應用層等待數據返回時的狀態問題,那系統內核獲取到的數據到底如何返回給應用層呢?這里不同類型的操作便體現的是同步和異步的區別。
對于同步型的調用,應用層需要自己去向系統內核問詢,如果數據還未讀取完畢,那此時讀取文件的任務還未完成,應用層根據其阻塞和非阻塞的劃分,或掛起或去做其他事情(所以同步和異步并不決定其等待數據返回時的狀態);如果數據已經讀取完畢,那此時系統內核將數據返回給應用層,應用層即可以用取得的數據做其他相關的事情。
而對于異步型的調用,應用層無需主動向系統內核問詢,在系統內核讀取完文件數據之后,會主動通知應用層數據已經讀取完畢,此時應用層即可以接收系統內核返回過來的數據,再做其他事情。
這便是(脫離阻塞和非阻塞來說之后)同步和異步的區別。也就是說,是否是同步還是異步,關注的是任務完成時消息通知的方式。由調用方盲目主動問詢的方式是同步調用,由被調用方主動通知調用方任務已完成的方式是異步調用。
上面這幾個概念大家一定要搞懂,這是基礎,必須好好理解上面這些,才能真正理解netty的出處,這也是面試常被問到的點之一。
總結一下
阻塞和非阻塞,關注的是發起請求之后等待數據返回時的狀態,被掛起無法執行其他操作的是阻塞型的,可以立即去進行其他作業的是非阻塞型的。
同步和異步,關注的是任務完成時的消息通知的方式,由調用方主動去詢問的方式屬于同步調用,而被調用方主動通知調用方該任務已完成的方式屬于異步調用。
這個在網上最常見的一個例子就是燒水的例子了,我也繼續給大家啰嗦一下咯。
老王燒水,老王把水放在爐子上,在這里干等著,啥也沒有去做,并且需要隨時看著水是否開了,這叫阻塞同步,阻塞是因為老王啥也不能去做,同步是因為水開他得自己看著。
老王后來學精了,不在這里傻等著了,把水放在爐子上之后,然后就去開了一把緊張又刺激的lol手游,這叫非阻塞同步,非阻塞是因為老王在等水期間自己打游戲了,同步是因為水開他還是得自己看著。
后來,老王覺得自己看著水太麻煩了,于是買了個升級版的水壺,牛了啊,這個水壺把水煮開了之后,會吹哨,哎。
老王不需要每隔幾分鐘就去看一眼水是否開了,只需要聽這個哨聲即可,做水期間可以打游戲,并且水開了還會主動通知老王,這就是異步非阻塞,非阻塞就是因為老王可以去玩游戲,異步就是水壺的那個哨子。
這下大家應該很好理解了吧!
接下來繼續看BIO、NIO、AIO
Socket 網絡通信過程簡單來說分為下面 4 步:
- 建立服務端并且監聽客戶端請求。
- 客戶端請求,服務端和客戶端建立連接。
- 兩端之間可以傳遞數據。
- 關閉資源。
傳統的阻塞式通信BIO流程
BIO就是屬于最傳統的一種阻塞同步的通信方式,也是屬于最簡單的一種,使用起來比較方便,但是處理并發能力低,通信比較耗時。
服務器會通過一個線程負責監聽客戶端請求和為每一個客戶端創建一個新的線程進行鏈路的處理,屬于一種典型的請求應答模式,若客戶端數量增加,則需要頻繁的創建和銷毀線程,會給服務器增加很大的壓力。
服務器提供IP地址和監聽的端口,客戶端通過TCP的三次握手和服務器建立連接通信,連接成功之后,雙方進行通過,之后通過四次揮手進行斷開連接。
即使用線程池的方式來改進新增加線程,這也是屬于一種偽異步IO,這樣實現能夠為少數的客戶端提供服務,如果客戶端并發量足夠多,還是會因為線程池滿導致OOM的問題。
給大家看一個簡單的Demon
public class SocketServer {
public static void main(String[] args) throws IOException {
SocketServer socketServer = new SocketServer();
socketServer.start(9000);
}
public void start(int port) {
//1.創建 ServerSocket 對象并且綁定一個端口
try (ServerSocket server = new ServerSocket(port);) {
System.out.println("server start");
Socket socket;
//2.通過 accept()方法監聽客戶端請求, 這個方法會一直阻塞到有一個連接建立
while ((socket = server.accept()) != null) {
System.out.println("client connected");
try (ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream())) {
//3.通過輸入流讀取客戶端發送的請求信息
String message = (String) objectInputStream.readObject();
System.out.println("server receive message:" + message);
//4.通過輸出流向客戶端發送響應信息
objectOutputStream.writeObject(message);
objectOutputStream.flush();
} catch (IOException | ClassNotFoundException e) {
System.out.println("occur exception:");
}
}
} catch (IOException e) {
System.out.println("occur IOException:");
}
}
}
這是服務端的代碼:
public class Client {
public Object send(String message, String host, int port) {
//1. 創建Socket對象并且指定服務器的地址和端口號
try (Socket socket = new Socket(host, port)) {
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
//2.通過輸出流向服務器端發送請求信息
objectOutputStream.writeObject(message);
//3.通過輸入流獲取服務器響應的信息
ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
return objectInputStream.readObject();
} catch (ClassNotFoundException | IOException e) {
System.out.println("occur exception:");
}
return null;
}
public static void main(String[] args) {
Client helloClient = new Client();
helloClient.send("content from client", "127.0.0.1", 9000);
System.out.println("發送數據成功");
}
}
這是客戶端的代碼,我們接下來先運行服務器,再運行客戶端,看效果。
。
服務器啟動之后,便會一直阻塞在這里,等待客戶端的連接處理。
接著我們啟動客戶端,然后看到發送數據成功,此時我們再切換到服務器的控制臺,看下效果。
我們也可以通過命令行直接執行telnet localhost 9000去連接服務端,效果如下:
從上面例子看出的問題
我們看到服務器和客戶端成功的進行通信了,也就是這段服務器的代碼只能同時為一個客戶端服務,當然有改進方法,我們監聽到連接之后,就立刻new Thread().start()創建一個線程用于這個客戶端接下來的處理。
這也就意味著,每一個客戶端都要建立一個線程為其處理,如果客戶端數量很多,或者說客戶端處理很慢,那就很糟糕了。
我們從線程文章中也介紹過線程是一個很寶貴的資源,我們需要合理的利用這些資源,需要根據機器的性能去合理的控制線程的數量。
即使線程池可以優化上面的例子,讓線程創建和銷毀的成本降低,我們也可以執行線程池的最大數量,控制線程資源的使用,但是,即使如何改進,我們并沒有從根本上解決這個問題,根本上還是屬于BIO,也就是同步阻塞IO的模式。
NIO
同步非阻塞模型,在JDK1.4中引入了NIO的框架,NIO 中的 N 可以理解為 Non-blocking,NIO是面向緩沖Buffer的,基于通道Channel的操作。
NIO提供了和傳統BIO模型中的ServerSocket和Socket相對應的ServerSocketChannel和SocketChannel兩種不同的套接字通道,對應服務端和客戶端。
兩種通道都支持阻塞和非阻塞的模式。
阻塞模式一般不會被使用,既然使用了阻塞,那就意味著使用起來就像上面的BIO一樣了,性能和可靠性都不是很好。
非阻塞模式,對于高負載和高并發的網絡應用是很友好的,后續我們要說的Netty就是基于這個改進的。
NIO 相對于BIO來說一大進步。客戶端和服務器之間通過Channel通信。NIO可以在Channel進行讀寫操作。這些Channel都會被注冊在Selector多路復用器上。Selector通過一個線程不停的輪詢這些Channel。找出已經準備就緒的Channel執行IO操作。
NIO 通過一個線程輪詢,實現千萬個客戶端的請求,這就是非阻塞NIO的特點。
NIO核心組件
Channel:和流不同,通道是雙向的。NIO可以通過Channel進行數據的讀,寫和同時讀寫操作。通道分為兩大類:一類是網絡讀寫(SelectableChannel),一類是用于文件操作(FileChannel),我們使用的SocketChannel和ServerSocketChannel都是SelectableChannel的子類。
Buffer:它是NIO與BIO的一個重要區別。BIO是將數據直接寫入或讀取到Stream對象中。而NIO的數據操作都是在緩沖區中進行的。緩沖區實際上是一個數組。
Selector和Selection Key:多路復用器提供選擇已經就緒的任務的能力。就是Selector會不斷地輪詢注冊在其上的通道(Channel),如果某個通道處于就緒狀態,會被Selector輪詢出來,然后通過SelectionKey可以取得就緒的Channel集合,從而進行后續的IO操作。服務器端只要提供一個線程負責Selector的輪詢,就可以接入成千上萬個客戶端。
接下來我們看使用的例子:
public class NioServer {
static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9000));
//設置serverSocketChannel為非阻塞
serverSocketChannel.configureBlocking(false);
System.out.println("服務器啟動成功");
while (true){
//非阻塞模式的accept不會阻塞,否則會阻塞
//NIO的非阻塞是由操作系統實現的,底層調用了Linux內核的accept函數
SocketChannel socketChannel = serverSocketChannel.accept();
if(socketChannel != null){ //此時有客戶端連接
System.out.println("有客戶端連接");
socketChannel.configureBlocking(false);
channelList.add(socketChannel);
}
//遍歷
Iterator<SocketChannel> iterator = channelList.iterator();
while (iterator.hasNext()){
SocketChannel channel = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
int read = channel.read(byteBuffer);
if(read > 0){
System.out.println("接收到消息:" + new String(byteBuffer.array()));
}else if(read == -1){
iterator.remove();
System.out.println("客戶端斷開連接");
}
}
}
}
}
這里我們只寫了服務端的代碼,客戶端就通過telnet來模擬就行了。
我們用debug的模式看下服務端。
啟動成功之后,發現NIO模式下竟然沒有在accept函數這里阻塞,而是直接執行過去了。
NIO優點
NIO最大的優點,就是引入了IO多路復用機制,使得一個服務器可以同時為大量的客戶端提供服務的同時,效率也不會低,而這個IO多路復用這里,經常遇到的一個面試題就是select、poll、epoll的區別,這個我會單獨開一篇給大家說清楚,這一篇放不下了。
NIO存在的問題
NIO跨平臺和兼容性問題
使用NIO的時候需要考慮Linux平臺和Windows平臺的兼容性問題,如果該程序運行在多個平臺,則需要考慮測試多個平臺。
NIO2看起來很理想,但是NIO2只支持Jdk1.7+,若你的程序在Java1.6上運行,則無法使用NIO2。另外,Java7的NIO2中沒有提供DatagramSocket的支持,所以NIO2只支持TCP程序,不支持UDP程序。
NIO對緩沖區的聚合和分散操作可能會導致內存泄露
很多Channel的實現支持Gather和Scatter。這個功能允許從從多個ByteBuffer中讀入或寫入,這樣做可以有更好的性能。
例如,你可能希望header在一個ByteBuffer中,而body在另外的ByteBuffer中。
下圖顯示的是Scatter(分散),將ScatteringByteBuffer中的數據分散讀取到多個ByteBuffer中:
下圖顯示的是Gather(聚合),將多個ByteBuffer的數據寫入到GatheringByteChannel:
可惜Gather/Scatter功能會導致內存泄露,知道Java7才解決內存泄露問題。使用這個功能必須小心編碼和Java版本。
Squashing the famous epoll bug(壓碎著名的epoll bug)
著名的epoll-bug也可能會導致無效的狀態選擇和100%的CPU利用率。要解決epoll-bug的唯一方法是回收舊的選擇器,將先前注冊的通道實例轉移到新創建的選擇器上。
不是十分的清楚這里,感興趣的可以去更深的了解下這里。
還有一個很真實貼切的問題,就是這個對于開發者來說太不友好了,開發成本和維護成本都比較高。
AIO
AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改進版 NIO 2,它是異步非阻塞的IO模型。
異步 IO 是基于事件和回調機制實現的,也就是應用操作之后會直接返回,不會堵塞在那里,當后臺處理完成,操作系統會通知相應的線程進行后續的操作。
AIO 是異步IO的縮寫,雖然 NIO 在網絡操作中,提供了非阻塞的方法,但是 NIO 的 IO 行為還是同步的。對于 NIO 來說,我們的業務線程是在 IO 操作準備好時,得到通知,接著就由這個線程自行進行 IO 操作,IO操作本身是同步的。
AIO 并沒有采用NIO的多路復用器,而是使用異步通道的概念。其read,write方法的返回類型都是Future對象。
而Future模型是異步的,其核心思想是:去主函數等待時間。AIO模型中通過AsynchronousSocketChannel和AsynchronousServerSocketChannel完成套接字通道的實現。非阻塞,異步。