在先前的文章《Unix之IO模型》已經講述到5種IO模型以及對應的同步異步和阻塞非阻塞相關核心概念,接下來看下Java的IO模型在服務端的網絡編程中是如何演進,注意這裏用啓動Java程序表示一個JVM進程,而JVM進程中以多線程方式進行協作,這裏講述以線程爲主展開
Java中的BIO演進
BIO 概述
- 在上篇文章中講述到阻塞式IO是應用進程等待內核系統接收到數據報並將數據報復制到內核再返回的處理過程
- 在Java中的阻塞式IO模型(Blocking IO)網絡編程中,服務端
accept
&read
都需要等待客戶端建立連接和發起請求才能夠進行讓服務端程序進行響應,也就是上述的方法在服務端的編程中會讓服務端的主線程產生阻塞,當其他客戶端與Java服務端嘗試建立連接和發請求的時候會被阻塞,等待前面一個客戶端處理完之後纔會處理下一個客戶端的連接和請求,以上是Java的BIO體現
服務端單線程BIO模型
- 單線程圖解
- 代碼演示
// server.java
// 僅寫部分服務端核心代碼
ServerSocket server = new ServerSocket(ip, port);
while(true){
Socket socket = server.accept(); // 接收客戶端的連接,會阻塞
out.put("收到新連接:" + socket.toString());
// client have connected
// start read
BufferedReader br = new BuufferedReader(new InputstreamReader(socket.getInputStream));
String line = br.readLine(); // 等待客戶端發起請求進行讀取操作,會阻塞
// decode ..
// process ..
// encode ..
// send ..
}
- 運行結果(啓動兩個客戶端)
- 分析
- 上述代碼accept方法以及read方法需要等待客戶端發送數據過來,服務端才能從操作系統的底層網卡獲取數據,在沒有獲取數據之前將處於阻塞狀態
- 其次,可以看到上述的服務端程序只能處理一個客戶端的連接和請求操作,只有當前的客戶端連接以及請求執行完之後才能接收下一個客戶端的連接以及請求處理操作
- 不足: 上述代碼壓根無法滿足服務端處理多客戶端的連接和請求,同時造成CPU空閒,尤其是在接收客戶端讀取的時候,如果沒有客戶端讀取其他客戶端建立的連接請求根本無法處理,因此對上述進行改進爲多線程處理方式
基於1:1的多線程BIO模型
- 根據上述的BIO模型,現優化爲主線程接收accept以及通過創建多線程方式處理IO的讀寫操作
- 一個客戶端的請求處理交由服務端新創建的一個線程進行處理,主線程仍然處理接收客戶端連接的操作
- 如下圖
- 代碼演示
// thread-task.java
public class IOTask implements Runnable{
private Socket client;
public IOTask(Socket client){
this.client = client;
}
run(){
while(!Thread.isInterrupt()){
// read from socket inputstream
// encode reading text
// process
// decode sent text
// send
}
}
}
// server.java
ServerSocket server = new ServerSocket(ip, port);
while(true){
Socket client = server.accept();
out.put(“收到新連接:” + client.toString());
new Thread(new IOTask(client)),start();
}
- 運行效果(客戶端啓動服務端就接收到客戶端的連接)
- 分析
- 通過多線程的方式將Accept與IO讀寫操作的阻塞方式進行分離,主線程處理accept接收客戶端的連接,新開線程接收客戶端的請求進行請求處理操作
- 上述的方式僅僅是將阻塞的方式進行分離,但是如果處理的客戶端數量越來越多的時候,上述服務器創建線程會越來越多,容易造成機器CPU出現100%情況,那麼我們可以如何控制線程的方式,在併發編程中,一般通過管理併發處理任務的多線程技術是採用線程池的方式,於是就有了以下的M:N的多線程網絡編程的BIO模型
基於M:N的線程池實現的BIO模式
- M:N的線程池實現的圖解如下
- 示例代碼
// server.java
ExecutorService executors = Executros.newFixedThreadPool(MAX_THREAD_NUM);
ServerSocket server = new ServerSocket(ip,port);
while(true){
Socket client = server.accept();
out.put(“收到新連接:” + client.toString());
threadPool.submit(new IOTask(ckient));
}
- 分析
- 上述運行結果與1:1的線程模型是一致的,但是相比1:1創建線程的方式,充分利用池化技術重複利用線程資源,有助於降低CPU佔用的資源
- 其次,上述的BIO都是屬於阻塞式IO處理,每一次的accept操作以及read操作都需要等待客戶端的操作才能給予響應,如果客戶端不發生操作,那麼新創建的線程將一直處於阻塞狀態,將佔用資源遲遲沒有釋放,也容易造成CPU發生瓶頸,於是我們想到能否等到客戶端有發起相應的操作的時候線程才進行處理呢,在客戶端還沒有發生請求操作的時候,服務端線程資源是否可以優先處理其他任務,提升CPU利用率呢,這也就是接下來的非阻塞式IO,即Non-Blocking IO
Java中的NIO演進
NIO概述
- 在《Unix的IO模型》中的NIO模型有非阻塞式IO,IO複用模型以及信號驅動的IO模型,在Java中的NIO模型主要是以非阻塞式IO以及IO複用模型爲主
- 從上述的BIO可知,服務端會在accept方法以及read方法調用中導致當前線程處於阻塞狀態,結合Unix中的非阻塞式IO可知,NIO本質上是將上述的方法設置爲非阻塞,然後通過輪詢的方式來檢查當前的狀態是否就緒,如果是Accept就處理客戶端連接事件,如果是READ就處理客戶端的請求事件
- Java實現NIO的方式注意依賴於以下三個核心組件
- Channel通道:服務端與客戶端建立連接以及進行數據傳輸的通道,分爲ServerSocketChannel(接收客戶端的TCP連接通道)以及SocketChannel(建立與服務端的連接通道)
- Buffer緩存區: 客戶端與服務端在channel中建立一個連續數組的內存空間,用於在channel中接收和發送數據數據實現兩端的數據通信
- Selector選擇註冊器,對比IO複用模型,Selector中包含select函數,用於向系統內核註冊網絡編程中的Aceept,Read以及Write等事件,相對於從Java而言,是指channel(不論是服務端還是客戶端通道)可以向註冊器selector發起註冊事件,底層交由
select()
向操作系統進行事件註冊
- 簡要的NIO模型圖
基於單線程通道輪詢的NIO模式(NIO模型)
- 這類IO模型與unix下的NIO模型是一致的,就是服務端不斷地檢查當前的連接狀態信息,如果狀態信息就緒那麼就開始執行相應的處理邏輯
- NIO圖解模型如下
- 在NIO模型圖中,accept不斷polling客戶端是否有建立連接,如果有客戶端連接到服務端,這個時候就會將其轉發進行IO操作
- 部分java示例僞代碼
// server.java
ServerSocketChannel server = ServerSocketChannel.open();
// 設置所有的socket默認僞阻塞,必須設置服務端的通道爲非阻塞模式
server.configureBlocking(false);
// 綁定端口以及最大可接收的連接數backlog
server.socket().bind(new InetSocketAddress(PORT), MAX_BACKLOG_NUM);
while(true){
SocketChannel client = server.accept();
// 非阻塞獲取,所以client可能爲null
if(null != client){
// 設置客戶端的通道爲非阻塞
client.configureBlocking(false);
// 進行IO操作
// read
ByteBuffer req = ByteBuffer.allocate(MAX_SIZE);
while(client.isOpen() &/& client.read(req)!= -1){
// BufferCoding是自己封裝的一個解碼工具類,結合ByteBuffer與Charset使用,這裏不演示代碼實現
// decode
byte[] data = BufferCoding.decode(req);
if(data != null){
break;
}
}
// prepared data to send
sentData = process(data);
// encode
ByteBuffer sent = BufferCoding.encode(sentData);
// write
client.writeAndFlush(sent);
}
}
- 分析
- 上述的代碼與BIO的設計基本無差,只是在原有的基礎上設置爲非阻塞的操作,然後通過不斷輪詢的方式不斷監控連接和讀取操作,與BIO的多線程設計差別不大,只是BIO是多線程方式實現,這裏是單線程實現
- 小結:上述代碼使用BIO的API方式,也就是說不斷polling的過程都是調用阻塞的API去檢查是否就緒的狀態,結合先前的Unix的IO模型,非阻塞可以繼續改進爲給予select的方式來實現,而select不是屬於調用阻塞式API而是通過事件輪詢的方式等待套接字中的描述符變爲就緒狀態再進行業務處理操作
基於單線程的select事件輪詢IO模式(IO多路複用模型)
- IO複用模型是通過調用select函數不斷輪詢獲取當前socket的描述符是否就緒,是基於事件的方式實現非阻塞
- 客戶端與服務端都需要註冊到selector上,告訴selector當前對哪個描述符感興趣,再由selector將感興趣的描述符註冊到系統內核中,內核收到一份描述符的數組表,根據網絡傳輸過來的事件告知selector當前對應的描述符的狀態信息
- 其簡要的示例圖如下
- 從上述模型可以看出
- 服務端啓動的時候,首先需要創建channel並註冊到selector上才能夠監聽到客戶端建立的連接
- 其次客戶端要與服務端建立通信,也需要在客戶端自己創建channel並註冊到selector上
- 當selector監聽到客戶端的連接就會轉發給服務端的Accept事件進行處理
- 當selector監聽到客戶端發起請求的操作,就會轉發給處理READ事件進行處理,並且如果需要將數據通知客戶端,並且在指定的事件上添加寫操作
- 此時selector監聽到寫操作的時候,就會轉發給處理WRITE事件進行處理,並且當前在進行寫操作之後取消寫操作的事件
- java實現的僞代碼
// server.java
ServerSocketChannel server = ServerSocketChannel,open();
server.configureBlocking(false);
Selector selector = Selector.open();
// 服務端只註冊ACCEPT,作爲接入客戶端的連接
// DataWrap封裝讀寫緩存ByteBuffer
server.register(selector, SelectionKey.OP_ACCEPT, server);
server.socket().bind(new InetSocketAddress(PORT), MAX_BACKLOG_NUM);
while(true){
int key = selector.select();
if(key == 0) continue;
// 獲取註冊到selecor所有感興趣的事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while(it.hasNext()){
SelectionKey key = it.next();
it.remove();
if(key.isAcceptable()){
// 接收accept事件
ServerSocketChannel serverChannel = (ServerSocketChannel)key.attachment();
SocketChannel client = server.accept();
client.configureBlocking(false);
// 客戶端已經獲取到連接,告訴客戶端的channel可以開始進行讀寫操作
client.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE, new DataWrap(256, 256));
}
// read
if(key.isReadable()){
//...
// 在事件中添加寫操作
key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
}
if(key.isWriteable()){
// ...
// 成功完成寫操作,這個時候取消寫操作
key.interestOps(key.interestOps() & (~SelectionKey.OP_WRITE));
}
}
}
- 分析
- 上述代碼相比第一種方案的實現,主要是採用select函數調用獲取註冊的事件,阻塞於select事件
- 另外上述的代碼相比第一種稍微更爲複雜,操作也更爲繁瑣
- 同時兩種方式存在的不足都是單線程的方式,在接入層支撐的連接併發量大的時候,並且如果處理的業務複雜且耗時,那麼無疑也將會影響到程序的性能,無法快速響應給客戶端,同時也會出現短暫的阻塞
基於多線程實現的NIO(Reactor模型)
- Reactor模型是參考《Scalable in Java》的文章,目的是爲了更好地利用CPU資源將selector監聽到事件分別進行相應的分發提高用戶響應
- Reactor中主要包含selector, channel以及事件SelectionKey,通過不斷polling的方式監聽客戶端的連接,如果有連接進來就將連接轉發給Accept,將服務端channel與客戶端的channel連接起來,並告知客戶端可以進行讀寫操作
- 後面在服務端接收到讀取事件的時候,就會利用線程池的方式提交任務來處理客戶端發過來的請求事件,並準備寫數據到緩衝區,並再通知並註冊寫事件
- 接收到寫事件之後,從緩存區中獲取數據並刷新到客戶端
- Reactor簡要圖如下
- 分析
- (Main)Reactor模型中主要接收服務端以及客戶端的註冊,其中服務端在啓動的時候就註冊到Reactor上,而客戶端建立連接之前也需要現註冊到Reactor中,再由監聽到客戶端連接的Reactor轉發給處理Accept的事件操作,將客戶端通道設置非阻塞且建立其可以進行讀寫的事件監聽操作,對於單個而言仍然是在Reactor線程中,而多個Reactor模型中會將當前的客戶端連接以及通道轉給另一個Reactor進行網絡IO操作
- 對於單個Reactor模型,接收到客戶端的請求之後,進行讀取然後向線程池提交任務去處理實際業務,在發送給客戶端之前向事件註冊寫事件然後交由Reactor執行寫數據到客戶端
- 對於多個Reactor模型.接收到客戶端的請求之後,將通過SubReactor讀取數據,之後的操作與上述單個Reactor模型流程一致的
後面會詳細寫高性能IO編程模型,會再詳細講Reactor以及對應的實現