高併發的祕訣:I-O 多路複用

程序員編寫代碼執行 I/O 操作最終都逃不過文件這個概念。

在 Unix/Linux 世界中,文件是一個很簡單的概念,作爲程序員我們只需要將其理解爲一個 N 字節的序列就可以了:

b1, b2, b3, b4, ....... , bN

實際上,所有的 I/O 設備都被抽象爲了文件這個概念,一切皆文件(Everything is  File),磁盤、網絡數據、終端,甚至進程間通信工具管道 pipe 等都被當成文件對待。

所有的 I/O 操作也都可以通過文件讀寫來實現,這一抽象可以讓程序員使用一套接口 就能操作所有外部設備,如用 open 打開文件、read/write 讀寫文件、seek 改變讀寫位置、 close 關閉文件等,這就是文件這個概念的強大之處。

01 文件描述符

《計算機底層的祕密》一書的 6.3 節講到用 read 讀取文件內容時,代碼是這樣寫的:

read(buffer);

但這裏忽略了一個關鍵問題,那就是雖然指定了往 buffer 中寫數據,但是我們該從哪裏讀數據呢?

這裏缺少的就是文件,該怎樣使用文件呢?

大家都知道,在週末人氣高的餐廳通常都會排隊,然後服務員會給你一個排隊序 號,通過這個序號服務員就能找到你,這裏的好處就是服務員不需要記住你是誰、你的名字是什麼、來自哪裏、喜好是什麼、是不是保護環境愛護小動物,等等,這裏的關鍵點就是服務員對你一無所知,但依然可以通過一個號碼找到你。

同樣地,在 Unix/Linux 世界要想使用文件,我們也需要藉助一個號碼,這個號碼就被稱爲文件描述符(file descriptors),其道理和上面那個排隊使用的號碼一樣,因此,文件描述僅僅就是一個數字而已。當打開文件時內核會返回給我們一個文件描述符,當進行文件操作時我們需要把該描述符告訴內核,內核獲取到這個數字後就能找到該數字所對應文件的一切信息並完成文件操作。

儘管外部設備千奇百怪,這些設備在內核中的表示以及處理方法也各不相同,但這些都不需要暴露給程序員,程序員需要知道的就是文件描述符這個數字而已。

使用文件描述符來處理 I/O 如圖 1 所示。

圖 1 使用文件描述符來處理 I/O

有了文件描述符,進程可以對文件一無所知,如文件是否存儲在磁盤上、存儲在磁盤的什麼位置、當前讀取到了哪裏等,這些信息統統交由操作系統打理,進程不需要關心,程序員只需要針對文件描述符編程就足夠了。

因此,我們來完善之前的文件讀取程序:

char buffer[LEN];
int fd = open(file_name); // 獲取文件描述符
read(fd, buffer);

怎麼樣,是不是非常簡單。

02 如何高效處理多個 I/O

經過了這麼多的鋪墊,終於來到高併發這一主題了,這裏的高併發主要指服務器可以同時處理很多用戶請求,現在的網絡通信多使用 socket 編程,這也離不開文件描述符。

如果你有一個 web 服務器,三次握手成功以後通過調用 accept 來獲取一個鏈接,調用該函數後我們同樣會得到一個文件描述符,通過這個描述符我們就可以和客戶端進行通信了。

// 通過accept獲取客戶端的文件描述符
int conn_fd = accept(...);

服務器的處理邏輯通常是讀取客戶端請求數據,然後執行某些處理邏輯:

if(read(conn_fd, buff) > 0) { 
 do_something(buff); 
}

是不是非常簡單。

既然我們的主題是高併發,那麼服務器就不可能只和一個客戶端通信了,而是可能會同時和成千上萬個客戶端進行通信,這時你需要處理的就不再是一個描述符這麼簡單,而是有可能要處理成千上萬個描述符。

爲了簡單起見,現在我們假設該服務器只需要同時處理兩個客戶端的請求,有的讀者可能會說,這還不容易,一個接一個地處理不就行了:

if(read(socket_fd1, buff) > 0) { 
// 處理第一個
do_something();
} 
if(read(socket_fd2, buff) > 0) {
// 處理第二個
do_something(); 
}

這裏的 read 函數通常是阻塞式 I/O,如果此時第一個用戶並沒有發送任何數據,那麼該代碼所在線程會被阻塞而暫停運行,這時我們就無法處理第二個請求了,即使第二個用戶已經發出了請求數據,這對需要同時處理成千上萬個客戶端的 server 來說是不能容忍的。

聰明的你一定會想到使用多線程,爲每個客戶端請求開啓一個線程,這樣即使某個線程被阻塞也不會影響到處理其他線程,但這種方法的問題在於隨着線程數量的增加, 線程調度及切換的開銷將開始增加,這顯然無法很好地應對高併發場景。

這個問題該怎麼解決呢?

