---------
# Pre
我們在處理網絡問題時,經常是處理 I/O 問題——輸入和輸出。看上去很複雜,但說白了就是如何把網卡收到的資料給到指定的程式,然後程式如何将資料拷貝到網卡。
在處理 I/O 的時候,要結合具體的場景來思考程式怎麼寫。從程式的 API 設計上,我們經常會看到 3 類設計:BIO、NIO 和 AIO 。
從本質上說,讨論 BIO、NIO、AIO 的差別,其實就是在讨論 I/O 的模型,我們可以從下面 3 個方面來思考 。
- 程式設計模型:合理設計 API,讓程式寫得更舒服。
- 資料的傳輸和轉化成本:比如減少資料拷貝次數,合理壓縮資料等。
- 高效的資料結構:利用好緩沖區、紅黑樹等
--------
# I/O 的程式設計模型
我們先從程式設計模型上讨論下 BIO、NIO 和 AIO 的差別。
BIO(Blocking I/O,阻塞 I/O),API 的設計會阻塞程式調用。比如:
```bash
byte a = readKey()
```
假設readKey方法從鍵盤讀取一個按鍵,如果是非阻塞 I/O 的設計,readKey不會阻塞目前的線程。你可能會問:那如果使用者沒有按鍵怎麼辦?在阻塞 I/O 的設計中,如果使用者沒有按鍵線程會阻塞等待使用者按鍵,在非阻塞 I/O 的設計中,線程不會阻塞,沒有按鍵會傳回一個空值,比如 null。
最後我們說說 AIO(Asynchronous I/O, 異步 I/O),API 的設計會多創造一條時間線。比如
func callBackFunction(byte keyCode) {
// 處理按鍵
}
readKey( callBackFunction )
在異步 I/O 中,readKey方法會直接傳回,但是沒有結果。結果需要一個回調函數callBackFunction去接收。從這個角度看,其實有兩條時間線。第一條是程式的主幹時間線,readKey的執行到readKey下文的程式都在這條主幹時間線中。而callBackFunction的執行會在使用者按鍵時觸發,也就是時間不确定,是以callBackFunction中的程式是另一條時間線也是基于這種原因産生的,我們稱作異步,異步描述的就是這種時間線上無法同步的現象,你不知道callbackFunction何時會執行。
但是我們通常說某某語言提供了異步 I/O,不僅僅是說提供上面程式這種寫法,上面的寫法會産生一個叫作回調地獄的問題,本質是異步程式的時間線錯亂,導緻維護成本較高。
```java
request("/order/123", (data1) -> {
//..
request("/product/456", (data2) -> {
// ..
request("/sku/789", (data3) -> {
//...
})
})
})
比如上面這段程式(稱作回調地獄)維護成本較高,是以通常提供異步 API 程式設計模型時,我們會提供一種将異步轉化為同步程式的文法。比如下面這段僞代碼:
Future future1 = request("/order/123")
Future future2 = request("/product/456")
Future future3 = request("/sku/789")
// ...
order = future1.get()
product = future2.get()
sku = future3.get()
request 函數是一次網絡調用,請求訂單 ID=123 的訂單資料。本身 request 函數不會阻塞,會馬上執行完成,而網絡調用是一次異步請求,調用不會在request("/order/123")下一行結束,而是會在未來的某個時間結束。是以,我們用一個 Future 對象封裝這個異步操作。future.get()是一個阻塞操作,會阻塞直到網絡調用傳回。
在request和future.get之間,我們還可以進行很多别的操作,比如發送更多的請求。 像 Future 這樣能夠将異步操作再同步回主時間線的操作,我們稱作異步轉同步,也叫作異步程式設計。
# 資料的傳輸和轉化成本
上面我們從程式設計的模型上對 I/O 進行了思考,接下來我們從内部實作分析下 BIO、NIO 和 AIO。無論是哪種 I/O 模型,都要将資料從網卡拷貝到使用者程式(接收),或者将資料從使用者程式傳輸到網卡(發送)。
另一方面,有的資料需要編碼解碼,比如 JSON 格式的資料。還有的資料需要壓縮和解壓。資料從網卡到核心再到使用者程式是 2 次傳輸。注意,将資料從記憶體中的一個區域拷貝到另一個區域,這是一個 CPU 密集型操作。資料的拷貝歸根結底要一個位元組一個位元組去做。
<font color=brown>從網卡到核心空間的這步操作,可以用 DMA(Direct Memory Access)技術控制。DMA 是一種小型裝置,用 DMA 拷貝資料可以不使用 CPU,進而節省計算資源。遺憾的是,通常我們寫程式的時候,不能直接控制 DMA,是以 DMA 僅僅用于裝置傳輸資料到記憶體中。
<font color=brown>不過,從核心到使用者空間這次拷貝,可以用記憶體映射技術,将核心空間的資料映射到使用者空間。
![在這裡插入圖檔描述](https://img-blog.csdnimg.cn/20210712000129419.jpg#pic_center)
----------
# 資料結構運用
在處理網絡 I/O 問題的時候,還有一個重點問題要注意,就是資料結構的運用。
## 緩沖區
緩沖區是一種在處理 I/O 問題中常用的資料結構,
- 一方面**緩沖區起到緩沖作用**,在瞬時 I/O 量較大的時候,利用排隊機制進行處理。
- 另一方面,**緩沖區起到一個批處理的作用**,比如 1000 次 I/O 請求進入緩沖區,可以合并成 50 次 I/O 請求,那麼整體性能就會上一個檔次。
舉個例子,比如你有 1000 個訂單要寫入 MySQL,如果這個時候你可以将這 1000 次請求合并成 50 次,那麼磁盤寫入次數将大大減少。同理,假設有 10000 次網絡請求,如果可以合并發送,會減少 TCP 協定握手時間,可以最大程度地複用連接配接;另一方面,如果這些請求都較小,還可以粘包複用 TCP 段。在處理 Web 網站的時候,經常會碰到将多個 HTTP 請求合并成一個發送,進而減少整體網絡開銷的情況。
除了上述兩方面原因,**緩沖區還可以減少實際對記憶體的訴求**。資料在網卡到核心,核心到使用者空間的過程中,建議都要使用緩沖區。當收到的某個請求較大的時候,抽象成流,然後使用緩沖區可以減少對記憶體的使用壓力。這是因為使用了緩沖區和流,就不需要真的準備和請求資料大小一緻的記憶體空間了。可以将緩沖區大小規模的資料分成多次處理完,實際的記憶體開銷是緩沖區的大小
--------------
## I/O 多路複用模型
在運用資料結構的時候,還要思考 I/O 的多路複用用什麼模型。
假設你在處理一個高并發的網站,每秒有大量的請求打到你的伺服器上,你用多少個線程去處理 I/O 呢?對于沒有需要壓縮解壓的場景,處理 I/O 的主要開銷還是資料的拷貝。那麼一個 CPU 核心每秒可以完成多少次資料拷貝呢?
拷貝,其實就是将記憶體中的資料從一個位址拷貝到另一個位址。再加上有 **DMA,記憶體映射**等技術,拷貝是非常快的。不考慮 DMA 和記憶體映射,一個 3GHz 主頻的 CPU 每秒可以拷貝的資料也是百兆級别的。當然,速度還受限于記憶體本身的速度。**是以總的來說,I/O 并不需要很大的計算資源**。通常我們在處理高并發的時候,也不需要大量的線程去進行 I/O 處理。
對于多數應用來說,處理 I/O 的成本小于處理業務的成本。處理高并發的業務,可能需要大量的計算資源。每筆業務也可能會需要更多的 I/O,比如遠端的 RPC 調用等。
**是以我們在處理高并發的時候,一種常見的 I/O 多路複用模式就是由少量的線程處理大量的網絡接收、發送工作。然後再由更多的線程,通常是一個線程池處理具體的業務工作**。
<font color=red>在這樣一個模式下,有一個核心問題需要解決,就是當作業系統核心監測到一次 I/O 操作發生,它如何具體地通知到哪個線程調用哪段程式呢?
這時,**一種高效的模型會要求我們将線程、線程監聽的事件類型,以及響應的程式注冊到核心**。具體來說,比如某個用戶端發送消息到伺服器的時候,我們需要盡快知道哪個線程關心這條消息(處理這個資料)。例如 <font color=red>**epoll 就是這樣的模型,内部是紅黑樹。我們可以具體地看到檔案描述符構成了一棵紅黑樹,而紅黑樹的節點上挂着檔案描述符對應的線程、線程監聽事件類型以及相應程式**。
-------
講了這麼多,和 BIO、AIO、NIO 有什麼關系?這裡有兩個聯系。
**首先是無論哪種程式設計模型都需要使用緩沖區,也就是說 BIO、AIO、NIO 都需要緩沖區**,是以關系很大。在我們使用任何程式設計模型的時候,如果内部沒有使用緩沖區,那麼一定要在外部增加緩沖區。**另一個聯系是類似 epoll 這種注冊+消息推送的方式,可以幫助我們節省大量定位具體線程以及事件類型的時間。這是一個通用技巧,并不是獨有某種 I/O 模型才可以使用**。
不過從能力上分析,使用類似 epoll 這種模型,确實沒有必要讓處理 I/O 的線程阻塞,因為作業系統會将需要響應的事件源源不斷地推送給處理的線程,是以可以考慮不讓處理線程阻塞(比如用 NIO)
# 總結
從 3 個方面讨論了 I/O 模型。
- 第一個是**程式設計模型**,阻塞、非阻塞、異步 3 者 API 的設計會有比較大的差異。通常情況下我們說的異步程式設計是異步轉同步。異步轉同步最大的價值,就是提升代碼的可讀性。可讀,就意味着維護成本的下降以及擴充性的提升。
- 第二個在設計系統的 I/O 時,另一件需要考慮的就是**資料傳輸以及轉化的成本**。傳輸主要是拷貝,比如可以使用記憶體映射來減少資料的傳輸。但是這裡要注意一點,記憶體映射使用的記憶體是核心空間的緩沖區,是以千萬不要忘記回收。因為這一部分記憶體往往不在我們所使用的語言提供的記憶體回收機制的管控範圍之内。
- 最後是關于**資料結構**的運用,針對不同的場景使用不同的緩沖區,以及選擇不同的消息通知機制,也是處理高并發的一個核心問題。
從上面幾個角度去看 I/O 的模型,你會發現,程式設計模型是程式設計模型、資料的傳輸是資料的傳輸、消息的通知是消息的通知,它們是不同的子產品,完全可以解耦,也可以根據自己不同的業務特性進行選擇。雖然在一個完整的系統設計中,往往提出的是一套完整的解決方案 ,但實際上我們還是應該将它們分開去思考,這樣可以産生更好的設計思路。
# QA
## BIO、NIO 和 AIO 有什麼差別?
總的來說,這三者是三個 I/O 的程式設計模型。BIO 接口設計會直接導緻目前線程阻塞。NIO 的設計不會觸發目前線程的阻塞。AIO 為 I/O 提供了異步能力,也就是将 I/O 的響應程式放到一個獨立的時間線上去執行。但是通常 AIO 的提供者還會提供異步程式設計模型,就是實作一種對異步計算封裝的資料結構,并且提供将異步計算同步回主線的能力。
通常情況下,這 3 種 API 都會伴随 I/O 多路複用。**如果底層用紅黑樹管理注冊的檔案描述符和事件,可以在很小的開銷内由核心将 I/O 消息發送給指定的線程**。另外,還可以用 **DMA,記憶體映射等方式優化 I/O**。
## I/O 多路複用用協程和用線程的差別?
線程是執行程式的最小機關。I/O 多路複用時,會用單個線程處理大量的 I/O。還有一種執行程式的模型,叫協作程,協程是輕量級的線程。作業系統将執行資源配置設定給了線程,然後再排程線程運作。如果要實作協程,就要利用配置設定給線程的執行資源,在這之上再建立更小的執行機關。協程不歸作業系統排程,協程共享線程的執行資源。
而 I/O 多路複用的意義,是減少線程間的切換成本。是以從設計上,隻要是用單個線程處理大量 I/O 工作,線程和協程是一樣的,并無差別。如果是單線程處理大量 I/O,使用協程也是依托協程對應線程執行能力。