IO多路轉接之select、poll、epoll

目錄

select

poll

epoll


IO分兩步:<1> 等         <2> 數據拷貝

  • 高效IO:拷貝數據的比重越高 --> 大部分時間進行數據傳輸 --> IO越高效
  • 低效IO:等待的比重越高 -->大部分時間在阻塞等待-->IO越低效

 

五種IO模型:(釣魚例子 【前四種爲同步IO,第五種是異步IO】)

  • 阻塞IO: 在內核將數據準備好之前, 系統調用會⼀直等待,所有的套接字, 默認都是阻塞方式  (最常見的IO模型)
  • 非阻塞IO: 如果內核還未將數據準備好, 系統調用仍然會直接返回, 並且返回錯誤碼 (常以輪詢方式讀寫fd,浪費資源)
  • 信號驅動IO: 內核將數據準備好的時候, 使用SIGIO信號通知應用程序進行數據拷貝操作
  • IO多路轉接: 與阻塞IO類似,實際上最核心在於IO多路轉接能夠同時等待多個文件描述符的就緒狀態,有效減少等待時間
  • 異步IO: 由內核在數據拷貝完成時, 通知應用程序(而信號驅動是告訴應用程序何時可以開始拷貝數據)

I/O多路轉接 也叫做 I/O多路複用:

IO多路複用是指內核一旦發現進程指定的一個或者多個IO條件準備讀取,它就通知該進程。

IO多路複用適用如下場合:

  • (1)當客戶處理多個描述字時(一般是交互式輸入和網絡套接口),必須使用I/O複用。
  • (2)當一個客戶同時處理多個套接口時,而這種情況是可能的,但很少出現。
  • (3)如果一個TCP服務器既要處理監聽套接口,又要處理已連接套接口,一般也要用到I/O複用。
  • (4)如果一個服務器即要處理TCP,又要處理UDP,一般要使用I/O複用。
  • (5)如果一個服務器要處理多個服務或多個協議,一般要使用I/O複用。

  與多進程和多線程技術相比,I/O多路複用技術的最大優勢是系統開銷小,系統不必創建進程/線程,也不必維護這些進程/線程,從而大大減小了系統的開銷。


接下來我們來了解I/O多路轉接下的三種方式:

select

一、函數原型:

        select只負責等,用戶將需要監聽的文件描述符集合通知給select,當有一個或多個文件描述符就位的時候,它就返回已就緒的文件描述符的數目。

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
返回值:就緒描述符的數目,超時返回0,出錯返回-1


maxfdp1:需要監視的最大的文件描述符值+1.

readset、writeset、exceptset:分別對應於需要檢測的可讀⽂件描述符的集合,可寫⽂件描述符的集合及異常⽂件描述符的集合;如果對某一個的條件不感興趣,就可以把它設爲空指針。
struct fd_set可以理解爲一個位圖,這個集合中存放的是文件描述符,※※※既是輸入型參數,又是輸出型參數,用來傳遞信息,可通過以下四個宏進行設置:
          void FD_ZERO(fd_set *fdset);           //清空集合

          void FD_SET(int fd, fd_set *fdset);   //將一個給定的文件描述符加入集合之中

          void FD_CLR(int fd, fd_set *fdset);   //將一個給定的文件描述符從集合中刪除

          int FD_ISSET(int fd, fd_set *fdset);   // 檢查集合中指定的文件描述符是否可以讀寫

timeout:告知內核等待所指定描述字中的任何一個就緒可花多少時間。其timeval結構⽤來設置select()的等待時間,該參數有三個返回值:
         執⾏成功則返回⽂件描述詞狀態已改變的個數
         如果返回0代表在描述詞狀態改變前已超過timeout時間,沒有返回
         當有錯誤發⽣時則返回-1,錯誤原因存於errno,此時參數readfds,writefds, exceptfds和timeout的值變成不可預測。

