本文原題“終于明白了,一文徹底理解I/O多路復用”,轉載請聯系作者。
1、系列文章引言
1.1 文章目的
作為即時通訊技術的開發者來說,高性能、高并發相關的技術概念早就了然與胸,什么線程池、零拷貝、多路復用、事件驅動、epoll等等名詞信手拈來,又或許你對具有這些技術特征的技術框架比如:Java的Netty、Php的workman、Go的nget等熟練掌握。但真正到了面視或者技術實踐過程中遇到無法釋懷的疑惑時,方知自已所掌握的不過是皮毛。
返璞歸真、回歸本質,這些技術特征背后的底層原理到底是什么?如何能通俗易懂、毫不費力真正透徹理解這些技術背后的原理,正是《從根上理解高性能、高并發》系列文章所要分享的。
1.2 文章源起
我整理了相當多有關IM、消息推送等即時通訊技術相關的資源和文章,從最開始的開源IM框架MobileIMSDK,到網絡編程經典巨著《TCP/IP詳解》的在線版本,再到IM開發綱領性文章《新手入門一篇就夠:從零開發移動端IM》,以及網絡編程由淺到深的《網絡編程懶人入門》、《腦殘式網絡編程入門》、《高性能網絡編程》、《不為人知的網絡編程》系列文章。
越往知識的深處走,越覺得對即時通訊技術了解的太少。于是后來,為了讓開發者門更好地從基礎電信技術的角度理解網絡(尤其移動網絡)特性,我跨專業收集整理了《IM開發者的零基礎通信技術入門》系列高階文章。這系列文章已然是普通即時通訊開發者的網絡通信技術知識邊界,加上之前這些網絡編程資料,解決網絡通信方面的知識盲點基本夠用了。
對于即時通訊IM這種系統的開發來說,網絡通信知識確實非常重要,但回歸到技術本質,實現網絡通信本身的這些技術特征:包括上面提到的線程池、零拷貝、多路復用、事件驅動等等,它們的本質是什么?底層原理又是怎樣?這就是整理本系列文章的目的,希望對你有用。
1.3 文章目錄
《從根上理解高性能、高并發(一):深入計算機底層,理解線程與線程池》
《從根上理解高性能、高并發(二):深入操作系統,理解I/O與零拷貝技術》
《從根上理解高性能、高并發(三):深入操作系統,徹底理解I/O多路復用》(* 本文)
《從根上理解高性能、高并發(四):深入操作系統,徹底理解同步與異步 (稍后發布..)》
《從根上理解高性能、高并發(五):高并發高性能服務器到底是如何實現的 (稍后發布..)》
1.4 本篇概述
接上篇《深入操作系統,理解I/O與零拷貝技術》,本篇是高性能、高并發系列的第3篇文章,上篇里我們講到了I/O技術,本篇將以更具象的文件這個話題入手,帶你一步步理解高性能、高并發服務端編程時無法回避的I/O多路復用及相關技術。
本文已同步發布于“即時通訊技術圈”公眾號,歡迎關注。公眾號上的鏈接是:點此進入。
2、本文作者
應作者要求,不提供真名,也不提供個人照片。
本文作者主要技術方向為互聯網后端、高并發高性能服務器、檢索引擎技術,網名是“碼農的荒島求生”,公眾號“碼農的荒島求生”。感謝作者的無私分享。
3、什么是文件?
在正式展開本文的內容之前,我們需要先預習一下文件以及文件描述符的概念。
程序員使用I/O最終都逃不過文件這個概念。
在Linux世界中文件是一個很簡單的概念,作為程序員我們只需要將其理解為一個N byte的序列就可以了:
b1, b2, b3, b4, ....... bN
實際上所有的I/O設備都被抽象為了文件這個概念,一切皆文件(Everything is File),磁盤、網絡數據、終端,甚至進程間通信工具管道pipe等都被當做文件對待。
所有的I/O操作也都可以通過文件讀寫來實現,這一非常優雅的抽象可以讓程序員使用一套接口就能對所有外設I/O操作。
常用的I/O操作接口一般有以下幾類:
- 1)打開文件,open;
- 2)改變讀寫位置,seek;
- 3)文件讀寫,read、write;
- 4)關閉文件,close。
程序員通過這幾個接口幾乎可以實現所有I/O操作,這就是文件這個概念的強大之處。
4、什么是文件描述符?
在上一篇《深入操作系統,理解I/O與零拷貝技術》中我們講到:要想進行I/O讀操作,像磁盤數據,我們需要指定一個buff用來裝入數據。
一般都是這樣寫的:
read(buff);
但是這里我們忽略了一個關鍵問題:那就是,雖然我們指定了往哪里寫數據,但是我們該從哪里讀數據呢?
從上一節中我們知道,通過文件這個概念我們能實現幾乎所有I/O操作,因此這里少的一個主角就是文件。
那么我們一般都怎樣使用文件呢?
舉個例子:如果周末你去比較火的餐廳吃飯應該會有體會,一般周末人氣高的餐廳都會排隊,然后服務員會給你一個排隊序號,通過這個序號服務員就能找到你,這里的好處就是服務員無需記住你是誰、你的名字是什么、來自哪里、喜好是什么、是不是保護環境愛護小動物等等,這里的關鍵點就是:服務員對你一無所知,但依然可以通過一個號碼就能找到你。
同樣的:在Linux世界要想使用文件,我們也需要借助一個號碼,根據“弄不懂原則”,這個號碼就被稱為了文件描述符(file descriptors),在Linux世界中鼎鼎大名,其道理和上面那個排隊號碼一樣。
因此:文件描述僅僅就是一個數字而已,但是通過這個數字我們可以操作一個打開的文件,這一點要記住。
有了文件描述符,進程可以對文件一無所知,比如文件在磁盤的什么位置、加載到內存中又是怎樣管理的等等,這些信息統統交由操作系統打理,進程無需關心,操作系統只需要給進程一個文件描述符就足夠了。
因此我們來完善上述程序:
int fd = open(file_name); // 獲取文件描述符
read(fd, buff);
怎么樣,是不是非常簡單。
5、文件描述符太多了怎么辦?
經過了這么多的鋪墊,終于要到高性能、高并發這一主題了。
從前幾節我們知道,所有I/O操作都可以通過文件樣的概念來進行,這當然包括網絡通信。
如果你有一個IM服務器,當三次握手建議長連接成功以后,我們會調用accept來獲取一個鏈接,調用該函數我們同樣會得到一個文件描述符,通過這個文件描述符就可以處理客戶端發送的聊天消息并且把消息轉發給接收者。
也就是說,通過這個描述符我們就可以和客戶端進行通信了:
// 通過accept獲取客戶端的文件描述符
int conn_fd = accept(...);
Server端的處理邏輯通常是接收客戶端消息數據,然后執行轉發(給接收者)邏輯:
if(read(conn_fd, msg_buff) > 0) {
do_transfer(msg_buff);
}
是不是非常簡單,然而世界終歸是復雜的,當然也不是這么簡單的。
接下來就是比較復雜的了。
既然我們的主題是高并發,那么Server端就不可能只和一個客戶端通信,而是可能會同時和成千上萬個客戶端進行通信。這時你需要處理不再是一個描述符這么簡單,而是有可能要處理成千上萬個描述符。
為了不讓問題一上來就過于復雜,我們先簡單化,假設只同時處理兩個客戶端的請求。
有的同學可能會說,這還不簡單,這樣寫不就行了:
if(read(socket_fd1, buff) > 0) { // 處理第一個
do_transfer();
}
if(read(socket_fd2, buff) > 0) { // 處理第二個
do_transfer();
在上一篇《深入操作系統,理解I/O與零拷貝技術》中我們討論過,這是非常典型的阻塞式I/O,如果此時沒有數據可讀那么進程會被阻塞而暫停運行。這時我們就無法處理第二個請求了,即使第二個請求的數據已經就位,這也就意味著處理某一個客戶端時由于進程被阻塞導致剩下的所有其它客戶端必須等待,在同時處理幾萬客戶端的server上。這顯然是不能容忍的。
聰明的你一定會想到使用多線程:為每個客戶端請求開啟一個線程,這樣一個客戶端被阻塞就不會影響到處理其它客戶端的線程了。注意:既然是高并發,那么我們要為成千上萬個請求開啟成千上萬個線程嗎,大量創建銷毀線程會嚴重影響系統性能。
那么這個問題該怎么解決呢?
這里的關鍵點在于:我們事先并不知道一個文件描述對應的I/O設備是否是可讀的、是否是可寫的,在外設的不可讀或不可寫的狀態下進行I/O只會導致進程阻塞被暫停運行。
因此要優雅的解決這個問題,就要從其它角度來思考這個問題了。
6、“不要打電話給我,有需要我會打給你”
大家生活中肯定會接到過推銷電話,而且不止一個,一天下來接上十個八個推銷電話你的身體會被掏空的。
這個場景的關鍵點在于:打電話的人并不知道你是不是要買東西,只能來一遍遍問你。因此一種更好的策略是不要讓他們打電話給你,記下他們的電話,有需要的話打給他們,這樣推銷員就不會一遍一遍的來煩你了(雖然現實生活中這并不可能)。
在這個例子中:你,就好比內核,推銷者就好比應用程序,電話號碼就好比文件描述符,和你用電話溝通就好比I/O。
現在你應該明白了吧,處理多個文件描述符的更好方法其實就存在于推銷電話中。
因此相比上一節中:我們通過I/O接口主動問內核這些文件描述符對應的外設是不是已經就緒了,一種更好的方法是,我們把這些感興趣的文件描述符一股腦扔給內核,并霸氣的告訴內核:“我這里有1萬個文件描述符,你替我監視著它們,有可以讀寫的文件描述符時你就告訴我,我好處理”。而不是弱弱的問內核:“第一個文件描述可以讀寫了嗎?第二個文件描述符可以讀寫嗎?第三個文件描述符可以讀寫了嗎?。。。”
這樣:應用程序就從“繁忙”的主動變為了清閑的被動,反正文件描述可讀可寫了內核會通知我,能偷懶我才不要那么勤奮。
這是一種更加高效的I/O處理機制,現在我們可以一次處理多路I/O了,為這種機制起一個名字吧,就叫I/O多路復用吧,這就是 I/O multiplexing。
7、I/O多路復用(I/O multiplexing)
multiplexing一詞其實多用于通信領域,為了充分利用通信線路,希望在一個信道中傳輸多路信號,要想在一個信道中傳輸多路信號就需要把這多路信號結合為一路,將多路信號組合成一個信號的設備被稱為Multiplexer(多路復用器),顯然接收方接收到這一路組合后的信號后要恢復原先的多路信號,這個設備被稱為Demultiplexer(多路分用器)。
如下圖所示:
回到我們的主題。
所謂I/O多路復用指的是這樣一個過程:
- 1)我們拿到了一堆文件描述符(不管是網絡相關的、還是磁盤文件相關等等,任何文件描述符都可以);
- 2)通過調用某個函數告訴內核:“這個函數你先不要返回,你替我監視著這些描述符,當這堆文件描述符中有可以進行I/O讀寫操作的時候你再返回”;
- 3)當調用的這個函數返回后我們就能知道哪些文件描述符可以進行I/O操作了。
也就是說通過I/O多路復用我們可以同時處理多路I/O。那么有哪些函數可以用來進行I/O多路復用呢?
以Linux為例,有這樣三種機制可以用來進行I/O多路復用:
接下來我們就來介紹一下牛掰的I/O多路復用三劍客。
8、I/O多路復用三劍客
本質上:Linux上的select、poll、epoll都是阻塞式I/O,也就是我們常說的同步I/O。
原因在于:調用這些I/O多路復用函數時如果任何一個需要監視的文件描述符都不可讀或者可寫那么進程會被阻塞暫停執行,直到有文件描述符可讀或者可寫才繼續運行。
8.1 select:初出茅廬
在select這種I/O多路復用機制下,我們需要把想監控的文件描述集合通過函數參數的形式告訴select,然后select會將這些文件描述符集合拷貝到內核中。
我們知道數據拷貝是有性能損耗的,因此為了減少這種數據拷貝帶來的性能損耗,Linux內核對集合的大小做了限制,并規定用戶監控的文件描述集合不能超過1024個,同時當select返回后我們僅僅能知道有些文件描述符可以讀寫了,但是我們不知道是哪一個。因此程序員必須再遍歷一邊找到具體是哪個文件描述符可以讀寫了。
因此,總結下來select有這樣幾個特點:
- 1)我能照看的文件描述符數量有限,不能超過1024個;
- 2)用戶給我的文件描述符需要拷貝的內核中;
- 3)我只能告訴你有文件描述符滿足要求了,但是我不知道是哪個,你自己一個一個去找吧(遍歷)。
因此我們可以看到,select機制的這些特性在高并發網絡服務器動輒幾萬幾十萬并發鏈接的場景下無疑是低效的。
8.2 poll:小有所成
poll和select是非常相似的。
poll相對于select的優化僅僅在于解決了文件描述符不能超過1024個的限制,select和poll都會隨著監控的文件描述數量增加而性能下降,因此不適合高并發場景。
8.3 epoll:獨步天下
在select面臨的三個問題中,文件描述數量限制已經在poll中解決了,剩下的兩個問題呢?
針對拷貝問題:epoll使用的策略是各個擊破與共享內存。
實際上:文件描述符集合的變化頻率比較低,select和poll頻繁的拷貝整個集合,內核都快被煩死了,epoll通過引入epoll_ctl很體貼的做到了只操作那些有變化的文件描述符。同時epoll和內核還成為了好朋友,共享了同一塊內存,這塊內存中保存的就是那些已經可讀或者可寫的的文件描述符集合,這樣就減少了內核和程序的拷貝開銷。
針對需要遍歷文件描述符才能知道哪個可讀可寫這一問題,epoll使用的策略是“當小弟”。
在select和poll機制下:進程要親自下場去各個文件描述符上等待,任何一個文件描述可讀或者可寫就喚醒進程,但是進程被喚醒后也是一臉懵逼并不知道到底是哪個文件描述符可讀或可寫,還要再從頭到尾檢查一遍。
但epoll就懂事多了,主動找到進程要當小弟替大哥出頭。
在這種機制下:進程不需要親自下場了,進程只要等待在epoll上,epoll代替進程去各個文件描述符上等待,當哪個文件描述符可讀或者可寫的時候就告訴epoll,epoll用小本本認真記錄下來然后喚醒大哥:“進程大哥,快醒醒,你要處理的文件描述符我都記下來了”,這樣進程被喚醒后就無需自己從頭到尾檢查一遍,因為epoll小弟都已經記下來了。
因此我們可以看到:在epoll這種機制下,實際上利用的就是“不要打電話給我,有需要我會打給你”這種策略,進程不需要一遍一遍麻煩的問各個文件描述符,而是翻身做主人了——“你們這些文件描述符有哪個可讀或者可寫了主動報上來”。
這種機制實際上就是大名鼎鼎的事件驅動——Event-driven,這也是我們下一篇的主題。
實際上:在Linux平臺,epoll基本上就是高并發的代名詞。
9、本文小結
基于一切皆文件的設計哲學,I/O也可以通過文件的形式實現,高并發場景下要與多個文件交互,這就離不開高效的I/O多路復用技術。
本文我們詳細講解了什么是I/O多路復用以及使用方法,這其中以epoll為代表的I/O多路復用(基于事件驅動)技術使用非常廣泛,實際上你會發現但凡涉及到高并發、高性能的場景基本上都能見到事件驅動的編程方法,當然這也是下一篇我們要重點講解的主題《從根上理解高性能、高并發(四):深入操作系統,徹底理解同步與異步》,敬請期待!
附錄:更多高性能、高并發文章精選
《高性能網絡編程(一):單臺服務器并發TCP連接數到底可以有多少》
《高性能網絡編程(二):上一個10年,著名的C10K并發連接問題》
《高性能網絡編程(三):下一個10年,是時候考慮C10M并發問題了》
《高性能網絡編程(四):從C10K到C10M高性能網絡應用的理論探索》
《高性能網絡編程(五):一文讀懂高性能網絡編程中的I/O模型》
《高性能網絡編程(六):一文讀懂高性能網絡編程中的線程模型》
《高性能網絡編程(七):到底什么是高并發?一文即懂!》
《以網游服務端的網絡接入層設計為例,理解實時通信的技術挑戰》
《知乎技術分享:知乎千萬級并發的高性能長連接網關技術實踐》
《淘寶技術分享:手淘億級移動端接入層網關的技術演進之路》
《一套海量在線用戶的移動端IM架構設計實踐分享(含詳細圖文)》
《一套原創分布式即時通訊(IM)系統理論架構方案》
《微信后臺基于時間序的海量數據冷熱分級架構設計實踐》
《微信技術總監談架構:微信之道——大道至簡(演講全文)》
《如何解讀《微信技術總監談架構:微信之道——大道至簡》》
《快速裂變:見證微信強大后臺架構從0到1的演進歷程(一)》
《17年的實踐:騰訊海量產品的技術方法論》
《騰訊資深架構師干貨總結:一文讀懂大型分布式系統設計的方方面面》
《以微博類應用場景為例,總結海量社交系統的架構設計步驟》
《新手入門:零基礎理解大型分布式架構的演進歷史、技術原理、最佳實踐》
《從新手到架構師,一篇就夠:從100到1000萬高并發的架構演進之路》
本文已同步發布于“即時通訊技術圈”公眾號。

▲ 本文在公眾號上的鏈接是:點此進入。同步發布鏈接是:http://www.52im.net/thread-3287-1-1.html