1. 程式人生 > 其它 >從IO 到BIO/NIO/AIO 淺析

從IO 到BIO/NIO/AIO 淺析

背景

最近在閱讀rocketMQ的原始碼中,涉及到多服務之間通訊,而rocketMQ的通訊中用了Netty框架,Netty框架又是基於NIO實現的。雖然NIO的日常中提到的很多,但是一直處於朦朧的狀態,本著知其然而知其所以然的態度,在這裡對IO進行一波梳理。

IO

對於IO的概念,這裡不多作介紹。這個小節從java的Socket和ServiceSocker為例,以demo的形式的對日常的通訊IO的工作原理作一個簡單的認識。 Socket定義:套接字(socket)是一個抽象層,應用程式可以通過它傳送或接收資料,可對其進行像對檔案一樣的開啟、讀寫和關閉等操作。套接字允許應用程式將I/O插入到網路中,並與網路中的其他應用程式進行通訊。網路套接字是IP地址與埠的組合。 可以理解為兩臺機器或程序間進行網路通訊的端點,這個端點包含IP地址和埠號。 Socket和ServerSocket區別就如其名字一樣,簡單地說ServerSocket作用在服務端,用以監聽客戶端的請求。Socket作用在客戶端和服務端,用以傳送接收訊息。但是就像上面說的,它們都要包含一個IP地址和埠號。 我個人不太喜歡枯燥的概念,我們直接看一個demo。 首先,建立一個普通的java專案,然後建立兩個類,Server和Client。分別模擬服務端和客戶端。 程式碼和註釋都比較詳細。

然後開啟兩個命令終端,通過javac編譯後,一個執行Server代表伺服器,一個執行Client代表客戶端。執行結果如下: 上面的程式碼能夠完成一個簡單的服務端-客戶端的簡單通訊,但是存在以下幾個問題:
  1. 一個服務端只能同時為一個客戶端服務,幾乎沒有併發。
  2. 如果一個客戶端持續佔用這個服務,則服務端會一直不能為其他客戶端服務。在佔用服務的客戶端沒有與服務端互動的情況下,服務端資源會被浪費。
  3. 客戶端讀寫在一個執行緒,即讀寫不能同時進行。

BIO

在前面一小節中運用Socket和ServerSocket簡單的實現了網路通訊。這節中,利用BIO程式設計模型對上面的程式碼進行改造升級,以實現同時多對多通訊,類似於微信群聊。 所謂BIO,就是Block IO,阻塞式的IO。這個阻塞主要發生在:ServerSocket接收請求時(accept()方法)、InputStream、OutputStream(輸入輸出流的讀和寫)都是阻塞的。這個可以在下面程式碼的除錯中發現,比如在客戶端接收伺服器訊息的輸入流處打上斷點,除非伺服器發來訊息,不然斷點是一直停在這個地方的。也就是說這個執行緒在這時間是被阻塞的 如圖:當一個客戶端請求進來時,接收器會為這個客戶端分配一個工作執行緒,這個工作執行緒專職處理客戶端的操作。在上一小節中,伺服器接收到客戶端請求後就跑去專門服務這個客戶端了,所以當其他請求進來時,是處理不到的。 看到這個圖,很容易就會想到執行緒池,BIO是一個相對簡單的模型,實現它的關鍵之處也在於執行緒池。 便於理解清楚,在上程式碼之前,先大概說清楚每個類的作用。。更詳細的說明,這裡同樣寫在程式碼的註釋當中。 服務端
  1. ChatServer:這個類的作用就像圖中的Acceptor。它有兩個比較關鍵的全域性變數,一個就是儲存線上使用者資訊的Map,一個就是執行緒池。這個類會監聽埠,接收客戶端的請求,然後為客戶端分配工作執行緒。還會提供一些常用的工具方法給每個工作執行緒呼叫,比如:傳送訊息、新增線上使用者等。
  2. ChatHandler:這個類就是工作執行緒的類。在這裡它的工作很簡單:把接收到的訊息轉發給其他客戶端,當然還有一些小功能,比如新增\移除線上使用者。
上程式碼 客戶端
  1. 相較於伺服器,客戶端的改動較小,主要是把等待使用者輸入資訊這個功能分到其他執行緒做,不然這個功能會一直阻塞主執行緒,導致無法接收其他客戶端的訊息。
  2. ChatClient:客戶端啟動類,也就是主執行緒,會通過Socket和伺服器連線。也提供了兩個工具方法:傳送訊息和接收訊息。
  3. UserInputHandler:專門負責等待使用者輸入資訊的執行緒,一旦有資訊鍵入,就馬上傳送給伺服器。
老規矩,直接上程式碼。 執行測試
  1. 首先開啟一個Server終端,兩個Client終端
  2. 在Client1 中傳送 hi,my name is lilei,可以發現在Client2 和 Server 均收到了該訊息。
  3. 在Client2 中傳送 hi,my name is hanmeimeii,可以發現在Client1 和 Server 均收到了該訊息。

NIO