二、理解select的執行過程:

        理解select模型的關鍵在於理解fd_set,爲說明⽅便,取fd_set⻓度爲1字節(8bite),fd_set中的每⼀bit可以對應⼀個文件描述符fd。則1字節⻓的fd_set最⼤可以對應8個fd. *

 

三、select的特點:

 <1>可監控的文件描述符個數取決與sizeof(fdset)的值, 若服務器上sizeof(fdset)=128,每一個bit表示⼀個文件描述符,則服務器上支持的最大文件描述符是128*8=1024

<2>將fd加入select監控集的同時,還要再使用⼀個數組array保存放到select監控集中的fd. ⼀是用於在select 返回後,array作爲源數據和fdset進行FDISSET判斷。 ⼆是select返回後會把以前加入的但並無事件發生的fd清空(參考上文執行過程),則每次開始select前都要重新從array取得fd逐⼀加入(FD_ZERO最先),掃描array的同時取得fd最大值maxfd,用於select的第⼀個參數。     ( fd_set的大小可以調整,涉及到重新編譯內核)  

  • (1)執行fdset set:FDZERO(&set);則set用位表示是0000,0000。
  • (2)若fd=5,執行 FD_SET(fd,&set);後set變爲0001,0000(第5位置爲1)
  • (3)若再加入fd=2,fd=1,則set變爲0001,0011
  • (4)執行select(6,&set,0,0,0)阻塞等待
  • (5)若fd=1,fd=2上都發生可讀事件,則select返回,此時set 變爲0000 0011 (注意:沒有事件發⽣的fd=5被清空)

四、select的缺點:(優點:可以同時處理多個請求 , 效率相對而言高)

  • 每次調用select, 都需要手動設置fd_set集合, 從接口使用角度來說也非常不便.
  • 每次調用用select,都需要把fd_set集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大.
  • 每次調用select都需要在內核(輪詢)遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大. 
  • select支持的文件描述符數量太小,原因:fd_set決定了select同時管理的鏈接數是有上限的(通常是128*8=1024).

適用場景:擁有大量連接但是隻有少量連接是活躍的。


poll

        poll的機制與select類似,與select在本質上沒有多大差別,管理多個描述符也是進行輪詢,根據描述符的狀態進行處理,但是poll沒有最大文件描述符數量的限制poll和select同樣存在一個缺點就是,包含大量文件描述符的數組被整體複製於用戶態和內核的地址空間之間,而不論這些文件描述符是否就緒,它的開銷隨着文件描述符數量的增加而線性增大。

一、函數原型:

   調用時用戶告訴系統關心fd描述符的events事件,返回時系統會告訴用戶該事件已就緒。

# include <poll.h>
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);

//fds是⼀個poll函數監聽的結構列表. 每⼀個元素中, 包含了三部分內容: ⽂件描述符, 監聽的事件集
//合, 返回的事件集合.

//nfds表⽰fds數組的⻓度.

//timeout表⽰poll函數的超時時間, 單位是毫秒(ms)

struct pollfd
{
  int fd;             /* 文件描述符 */
  short events;       /* 等待的事件 */
  short revents;      /* 實際發生了的事件 */
} ; 

/*
返回值⼩於0, 表⽰出錯;
返回值等於0, 表⽰poll函數等待超時;
返回值⼤於0, 表⽰poll由於監聽的⽂件描述符就緒⽽返回.
*/

/*
events和revents的取值:

 事件                描述                            
POLLIN         有數據可讀。
POLLRDNORM        有普通數據可讀。
POLLRDBAND        有優先數據可讀。
POLLPRI        有緊迫數據可讀。
POLLOUT          寫數據不會導致阻塞。
POLLWRNORM       寫普通數據不會導致阻塞。
POLLWRBAND        寫優先數據不會導致阻塞。
POLLMSGSIGPOLL    消息可用。
POLLER             指定的文件描述符發生錯誤。
POLLHUP            指定的文件描述符掛起事件。
POLLNVAL           指定的文件描述符非法。
*/

