IO 和 NIO 的思考
輸入輸出是作業系統不可或缺的一部分,大致分為兩類:面向磁碟和麵向網路。在 Java 中有3種 I/O 型別:BIO、NIO 和 AIO,分別是同步阻塞、同步非阻塞和非同步非阻塞 I/O,這裡著重描述 BIO 和 NIO 的區別和常用的程式設計模型。
1. 為什麼設計 NIO
一個直接原因就是為了更好的利用作業系統特性,改善和擴充套件原有 API。與 NIO 相關的規範有兩個:
- JSR 51 :它是 NIO 的第一個規範,關注緩衝區、通道和字符集的設計,引入一個簡單的面向緩衝區的 I/O 模型,並且提供一套非阻塞、I/O 多路複用、可擴充套件的 API
- JSR 203(NIO.2) :它在前者的基礎上,新增新的檔案系統的抽象,完善現有 Socket 通道的配置,新增多播資料報的支援,並且定義了一個非同步 I/O 程式設計 API
那麼,傳統的 BIO 又有什麼弊端?NIO 又是如何改進的?可以從兩方面進行說明。
1.2 檔案操作
關於 java.io.file,它的不足之處在於:
- 查詢檔案屬性時,如修改時間或檔案型別,都會發生系統呼叫,並且這些組合操作非常常見,造成效能問題
- 部分方法在發生錯誤時返回 false 而不是丟擲異常,比如 delete、rename,不知操作失敗的原因
- 一些 OS 高階功能不支援,比如符號連結、檔案鎖定、記憶體對映等
而 NIO 支援批量獲取檔案屬性,對檔案、目錄的處理也重新設計,提供 FileLock、MappedByteBuffer 等支援 OS 高階功能。
1.3 網路通訊
BIO 是同步阻塞、基於流的 I/O,阻塞就意味著當 Socket 輸入流中無資料可讀取時,呼叫執行緒掛起,直到有資料讀取,期間不能處理其他請求,如果來了一個新連線,就只能再新建一個執行緒處理。
隨著連線數的增加,BIO 將會建立大量執行緒,而一個計算機能開啟的程序數或執行緒數是有限的,嚴重的時候可能會導致應用崩潰無響應。一個有效的解決辦法是使用執行緒池,限制最大執行緒數,但它同時也限制了最大連線數。
NIO 將讀寫改為非阻塞,無資料可讀,執行緒返回執行緒池,可用於處理其他連線。它對原始 I/O 提供了新的抽象 - Channel(通道),並且提供基於緩衝區的讀寫 API。Channel 表示一個到硬體裝置、檔案或網路套接字的連線,與 java.net.Socket 的區別是:
- 可配置非阻塞,允許事件驅動 的設計,提供了一種更加可擴充套件的伺服器開發
- 面向緩衝區,可實現零拷貝 執行 I/O ,只不過有一端必須是 FileChannel
相同環境下,BIO 的執行緒全程只處理一條連線,而 NIO 的執行緒可處理多個連線,提高了系統的吞吐能力。NIO 在伺服器進行縱向擴充套件(比如增加記憶體、CPU)或者橫向擴充套件(比如增加伺服器)往往能夠比 BIO 帶來更高的處理能力,使伺服器具有更強的可擴充套件性和可伸縮性。
1.4 零拷貝
NIO 還有一個零拷貝的概念,零拷貝是指 CPU 不執行將資料從一個儲存區複製到另一個儲存區的操作。OS 級別的零拷貝指的是將資料傳送到硬體驅動程式(網絡卡或磁碟驅動器)時避免從一個位置複製 到另一個位置(一般是從使用者空間到核心空間),反之亦然。NIO 中的零拷貝就是這樣,只不過它只針對在網路上傳送檔案。
2. I/O 模型的選擇
一般的,我們潛意識的會認為 NIO 比 BIO 的效能高,其實不盡然,當然了有個讀取方式的問題,read(byte[]) 和 read(ByteBuffer)應該沒區別吧?所以如果系統的併發量不高,兩個用誰都行。
BIO 的問題通常會在海量的連線下體現出來,由於它不能充分利用、壓榨一臺伺服器的效能,不管怎麼擴充套件,它能處理的連線數與機器效能往往是非線性的,付出和收穫不成正比。如果你的應用面臨的連線不斷增加,特別是存在大量的長連線,此時就要選擇 NIO,它不僅提高了單機處理能力,還能節省伺服器成本。
NIO 相比 BIO 的重點在於可擴充套件性,在選擇 I/O 模型時,需要結合業務場景,綜合考慮以下幾點:
- 預計最大的併發數
- 短連線還是長連線
- 預計每個連線的資料量,即流量的大小
- NIO 靈活,但代價是程式設計複雜
3. 程式設計模型
BIO 的程式設計模型是一連線一處理執行緒,採用執行緒池優化。
NIO 典型的程式設計模型是Reactor ,事件複用器通知套接字何時準備好讀取和寫入操作的事件,將事件傳遞給合適的處理程式,由該程式負責實際的讀取或寫入。對於讀操作基本過程如下:
- 處理程式宣告感興趣的 I/O 事件 - 讀取事件
- 事件複用器等待事件
- 一個事件發生,複用器被喚醒並呼叫適當的處理程式
- 處理程式執行實際的讀取操作,處理讀取的資料,重新宣告關注的 I/O 事件,並將控制權返回給排程程式
與 Reactor 相對的還有一個 AIO 的Proactor 模型,它是非同步 I/O ,事件複用器等待 I/O 操作完成的事件,它是真正的非同步,因為實際的 I/O 操作完全由作業系統執行。對於讀操作,它的做法是:
- 處理程式啟動非同步讀取操作,在這種情況下,處理程式不關心 I/O 就緒事件,而是關注接收完成的事件
- 事件複用器等待操作完成
- 當事件複用器等待時,OS 並行的在核心執行緒中執行讀操作,將資料放入使用者定義的緩衝區,讀取完成後通知事件多路複用器
- 事件複用器呼叫適當的處理程式
- 處理程式處理使用者定義緩衝區的資料,啟動新的非同步操作,並將控制返回給事件多路複用器
4. 小結
I/O 模型經常對比的是阻塞和非阻塞,還有同步和非同步,在《UNIX 網路程式設計》第6章已經給出了它們的區別,並且給出的圖示很直觀。這裡簡單做下說明,以 UDP 為例,讀取一個數據包可以分為兩個階段:
- 第一階段:應用程序發起系統呼叫,核心無資料包準備好,等待資料
- 第二階段:資料包準備好,OS 將資料從核心複製到使用者空間
阻塞和非阻塞描述的是第一階段無資料可讀取時執行緒是否掛起;同步和非同步描述的是第二階段,在資料複製過程中執行緒是否參與和掛起。注意 NIO/BIO 都是同步 I/O,NIO 對應 UNP 中描述的 I/O 複用模型。