NIO非阻塞通信簡介及基於TCP的非阻塞通信
通信方式簡述
阻塞式的通信
以基於TCP的通信方式來說明:創建ServerSocket
(或ServerSocketChannel
)、調用accept()
方法獲取客戶端的Socket
連接、通過Socket
通信、關閉Socket
連接。
對於阻塞式的通信,最影響效率的其實是SocketChannel.read()
或通過Socket
獲得的輸入流的InputStream.read()
。當服務端調用這些輸入方法時,線程就會進入阻塞狀態,直到客戶端發來數據才被喚醒。
如果用單線程的方式實現上述流程,資源的利用率極低。線程調用read()
方法後,進入阻塞狀態,如果客戶端遲遲沒有數據,服務端就會一直被阻塞,而沒辦法進行其他操作。
第一種解決方法就是使用多線程的方式,一個線程僅負責accept()
接收請求,將接收到的請求分配給其他線程,這樣,調用read()
時,處理請求的線程不會被阻塞,這樣就還能接收新的請求,提高了併發量與資源利用率。
第二種解決方法就是使用非阻塞的通信方式。
非阻塞式的通信
先來考慮一下爲什麼會有阻塞通信?和本地I/O一樣,本地I/O也是需要阻塞,當通過系統調用I/O完成後,喚醒被阻塞的進程(線程)。問題就在於:什麼時候I/O完成,對於阻塞式的I/O來說,當進程被喚醒時就說明I/O完成。
現在再來想一下本地I/O最低效的方式,循環的訪問I/O完成的狀態標誌,當狀態標誌指示I/O完成後,說明I/O結束。這就是一個非阻塞的過程,當調度到這個進程(線程),CPU在循環的訪問一個標誌位來判斷I/O是否完成
把這個思想遷移到網絡通信中,現在有多個連接,怎樣判斷哪個連接有數據過來?給這些連接一個標誌,用來表示是否有數據,服務端循環的訪問每個連接的這個標誌,就可以知道哪個連接的數據已經準備好了
問題貌似解決了,但是想一下,如果所有連接都沒有數據,那CPU一直再做“無用功”,浪費資源。
爲了避免這種資源浪費,可以使用一個選擇器
,這個選擇器的作用就是把有數據的連接都挑出來,輪詢的時候保證每個連接都是有數據需要處理的;如果所有連接都沒有數據,選擇器
被阻塞,直到有某個連接的數據準備好。
關於非阻塞式的通信的基本思想,已經闡述完成,帶着這些簡單的理解,繼續瞭解NIO提供的非阻塞通信。
Java提供的抽象
上面分析非阻塞I/O時,有兩個很重要的點:被選擇器選擇的連接
和選擇器
。
NIO提供了它們的抽象:java.nio.channels.SelectableChannel
和Selector
SelectableChannel
下面給出一段官方文檔描述的翻譯:
SelectableChannel
可以通過Selector
複用。
SelectableChannel
的實例爲了可以被Selector
使用,必須使用register()
方法註冊到Selector
的實例;該方法返回一個SelectionKey
對象,這個對象代表了這個通道註冊到一個選擇器中。
SelectableChannel
是線程安全的。
看完這些解釋如果不懂,可以先接着往下看,看到後面就會理解。
Selector
再談談向Selector
註冊,在註冊時,有兩個參數,一個是Selector
,另一個是一個int
類型,它表示事件。
在上面一直用“讀”/“輸入”舉例子,如果要讓Selector
判斷一個Selectable
是否可讀,註冊時僅需要調用SelectableChannel.register(Selector, SelectionKey.OP_READ)
SelectionKey.OP_READ
表示一個讀事件,或者說讓選擇器判斷通道是否可讀。
非阻塞通信的使用-TCP
SelectableChannel
是一個抽象類,在使用時,沒辦法直接實例化,查看一下它的子類,巧了,有ServerSocketChannel
和SocketChannel
看到這兩個,就想到了它們可以進行阻塞的通信,那怎麼進行非阻塞通信?
1. 創建ServerSocketChannel
這一步和阻塞式的通信一致。
severSocketChannel = ServerSocketChannel.open();
2. 設置使用非阻塞式通信
severSocketChannel.configureBlocking(false);
默認情況下,爲阻塞通信,也就是參數爲true的情況。
3. 設置使用非阻塞通信
這一步和阻塞式的通信一致。
severSocketChannel.bind(new InetSocketAddress(PORT));
4. 創建Selector
selector = Selector.open();
5. 將ServerSocketChannel
的OP_Accept
事件註冊到Selector
severSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
當有連接到來(accept隊列中有連接請求),事件被觸發,選擇器選擇時就會把這個通道“挑選”出來,之後就可以輪詢所有“挑選”出來的通道,並處理這些通道出發的事件。
這裏要注意,註冊的通道必須是非阻塞的,否則會出錯拋出異常。
6. 選擇器選擇已經觸發了事件的通道:select()
通過使用Selector.select()
方法,可以“挑選”出已經觸發了事件的通道。
請看官方的解釋
Selects a set of keys whose corresponding channels are ready for I/O operations.
選擇出一組健,它們對應的通道已經準備好I/O操作
其實,因該改成選擇出一組健,它們對應的通道的註冊事件已經觸發,因爲可註冊的事件除了I/O之外,還有accept
這裏說一下可以註冊的事件:
- OP_ACCEPT:選擇器檢測到對應的
ServerSocketChannel
已經準備好接受(accept)另一個連接,則該事件被觸發(被“選中”)。 - OP_CONNECT:選擇器檢測到對應的
SocketChannel
準備完成連接序列,則該事件觸發(被“選中”)。 - OP_READ:選擇器檢測到對應的
SocketChannel
已經準備好讀取數據,則該事件被觸發(被“選中”)。 - OP_WRITE:選擇器檢測到對應的
SocketChannel
已經準備好寫數據,則改時間觸發(被“選中”)。
7. 獲得選擇出來的集合:selectedKeys()
可以這樣理解:select()方法會遍歷已經註冊的通道,如果通道對應的註冊事件已經出發,則把這個通道放入一個selected
集合,調用Selector.selectedKeys()即可獲得該集合
selected集合中存放的不是一個通道,而是對它進行了封裝,封裝成了一個SelectionKey
,通過這個封裝好的類,不僅可以獲得觸發了時間的通道,還可以知道觸發了什麼事件,針對不同的事件,可以給出不同的處理。
8. 遍歷selectedKey集合,處理事件
示例:Echo服務端
public class Server {
private static final int PORT = 8888;
private ServerSocketChannel severSocketChannel;
private Selector selector;
public Server() throws IOException {
//創建serverSocketChannel
this.severSocketChannel = ServerSocketChannel.open();
//開啓非阻塞方式
this.severSocketChannel.configureBlocking(false);
//綁定地址,開啓監聽
this.severSocketChannel.bind(new InetSocketAddress(PORT));
//創建selector
this.selector = Selector.open();
//註冊accepte事件
this.severSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
}
public void service() {
//是否有事件觸發
try {
while(selector.select() > 0) {
//獲得已經觸發的事件
Iterator<SelectionKey> selectionKeys = selector.selectedKeys().iterator();
while(selectionKeys.hasNext()) {
SelectionKey key = selectionKeys.next();
//刪除即將處理過的事件,因爲使用多線程進行處理,避免多次處理
selectionKeys.remove();
if(key.isAcceptable()) {//可接受一個連接
ServerSocketChannel serverSocketChannel = (ServerSocketChannel)key.channel();
SocketChannel clientChannel = serverSocketChannel.accept();
clientChannel.configureBlocking(false);
clientChannel.register(this.selector, SelectionKey.OP_READ);
}else if(key.isReadable()) {//可讀
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel clientChannel = (SocketChannel) key.channel();
InetSocketAddress address = (InetSocketAddress)clientChannel.getLocalAddress();
String clientIp = address.getHostString();
int clientPort = address.getPort();
try {
clientChannel.read(buffer);
buffer.flip();
//服務端輸出
String content = new String(buffer.array(), 0, buffer.limit());
System.out.println("收到信息 [" + clientIp + " : " + clientPort + "] " + content);
//回送給客戶端
clientChannel.write(buffer);
//收到exit,退出
if("exit".equals(content)) {
System.out.println("與 [ " + clientIp + " : " + clientPort + "]的連接已斷開");
try {
clientChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
//準備下一次輸入
buffer.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
this.severSocketChannel.close();
this.selector.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
try {
(new Server()).service();
} catch (IOException e) {
e.printStackTrace();
}
}
}
關於非阻塞客戶端的坑
- 連接建立的不確定性:如果使用一個非阻塞的通道連接服務端(
connect()
),connect()
可能會在連接建立完成之前返回false。因此,connect()
之後,需要使用finishConnect()
檢查連接是否建立成功。 - 在實現給Echo服務端發數據的客戶端時,對於阻塞通信,只需要在發送數據之後在調用
read()
接收服務端的會送即可,阻塞通信可以保證從通道內可以讀到數據;對於非阻塞通信,在發送數據之後不能直接讀取,因爲不能保證通道內的數據已經準備好了(服務端可能還沒把數據通過通道傳輸過來),直接讀取可能會出錯,對於這種情況,必須向選擇器註冊一個讀事件,可以通過選擇器來判斷通道什麼時候可讀。
關於Selector.select()
的細節
Selector提供了三個select()方法:
- select():阻塞式,至少會選擇出一個。當沒有事件觸發時,進入阻塞狀態,當有事件被觸發是,喚醒。
- selectNow():非阻塞,如果有事件觸發,則選擇並返回;沒有事件觸發,立即返回,不會阻塞等待。
- select(long timeout):阻塞式,與select()相同,但是加了一個定時器,阻塞時間超過timeout將會返回。