二、poll的優點:

  • 不同與select使用三個位圖來表示三個fdset的⽅式,poll使用⼀個pollfd的指針實現.
  • pollfd結構包含了要監視的event和發生的event,將輸入、輸出的參數分開,不用每次對參數進行重新設置。不再使用select“參數-值”傳遞的方式. 接口使用比select更方便.
  • poll並沒有最大文件描述符數量限制 (但是數量過大後性能也是會下降).     /*與select最大的區別*/

三、poll的缺點:

poll中監聽的文件描述符數目增多時 :

  • 和select函數⼀樣,poll返回後,需要輪詢pollfd來獲取就緒的描述符(revents)
  • 每次調用poll都需要把⼤量的pollfd結構從用戶態拷貝到內核中.
  • 同時連接的⼤量客戶端在⼀時刻可能只有很少的處於就緒狀態, 因此隨着監視的描述符數量的增長, 其效率也會線性下降.

epoll

        epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎麼解決的呢?在此之前,我們先看一下epoll和select和poll的調用接口上的不同,select和poll都只提供了一個函數——select或者poll函數。而epoll提供了三個函數epoll_create、epoll_ctl、epoll_wait,epoll_create是創建一個epoll句柄;epoll_ctl是註冊要監聽的事件類型;epoll_wait則是等待事件的產生(文件描述符就緒)。

一、函數原型:

/*創建⼀個epoll的句柄*/
int epoll_create(int size);  

//⾃從linux2.6.8之後,size參數是被忽略的.
//⽤完之後, 必須調⽤close()關閉,返回值epfd.


/*epoll的事件註冊函數*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

//它不同於select()是在監聽事件時告訴內核要監聽什麼類型的事件, ⽽是在這⾥先註冊要監聽的事件類型.
//第⼀個參數是epoll_create()的返回值(epoll的句柄).
//第⼆個參數表⽰動作,⽤三個宏來表示:
                    EPOLL_CTL_ADD :註冊新的fd到epfd中;
                    EPOLL_CTL_MOD :修改已經註冊的fd的監聽事件;
                    EPOLL_CTL_DEL :從epfd中刪除⼀個fd;
//第三個參數是需要監聽的fd.
//第四個參數是告訴內核需要監聽什麼事:events可以是以下⼏個宏的集合:
                    EPOLLIN : 表⽰對應的⽂件描述符可以讀 (包括對端SOCKET正常關閉);
                    EPOLLOUT:表⽰對應的⽂件描述符可以寫;
                    EPOLLPRI:表⽰對應的⽂件描述符有緊急的數據可讀(這⾥應該表⽰有帶外數據到來);
                    EPOLLERR : 表⽰對應的⽂件描述符發⽣錯誤;
                    EPOLLHUP : 表⽰對應的⽂件描述符被掛斷;
                    EPOLLET : 將EPOLL設爲邊緣觸發(ET)模式, 這是相對於⽔平觸發(LT)來說的.
                    EPOLLONESHOT:只監聽⼀次事件, 當監聽完這次事件之後, 如果還需要繼續監聽這個socket的話, 需要再次把這個socket加⼊到EPOLL隊列⾥.


/*收集在epoll監控的事件中已經發送的事件*/
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

//參數events是分配好的epoll_event結構體數組.
//epoll將會把發⽣的事件賦值到events數組中 (events不可以是空指針,內核只負責把數據複製到這個events數組中,不會去幫助我們在⽤戶態中分配內存).
//maxevents告知內核這個events有多⼤,這個maxevents的值不能⼤於創建epoll_create()時的size.
//參數timeout是超時時間 (毫秒,0會⽴即返回,-1是永久阻塞).
//如果函數調⽤成功,返回對應I/O上已準備好的⽂件描述符數目,如返回0表⽰已超時, 返回⼩於0表示函數失敗.

epoll的使用過程:(共3步)

  •  epoll_create:創建了一個epoll模型(操作系統創建維護):就緒隊列,紅黑樹,回調機制
  • epoll_ctl:將要監控的文件描述符進行註冊;(註冊進紅黑樹)
  • epoll_wait:等待文件描述符就緒(就緒隊列),操作系統將已經OK的文件描述符返回給用戶

