從根上理解高性能、高併發(三):深刻操做系統,完全理解I/O多路複用

一、系列文章引言

1.1 文章目的

做爲即時通信技術的開發者來講,高性能、高併發相關的技術概念早就瞭然與胸,什麼線程池、零拷貝、多路複用、事件驅動、epoll等等名詞信手拈來,又或許你對具備這些技術特徵的技術框架好比:Java的Netty、Php的workman、Go的nget等熟練掌握。但真正到了面視或者技術實踐過程當中遇到沒法釋懷的疑惑時,方知自已所掌握的不過是皮毛。php

返璞歸真、迴歸本質,這些技術特徵背後的底層原理究竟是什麼?如何能通俗易懂、絕不費力真正透徹理解這些技術背後的原理,正是《從根上理解高性能、高併發》系列文章所要分享的。html

1.2 文章源起

我整理了至關多有關IM、消息推送等即時通信技術相關的資源和文章,從最開始的開源IM框架MobileIMSDK,到網絡編程經典鉅著《TCP/IP詳解》的在線版本,再到IM開發綱領性文章《新手入門一篇就夠:從零開發移動端IM》,以及網絡編程由淺到深的《網絡編程懶人入門》、《腦殘式網絡編程入門》、《高性能網絡編程》、《鮮爲人知的網絡編程》系列文章。git

越往知識的深處走,越以爲對即時通信技術瞭解的太少。因而後來,爲了讓開發者門更好地從基礎電信技術的角度理解網絡(尤爲移動網絡)特性,我跨專業收集整理了《IM開發者的零基礎通訊技術入門》系列高階文章。這系列文章已然是普通即時通信開發者的網絡通訊技術知識邊界,加上以前這些網絡編程資料,解決網絡通訊方面的知識盲點基本夠用了。程序員

對於即時通信IM這種系統的開發來講,網絡通訊知識確實很是重要,但迴歸到技術本質,實現網絡通訊自己的這些技術特徵:包括上面提到的線程池、零拷貝、多路複用、事件驅動等等,它們的本質是什麼?底層原理又是怎樣?這就是整理本系列文章的目的,但願對你有用。github

1.3 文章目錄

從根上理解高性能、高併發(一):深刻計算機底層,理解線程與線程池
從根上理解高性能、高併發(二):深刻操做系統,理解I/O與零拷貝技術
從根上理解高性能、高併發(三):深刻操做系統,完全理解I/O多路複用》(* 本文)
《從根上理解高性能、高併發(四):深刻操做系統,完全理解同步與異步 (稍後發佈..)》
《從根上理解高性能、高併發(五):高併發高性能服務器究竟是如何實現的 (稍後發佈..)》

1.4 本篇概述

接上篇《深刻操做系統,理解I/O與零拷貝技術》,本篇是高性能、高併發系列的第3篇文章,上篇裏咱們講到了I/O技術,本篇將以更具象的文件這個話題入手,帶你一步步理解高性能、高併發服務端編程時沒法迴避的I/O多路複用及相關技術。編程

本文已同步發佈於「即時通信技術圈」公衆號,歡迎關注。公衆號上的連接是:點此進入後端

二、本文做者

應做者要求,不提供真名,也不提供我的照片。服務器

本文做者主要技術方向爲互聯網後端、高併發高性能服務器、檢索引擎技術,網名是「碼農的荒島求生」,公衆號「碼農的荒島求生」。感謝做者的無私分享。微信

三、什麼是文件?

在正式展開本文的內容以前,咱們須要先預習一下文件以及文件描述符的概念。網絡

程序員使用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操做,這就是文件這個概念的強大之處。

四、什麼是文件描述符?

在上一篇《深刻操做系統,理解I/O與零拷貝技術》中咱們講到:要想進行I/O讀操做,像磁盤數據,咱們須要指定一個buff用來裝入數據。

通常都是這樣寫的:

read(buff);

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

從上一節中咱們知道,經過文件這個概念咱們能實現幾乎全部I/O操做,所以這裏少的一個主角就是文件。

那麼咱們通常都怎樣使用文件呢?

舉個例子:若是週末你去比較火的餐廳吃飯應該會有體會,通常週末人氣高的餐廳都會排隊,而後服務員會給你一個排隊序號,經過這個序號服務員就能找到你,這裏的好處就是服務員無需記住你是誰、你的名字是什麼、來自哪裏、喜愛是什麼、是否是保護環境愛護小動物等等,這裏的關鍵點就是:服務員對你一無所知,但依然能夠經過一個號碼就能找到你。

一樣的:在Linux世界要想使用文件,咱們也須要藉助一個號碼,根據「弄不懂原則」,這個號碼就被稱爲了文件描述符(file descriptors),在Linux世界中鼎鼎大名,其道理和上面那個排隊號碼同樣。

所以:文件描述僅僅就是一個數字而已,可是經過這個數字咱們能夠操做一個打開的文件,這一點要記住。

有了文件描述符,進程能夠對文件一無所知,好比文件在磁盤的什麼位置、加載到內存中又是怎樣管理的等等,這些信息通通交由操做系統打理,進程無需關心,操做系統只須要給進程一個文件描述符就足夠了。

所以咱們來完善上述程序:

int fd = open(file_name); // 獲取文件描述符
read(fd, buff);

怎麼樣,是否是很是簡單。

五、文件描述符太多了怎麼辦?

通過了這麼多的鋪墊,終於要到高性能、高併發這一主題了。

從前幾節咱們知道,全部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只會致使進程阻塞被暫停運行。

所以要優雅的解決這個問題,就要從其它角度來思考這個問題了。

六、「不要打電話給我,有須要我會打給你」

你們生活中確定會接到過推銷電話,並且不止一個,一天下來接上十個八個推銷電話你的身體會被掏空的。

這個場景的關鍵點在於:打電話的人並不知道你是否是要買東西,只能來一遍遍問你。所以一種更好的策略是不要讓他們打電話給你,記下他們的電話,有須要的話打給他們,這樣推銷員就不會一遍一遍的來煩你了(雖然現實生活中這並不可能)。

在這個例子中:你,就比如內核,推銷者就比如應用程序,電話號碼就比如文件描述符,和你用電話溝通就比如I/O。

如今你應該明白了吧,處理多個文件描述符的更好方法其實就存在於推銷電話中。

所以相比上一節中:咱們經過I/O接口主動問內核這些文件描述符對應的外設是否是已經就緒了,一種更好的方法是,咱們把這些感興趣的文件描述符一股腦扔給內核,並霸氣的告訴內核:「我這裏有1萬個文件描述符,你替我監視着它們,有能夠讀寫的文件描述符時你就告訴我,我好處理」。而不是弱弱的問內核:「第一個文件描述能夠讀寫了嗎?第二個文件描述符能夠讀寫嗎?第三個文件描述符能夠讀寫了嗎?。。。」

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

這是一種更加高效的I/O處理機制,如今咱們能夠一次處理多路I/O了,爲這種機制起一個名字吧,就叫I/O多路複用吧,這就是 I/O multiplexing。

七、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多路複用:

  • 1)select;
  • 2)poll;
  • 3)epoll。

接下來咱們就來介紹一下牛掰的I/O多路複用三劍客。

八、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基本上就是高併發的代名詞。

九、本文小結

基於一切皆文件的設計哲學,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