本文首發於 jaychen.cc
作者 JayChen
在進入主題之前先看個 Java 網路程式設計的一個簡單例子:程式碼很簡單,客戶端和服務端進行通訊,對於客戶端的每次輸入,服務端回覆 get。注意,服務端可以同時允許多個客戶端連線。
服務端端程式碼:
// 建立服務端 socket
ServerSocket serverSocket = new ServerSocket(20000);
client = serverSocket.accept();
// 客戶端連線成功,輸出提示
System.out.println("客戶端連線成功");
// 啟動一個新的執行緒處理客戶端請求
new Thread(new ServerThread(client)).start();
// 子執行緒中處理客戶端的輸入
class ServerThread implements Runnable {
.....
@Override
public void run() {
boolean flag = true;
while (flag) {
// 讀取客戶端傳送來的資料
String str = buf.readLine();
// 回覆給客戶端 get 表示收到資料
out.println("get");
}
}
}複製程式碼
客戶端程式碼 :
Socket client = new Socket("127.0.0.1", 20000);
boolean flag = true;
while (flag) {
// 讀取使用者從鍵盤的輸入
String str = input.readLine();
// 把使用者的輸入傳送給服務端
out.println(str);
// 接受到服務端回傳的 get 字串
String echo = buf.readLine();
System.out.println(echo);
}
}複製程式碼
考慮到完整的 Java 示例程式碼太過龐大影響閱讀,所以這裡不完整貼出,如果需要在 github 直接下載,這裡是下載地址。
可以看到,server 為了能夠同時處理多個 client 的請求,需要為每個 client 開啟一個 thread,這種 one-thread-per-client 的模式對於 server 而言壓力是很大的。假設有 1k 個 client,對應的 server 應該啟動 1k 個 thread,那麼 server 所耗費的記憶體,以及 thread 切換時候佔用的時間等等都是致命傷。即使使用執行緒池的技術來限制執行緒個數,這種 blocking-IO 的模型還是沒辦法支撐大量連線。
每個 client 都需要一個 thread 來請求處理。
NIO
上面這種 one-thread-per-client 的模式無法支撐大量連線的主要原因在於 readLine
會 阻塞 IO,即在 readLine
沒能夠讀取到資料的時候,會一直阻塞執行緒,使得執行緒無法繼續執行,那麼 server 為了可以同時處理多個 client,只能同時開啟多個執行緒。
所以,Java 1.4 之後引入了一套 NIO 介面。NIO 中最主要的一個功能就是可以進行非阻塞 IO 操作:如果沒能夠讀取到資料,非阻塞 IO 不會阻塞執行緒,而是直接返回 0。這種情況下,執行緒通過返回值判斷資料還沒有準備好,就可以處理其他事情,而不會被阻塞。
上圖是阻塞 IO 和非阻塞 IO 的區別,可以看出雖然 非阻塞 IO 並不會被阻塞,但是它仍然不斷的呼叫函式檢查資料是否已經可讀,這種現象在程式碼中是以這種形式展現:
while((str = read()) == 0) {
}
// 繼續讀取到資料之後的邏輯。複製程式碼
可以明白,雖然非阻塞 IO 不會阻塞執行緒,但是由於沒有資料可讀,執行緒也沒有辦法繼續執行下面的邏輯,只能不斷的呼叫判斷,等待資料到來。這種情況下稱為同步 IO。所以綜上,NIO 本質上是一個非阻塞同步 IO。
IO 複用
由於 NIO 不會因為資料還沒有到達而被阻塞,那麼就沒有必要每一個 client 都分配一個 thread 不斷去輪詢判斷是否有資料可讀。可以使用一個 thread 監聽所有的 client 連線,由這個 thread 迴圈判斷是否有某個 client 的資料可讀,如果有就告知其他 thread 某個 client 連線由資料可讀。這種行為就被稱之為 IO 複用。 在 NIO 中提供了 Selector
類來監聽所有 client 連線是否有資料可讀。
使用 Selector
來實現 IO 複用,只有一個 thread 需要關心資料是否到來,其他執行緒等待通知就好。如此一來,只有監聽執行緒會一直迴圈判斷,並不會佔據太多 CPU 資源。提到 NIO 中的 Selector
,不得不說一下 Linux 程式設計中的 IO 複用,因為 NIO 中的 Selector
底層就是使用系統級的 IO 複用方案。
Linux 系統的 IO 複用實現方案有 2 種:
select
epoll
在 Linux 2.6+ 的版本上 NIO 底層使用的是 epoll
,在 2.4.x 的版本使用的是 select
函式。epoll
函式在效能方面比 select
好很多,這裡可以不關心 Linux 程式設計具體細節。值得一提的是,Java 的 netty 網路框架底層就是使用 NIO 技術。
AIO
回顧一下 NIO 中:使用監聽執行緒呼叫 select
函式來監聽所有請求是否有資料到達,如果有資料則通知其他執行緒來讀取資料。這裡線上程讀取資料的過程中,執行緒在資料沒有讀取完畢之前是處於阻塞狀態,只有資料讀取完畢之後執行緒才可以繼續執行邏輯。之前說過,這種稱之為同步 IO。JDK 7 中新增了一套新介面 AIO(Asynchronous IO)。
AIO 有一個神奇的特性:當發起 IO 操作之後,執行緒不用等待 IO 讀取完畢,而是可以直接返回,繼續執行其他操作。等到資料讀取完畢之後,系統會通知執行緒資料已經讀取完畢。這種發起 IO 操作,但是不必等待資料讀取完畢的 IO 操作稱之為非同步 IO。如果使用 AIO,一個執行緒可以同時發起多個 IO 操作,這就意味著,一個執行緒可以同時處理多個請求。著名的 web 伺服器 Nginx 就是用了非同步 IO。關於更多的細節,可以參考下我的另一篇文章 Apache--MPMs && Nginx事件驅動。
End
到目前為止,文章解釋了阻塞/非阻塞 IO,同步/非同步 IO 的區別,談起 IO 模型,必不可少會涉及 Linux 的 5 種 IO 模型
- 阻塞 IO
- 非阻塞 IO
- IO 複用
- 訊號驅動 IO
- 非同步 IO
除去訊號驅動 IO沒有提及,其他 4 種主要的 IO 模型都有所解釋,理解了這些 IO 模型的概念對於編寫程式碼有很大的幫助。