二、工作原理:

  1. 當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。
  2. 每一個epoll對象都有一個獨立的eventpoll結構體,用於存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重複添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n爲樹的高度)。
  3. 而所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關係,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。
  4. 當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不爲空,則把發生的事件複製到用戶態,同時將事件數量返回給用戶

三、epoll的優點:

<1>文件描述符數目無上限: 通過epoll_ctl()來註冊⼀個文件描述符, 內核中使用紅黑樹來管理所有需要監控的文件描述符.

<2>基於事件的就緒通知方式: ⼀旦被監聽的某個⽂件描述符就緒, 內核會採用類似於callback的回調機制, 迅速激活這個文件描述符. 這樣隨着文件描述符數量的增加, 也不會影響判定就緒的性能;

<3>維護就緒隊列: 當文件描述符就緒, 就會被放到內核中的⼀個就緒隊列中. 這樣調用epoll_wait獲取就緒文件描述符的時候, 只要取隊列中的元素即可, 操作的時間複雜度是O(1);

<4>內存映射機制: 內核直接將就緒隊列通過mmap的方式映射到用戶態. 避免了拷貝內存這樣的額外性能開銷. 

epoll既然是對select和poll的改進,就應該能避免select的三個缺點:

  • 每次調用用select,都需要把fd_set集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大.
  • 每次調用select都需要在內核(輪詢)遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大. 
  • select支持的文件描述符數量太小,原因:fd_set決定了select同時管理的鏈接數是有上限的(通常是128*8=1024).

那epoll都是怎麼解決的呢?

  • 對於第一個缺點,epoll的解決方案在epoll_ctl函數中。每次註冊新的事件到epoll句柄中時(在epoll_ctl中指定EPOLL_CTL_ADD),會把所有的fd拷貝進內核,而不是在epoll_wait的時候重複拷貝。epoll保證了每個fd在整個過程中只會拷貝一次。
  • 對於第二個缺點,epoll的解決方案不像select或poll一樣每次都把current輪流加入fd對應的設備等待隊列中,而只在epoll_ctl時把current掛一遍(這一遍必不可少)併爲每個fd指定一個回調函數,當設備就緒,喚醒等待隊列上的等待者時,就會調用這個回調函數,而這個回調函數會把就緒的fd加入一個就緒鏈表)。epoll_wait的工作實際上就是在這個就緒鏈表中查看有沒有就緒的fd(利用schedule_timeout()實現睡一會,判斷一會的效果)
  • 對於第三個缺點,epoll沒有這個限制,它所支持的fd上限是最大可以打開文件的數目,這個數字一般遠大於2048,舉個例子,在1GB內存的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統內存關係很大。

總結:

(1)select,poll實現需要自己不斷輪詢所有fd集合,直到設備就緒,期間可能要睡眠和喚醒多次交替。而epoll其實也需要調用epoll_wait不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,並喚醒在epoll_wait中進入睡眠的進程。雖然都要睡眠和交替,但是select和poll在“醒着”的時候要遍歷整個fd集合,而epoll在“醒着”的時候只要判斷一下就緒鏈表是否爲空就行了,這節省了大量的CPU時間。這就是回調機制帶來的性能提升。

(2)select,poll每次調用都要把fd集合從用戶態往內核態拷貝一次,並且要把current往設備等待隊列中掛一次,而epoll只要一次拷貝,而且把current往等待隊列上掛也只掛一次(在epoll_wait的開始,注意這裏的等待隊列並不是設備等待隊列,只是一個epoll內部定義的等待隊列)。這也能節省不少的開銷。

 

四、epoll工作方式