在上一小節中,我們使用BIO程式設計模型簡單的實現了一個多對多通訊(群聊)的功能。但是其最大的問題在解釋BIO時就已經說了:ServerSocket接收請求時(accept()方法)、InputStream、OutputStream(輸入輸出流的讀和寫)都是阻塞的。還有一個問題就是執行緒池,執行緒多了,伺服器效能耗不起。執行緒少了,在群聊這種場景下,讓使用者等待連線肯定不可取。今天要說到的NIO程式設計模型就很好的解決了這幾個問題。有兩個主要的替換地方:
  1. 用Channel代替Stream。
  2. 2.使用Selector監控多條Channel,起到類似執行緒池的作用,但是它只需一條執行緒。
既然要用NIO程式設計模型,那就要說說它的三個主要核心:Selector、Channel、Buffer。它們的關係是:一個Selector管理多個Channel,一個Channel可以往Buffer中寫入和讀取資料。Buffer名叫緩衝區,底層其實是一個數組,會提供一些方法往陣列寫入讀取資料。 Buffer Buffer 其實底層就是一個byte資料,其主要作用就是提供一系列api對這個byte資料進行讀寫操作,日常開發中Buffer使用較多,這裡就不做講解。 Channel Channel(通道)主要用於傳輸資料,然後從Buffer中寫入或讀取。它們兩個結合起來雖然和流有些相似,但主要有以下幾點區別:
  1. 流是單向的,可以發現Stream的輸入流和輸出流是獨立的,它們只能輸入或輸出。而通道既可以讀也可以寫。
  2. 通道本身不能存放資料,只能藉助Buffer。
  3. Channel支援非同步。
Channel有如下三個常用的類:FileChannel、SocketChannel、ServerSocketChannel。從名字也可以看出區別,第一個是對檔案資料的讀寫,後面兩個則是針對Socket和ServerSocket,這裡我們只是用後面兩個。更詳細的用法可以看:https://www.cnblogs.com/snailclimb/p/9086335.html,下面的程式碼中也會用到,會有詳細的註釋。 Selector 多個Channel可以註冊到Selector,就可以直接通過一個Selector管理多個通道。Channel在不同的時間或者不同的事件下有不同的狀態,Selector會通過輪詢來達到監視的效果,如果查到Channel的狀態正好是我們註冊時宣告的所要監視的狀態,我們就可以查出這些通道,然後做相應的處理。這些狀態如下:
  1. 客戶端的SocketChannel和伺服器端建立連線,SocketChannel狀態就是Connect
  2. 伺服器端的ServerSocketChannel接收了客戶端的請求,ServerSocketChannel狀態就是Accept
  3. 當SocketChannel有資料可讀,那麼它們的狀態就是Read
  4. 當我們需要向Channel中寫資料時,那麼它們的狀態就是Write
同樣,為了方便理解,我們先說各個類的大概功能。
  1. 相比較BIO的程式碼,NIO的程式碼還少了一個類,那就是伺服器端的工作執行緒類。沒了執行緒池,自然也不需要一個單獨的執行緒去服務客戶端。客戶端還是需要一個單獨的執行緒去等待使用者輸入,因為使用者隨時都可能輸入資訊,這個沒法預見,只能阻塞式的等待。
  2. ChatServer:伺服器端的唯一的類,作用就是通過Selector監聽Read和Accept事件,並針對這些事件的型別,進行不同的處理,如連線、轉發。
  3. ChatClient:客戶端,通過Selector監聽Read和Connect事件。Read事件就是獲取伺服器轉發的訊息然後顯示出來;Connect事件就是和伺服器建立連線,建立成功後就可以傳送訊息。
  4. UserInputHandler:專門等待使用者輸入的執行緒,和BIO沒區別。
這裡的執行結果與BIO結果類似,這裡便不錯執行測試。感興趣的小夥伴自行測試一下即可。

AIO