這裏的關鍵點在於,我們事先並不知道一個文件描述對應的 I/O 設備是否是可讀的、是否是可寫的,在外部設備不可讀或不可寫的狀態下發起 I/O 只會導致線程被阻塞而暫停運行。

我們需要改變思路。

03 不要打電話給我,有必要我會打給你

大家生活中肯定會接到過推銷電話,而且肯定不止一個,這裏的關鍵點在於推銷員並不知道你是不是要買東西,只能來一遍一遍問你,因此一種更好的策略是不要讓他們打電話給你,記下他們的電話,有需要的話打給他們,這樣推銷員就不會一遍一遍地來煩你了(雖然現實生活中這並不可能)。

在這個例子中,你就好比內核,推銷員就好比應用程序,電話號碼就好比文件描述符,推銷員與你用電話溝通就好比 I/O,處理多個文件描述符的更好方法其實就在於 “不 要總打電話給內核,有必要的話內核會通知你”。

因此,相比《計算機底層的祕密》一書 6.3 節中我們通過 read 函數主動問內核該文件描述符對應的文件是否有數據可讀,一種更好的方法是,我們把這些感興趣的文件描述符一股腦扔給內核,並告訴內核:“我這裏有 1 萬個文件描述符,你替我監視着它們,有可以讀寫的文件描述符時 你就告訴我,我好處理”,而不是一遍一遍地問:“第一個文件描述可以讀寫了嗎?第二個文件描述符可以讀寫嗎?第三個文件描述符可以讀寫了嗎?…” 

這樣應用程序就從繁忙的主動變爲了清閒的被動——反正文件描述可讀可寫時內核會通知我,能偷懶我纔不要那麼勤奮。

這是一種方便程序員同時處理多個文件描述符的方法,這就是 I/O 多路複用技術(I/O  multiplexing)。

04 I/O 多路複用,I/O multiplexing

multiplexing 一詞其實多用於通信領域,爲充分利用通信線路,希望在一個信道 中傳輸多路信號,爲此需要將多路信號組合爲一路,對多路信號進行組合的設備被稱爲 multiplexer,顯然接收方接收到信號後要恢復原先的多路信號,這個設備被稱爲 demultiplexer,如圖 2 所示。

圖 2 通信領域中的多路複用

回到我們的主題。

I/O 多路複用指的是這樣一個過程:

(1)我們得到了一堆文件描述符,不管是與網絡相關的,還是與文件相關等,任何 文件描述符都可以;

(2)通過調用某個函數告訴內核:“這個函數你先不要返回,你替我監視着這些描 述符,當其中有可以進行讀寫操作的文件描述符時你再返回”;

(3)該函數返回後我們即可獲取到具備讀寫條件的文件描述,符並對其進行相應的處理。

通過該技術我們可以一次處理多路 I/O,在 Linux 世界中使用 I/O 多路複用時有這樣三種方式:select、poll 和 epoll。

接下來,我們簡單介紹一下這 I/O 多路複用技術三劍客。

05 三劍客:select、poll 與 epoll

本質上 select、poll、epoll 都是同步 I/O 多路複用機制,原因在於調用這些函數時如果所需要監控的文件描述符都沒有我們感興趣的事件(如可讀可寫等)出現時,那麼調用線程會被阻塞而暫停運行,直到有文件描述符產生這樣的事件時該函數纔會返回。

在 select 這種 I/O 多路複用機制下,我們能監控的文件描述集合是有限制的,通常不能超過 1024 個,從該機制的實現上看,當調用 select 時會將相應的進程(線程)放到被監控文件的等待隊列上,此時進程(線程)會因調用 select 而阻塞暫停運行,當任何一個被監聽文件描述符出現,如可讀或可寫事件時,就喚醒相應的進程(線程),但這裏的問題是當進程被喚醒後程序員並不知道到底是哪個文件描述符可讀或可寫,因此要想知道哪些文件描述符已經就緒就必須從頭到尾再檢查一遍,這是 select 在監控大量文件描述符時低效的根本原因所在。

poll 和 select 是非常相似的,poll 相對於 select 的優化僅僅在於解決了被監控文件描述符不能超過 1024 個的限制,poll 同樣會有隨着監控文件描述數量增加而出現性能下降的問題,無法很好地應對高併發場景,爲解決這一問題 epoll 應運而生。 

epoll 解決問題的思路是在內核中創建必要的數據結構,該數據結構中比較重要的字段是一個就緒文件描述符列表,當任何一個被監聽文件描述符出現我們感興趣的事件時,除了喚醒相應的進程之外還會把就緒的文件描述符添加到就緒列表中,這樣進程 (線程)被喚醒後可以直接獲取就緒文件描述符而不需要從頭到尾把所有文件描述符都遍歷一邊,非常高效。 

實際上在 Linux 平臺,epoll 基本上就是高併發的代名詞,大量與網絡相關的框架、庫等在其底層都能見到 epoll 的身影。

以上就是關於 I/O 多路複用的講解!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/r6dattEKm9FDqy40mTw3LQ