epoll有2種工作方式:select和poll其實也是工作在LT模式下. epoll既可以支持LT, 也可以支持ET.

  • 水平觸發(LT) :LT模式下,直到緩衝區上的所有數據都被處理完,epoll_wait纔不會再次返回。(epoll默認模式)
  • 邊緣觸發(ET):ET模式下, 文件描述符上的事件就緒後, 只有⼀次處理機會,否則就算數據未讀完第二次也不會再返回。

總結:

LT:讀數據時只要沒讀完每一次操作系統EPOLL模型都會將該文件描述符對應的讀事件就緒添加到就緒隊列(不用一次讀完) 

ET:只有數據到來時或增多時纔會將事件添加到就緒隊列,所以必須一次讀完  【從無到有,有到多】 

ET模式:循環讀 + 非阻塞接口

  • 循環讀:ET模式下數據就緒只會通知⼀次,也就是說,如果要使用ET模式,當數據就緒時,需要⼀直read,直到出錯或完成爲止,因此epoll_wait返回的次數少了很多,所以ET的性能比LT性能更高。
  • 非阻塞若當前fd爲阻塞(默認),那麼在當讀完緩衝區的數據時,如果對端並沒有關閉寫端,那麼該read函數會⼀直阻塞,影響其他fd以及後續邏輯. 所以此時將該fd設置爲非阻塞,當沒有數據的時候,read雖然讀取不到任何內容,但是肯定不會被hang住, 那麼此時,說明緩衝區數據已經讀取完畢,需要繼續處理後續邏輯(讀取其他fd或者進⼊wait)

五、epoll的使用場景

         對於多連接, 且多連接中只有⼀部分連接比較活躍時, 比較適合使用epoll.

        例如, 典型的⼀個需要處理上萬個客戶端的服務器, 例如各種互聯網APP的入口服務器, 這樣的服務器就很適合epoll.

        如果只是系統內部, 服務器和服務器之間進行通信, 只有少數的幾個連接, 這種情況下用epoll就並不合適

 

六、epoll的驚羣問題

       有一個單進程的linux epoll服務器程序,在服務高峯期間併發的網絡請求非常大,目前的單進程版本的支撐不了:單進程時只有一個循環先後處理epoll_wait()到的事件,使得某些不幸排隊靠後的socket fd的網絡事件得不到及時處理;所以希望將它改寫成多進程版本:

  • 主進程先監聽端口: listen_fd = socket(...)  
  • 創建epoll,epoll_fd = epoll_create(...);
  • 開始fork(),每個子進程進入大循環,去等待新的accept,epoll_wait(...),處理事件等。

       接着就遇到了“驚羣”現象:當listen_fd有新的accept()請求過來,操作系統會喚醒所有子進程(因爲這些進程都epoll_wait()同 一個listen_fd,操作系統又無從判斷由誰來負責accept,索性乾脆全部叫醒……),但最終只會有一個進程成功accept,其他進程 accept失敗。外國IT友人認爲所有子進程都是被“嚇醒”的,所以稱之爲Thundering Herd(驚羣)。
        打個比方,街邊有一家麥當勞餐廳,裏面有4個服務小窗口,每個窗口各有一名服務員。當大門口進來一位新客人,“歡迎光臨!”餐廳大門的感應式門鈴自動響了 (相當於操作系統底層捕抓到了一個網絡事件),於是4個服務員都擡起頭(相當於操作系統喚醒了所有服務進程)希望將客人招呼過去自己所在的服務窗口。但結 果可想而知,客人最終只會走向其中某一個窗口,而其他3個窗口的服務員只能“失望嘆息”(這一聲無奈的嘆息就相當於accept()返回EAGAIN錯誤),然後埋頭繼續忙自己的事去。 
這樣子“驚羣”現象必然造成資源浪費,那有沒有好的解決辦法呢?

        lighttpd的解決思路:無視驚羣。採用Watcher/Workers模式,具體措施有優化fork()與epoll_create()的位置(讓每個子進程自己去 epoll_create()和epoll_wait()),捕獲accept()拋出來的錯誤並忽視等。這樣子一來,當有新accept時仍將有多個 lighttpd子進程被喚醒。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章