AIO,非同步IO。非同步的精髓就是註冊+回掉。 上一小節說到的NIO程式設計模型比較主流,開編提到的Netty就是基於NIO程式設計模型的。這一小節說的是AIO程式設計模型,是非同步非阻塞的。雖然同樣實現的是多對多通訊(群聊),但是實現邏輯上稍微要比NIO和BIO複雜一點。不過理好整體脈絡,會好理解一些。首先還是講講概念: BIO和NIO的區別是阻塞和非阻塞,而AIO代表的是非同步IO。在此之前只提到了阻塞和非阻塞,沒有提到非同步還是同步。可以用我在知乎上看到的一句話表示:【在處理 IO 的時候,阻塞和非阻塞都是同步 IO,只有使用了特殊的 API 才是非同步 IO】。這些“特殊的API”下面會講到。在說AIO之前,先總結一下阻塞、非阻塞、非同步、同步的概念。 阻塞和非阻塞,描述的是結果的請求阻塞:在得到結果之前就一直呆在那,啥也不幹,此時執行緒掛起,就如其名,執行緒被阻塞了。 非阻塞:如果沒得到結果就返回,等一會再去請求,直到得到結果為止。 非同步和同步,描述的是結果的發出,當呼叫方的請求進來。 同步:在沒獲取到結果前就不返回給呼叫方,如果呼叫方是阻塞的,那麼呼叫方就會一直等著。如果呼叫方是非阻塞的,呼叫方就會先回去,等一會再來問問得到結果沒。 非同步:呼叫方一來,會直接返回,等執行完實際的邏輯後在通過回撥函式把結果返回給呼叫方。 AIO中的非同步操作 在AIO程式設計模型中,常用的API,如connect、accept、read、write都是支援非同步操作的。當呼叫這些方法時,可以攜帶一個CompletionHandler引數,它會提供一些回撥函式。這些回撥函式包括:1.當這些操作成功時你需要怎麼做;2.如果這些操作失敗了你要這麼做。關於這個CompletionHandler引數,你只需要寫一個類實現CompletionHandler口,並實現裡面兩個方法就行了。 而 connect、accept、read、write 這四個方法的同步在前面的程式碼中,我們已經熟悉過了。在AIO中這四個方法需要傳入CompletionHandler引數從而實現非同步呢。下面分別舉例這四個方法的使用。 先說說Socket和ServerSocket,在NIO中,它們變成了通道,配合緩衝區,從而實現了非阻塞。而在AIO中它們變成了非同步通道。也就是AsynchronousServerSocketChannel和AsynchronousSocketChannel,下面例子中物件名分別是serverSocket和socket.
  1. accept:serverSocket.accept(attachment,handler)。handler就是實現了CompletionHandler介面並實現兩個回撥函式的類,它具體怎麼寫可以看下面的實戰程式碼。attachment為handler裡面可能需要用到的輔助資料,如果沒有就填null。
  2. read:socket.read(buffer,attachment,handler)。buffer是緩衝區,用以存放讀取到的資訊。後面兩個引數和accept一樣。
  3. write:socket.write(buffer,attachment,handler)。和read引數一樣。
  4. connect:socket.connect(address,attachment,handler)。address為伺服器的IP和埠,後面兩個引數與前幾個一樣。
Future 既然說到了非同步操作,除了使用實現CompletionHandler介面的方式,不得不想到Future。客戶端邏輯較為簡單,如果使用CompletionHandler的話程式碼反而更復雜,所以下面的實戰客戶端程式碼就會使用Future的方式。簡單來說,Future表示的是非同步操作未來的結果,怎麼理解未來。比如,客戶端呼叫read方法獲取伺服器發來得訊息: Future<Integer> readResult=clientChannel.read(buffer) Integer是read()的返回型別,此時變數readResult實際上並不一定有資料,而是表示read()方法未來的結果,這時候readResult有兩個方法,isDone():返回boolean,檢視程式是否完成處理,如果返回true,有結果了,這時候可以通過get()獲取結果。如果你不事先判斷isDone()直接呼叫get()也行,只不過它是阻塞的。如果你不想阻塞,想在這期間做點什麼,就用isDone()。 還有一個問題:這些handler的方法是在哪個執行緒執行的?serverSocket.accept這個方法肯定是在主執行緒裡面呼叫的,而傳入的這些回撥方法其實是在其他執行緒執行的。在AIO中,會有一個AsynchronousChannelGroup,它和AsynchronousServerSocketChannel是繫結在一起的,它會為這些非同步通道提供系統資源,執行緒就算其中一種系統資源,所以為了方便理解,我們暫時可以把他看作一個執行緒池,它會為這些handler分配執行緒,而不是在主執行緒中去執行。 上面零碎的概念講的比較多,僅是為了大家方便理解。下面簡單梳理一下大概的工作流程,重點是服務端,客戶端相對比較簡單。
  1. 跟NIO一樣,先要建立好通道,只不過AIO是非同步通道。然後建立好AsyncChannelGroup,可以選擇自定義執行緒池。最後把AsyncServerSocket和AsyncChannelGroup繫結在一起,這樣處於同一個AsyncChannelGroup裡的通道就可以共享系統資源。
  2. 建立好handler類,並實現介面和裡面兩個回撥方法。(如圖:客戶端1對應的handler,裡面的回撥方法會實現讀取訊息和轉發訊息的功能;serverSocket的handler裡的回撥方法會實現accept功能。)
  3. 準備工作完成,當客戶端1連線請求進來,客戶端會馬上回去,ServerSocket的非同步方法會在連線成功後把客戶端的SocketChannel存進線上使用者列表,並利用客戶端1的handler開始非同步監聽客戶端1傳送的訊息。
  4. 當客戶端1傳送訊息時,如果上一步中的handler成功監聽到,就會回撥成功後的回撥方法,這個方法裡會把這個訊息轉發給其他客戶端。轉發完成後,接著利用handler監聽客戶端1傳送的訊息。
程式碼部分同NIO一樣,也只有三個類。
  1. ChatServer:功能基本上和上面講的工作流程差不多,還會有一些工具方法,都比較簡單,就不多說了,如:轉發訊息,客戶端下線後從線上列表移除客戶端等。
  2. ChatClient:基本和前兩章的BIO、NIO沒什麼區別,一個執行緒監聽使用者輸入資訊併發送,主執行緒非同步的讀取伺服器資訊。
  3. UserInputHandler:監聽使用者輸入資訊的執行緒。
測試結果: