Linux中的IO多路複用機制

原文地址:https://www.jianshu.com/p/397449cadc9a

I/O多路複用(multiplexing)的本質是通過一種機制(系統內核緩衝I/O數據),讓單個進程可以監視多個文件描述符,一旦某個描述符就緒(一般是讀就緒或寫就緒),能夠通知程序進行相應的讀寫操作

select、poll 和 epoll 都是 Linux API 提供的 IO 複用方式。

相信大家都瞭解了Unix五種IO模型,不瞭解的可以 => 查看這裏

[1] blocking IO - 阻塞IO
[2] nonblocking IO - 非阻塞IO
[3] IO multiplexing - IO多路複用
[4] signal driven IO - 信號驅動IO
[5] asynchronous IO - 異步IO

其中前面4種IO都可以歸類爲synchronous IO - 同步IO,而select、poll、epoll本質上也都是同步I/O,因爲他們都需要在讀寫事件就緒後自己負責進行讀寫,也就是說這個讀寫過程是阻塞的。

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

在介紹select、poll、epoll之前,首先介紹一下Linux操作系統中基礎的概念

  • 用戶空間 / 內核空間
    現在操作系統都是採用虛擬存儲器,那麼對32位操作系統而言,它的尋址空間(虛擬存儲空間)爲4G(2的32次方)。
    操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。爲了保證用戶進程不能直接操作內核(kernel),保證內核的安全,操作系統將虛擬空間劃分爲兩部分,一部分爲內核空間,一部分爲用戶空間。
  • 進程切換
    爲了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程的執行。這種行爲被稱爲進程切換。因此可以說,任何進程都是在操作系統內核的支持下運行的,是與內核緊密相關的,並且進程切換是非常耗費資源的。
  • 進程阻塞
    正在執行的進程,由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由運行狀態變爲阻塞狀態。可見,進程的阻塞是進程自身的一種主動行爲,也因此只有處於運行態的進程(獲得了CPU資源),纔可能將其轉爲阻塞狀態。當進程進入阻塞狀態,是不佔用CPU資源的。
  • 文件描述符
    文件描述符(File descriptor)是計算機科學中的一個術語,是一個用於表述指向文件的引用的抽象化概念。
    文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核爲每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞着文件描述符展開。但是文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。
  • 緩存I/O
    緩存I/O又稱爲標準I/O,大多數文件系統的默認I/O操作都是緩存I/O。在Linux的緩存I/O機制中,操作系統會將I/O的數據緩存在文件系統的頁緩存中,即數據會先被拷貝到操作系統內核的緩衝區中,然後纔會從操作系統內核的緩衝區拷貝到應用程序的地址空間。

Select

我們先分析一下select函數

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);

【參數說明】
int maxfdp1 指定待測試的文件描述字個數,它的值是待測試的最大描述字加1。
fd_set *readset , fd_set *writeset , fd_set *exceptset
fd_set可以理解爲一個集合,這個集合中存放的是文件描述符(file descriptor),即文件句柄。中間的三個參數指定我們要讓內核測試讀、寫和異常條件的文件描述符集合。如果對某一個的條件不感興趣,就可以把它設爲空指針。
const struct timeval *timeout timeout告知內核等待所指定文件描述符集合中的任何一個就緒可花多少時間。其timeval結構用於指定這段時間的秒數和微秒數。

【返回值】
int 若有就緒描述符返回其數目,若超時則爲0,若出錯則爲-1

select運行機制

select()的機制中提供一種fd_set的數據結構,實際上是一個long類型的數組,每一個數組元素都能與一打開的文件句柄(不管是Socket句柄,還是其他文件或命名管道或設備句柄)建立聯繫,建立聯繫的工作由程序員完成,當調用select()時,由內核根據IO狀態修改fd_set的內容,由此來通知執行了select()的進程哪一Socket或文件可讀。

從流程上來看,使用select函數進行IO請求和同步阻塞模型沒有太大的區別,甚至還多了添加監視socket,以及調用select函數的額外操作,效率更差。但是,使用select以後最大的優勢是用戶可以在一個線程內同時處理多個socket的IO請求。用戶可以註冊多個socket,然後不斷地調用select讀取被激活的socket,即可達到在同一個線程內同時處理多個IO請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。

select機制的問題

  1. 每次調用select,都需要把fd_set集合從用戶態拷貝到內核態,如果fd_set集合很大時,那這個開銷也很大
  2. 同時每次調用select都需要在內核遍歷傳遞進來的所有fd_set,如果fd_set集合很大時,那這個開銷也很大
  3. 爲了減少數據拷貝帶來的性能損壞,內核對被監控的fd_set集合大小做了限制,並且這個是通過宏控制的,大小不可改變(限制爲1024)

Poll

poll的機制與select類似,與select在本質上沒有多大差別,管理多個描述符也是進行輪詢,根據描述符的狀態進行處理,但是poll沒有最大文件描述符數量的限制。也就是說,poll只解決了上面的問題3,並沒有解決問題1,2的性能開銷問題。

下面是pll的函數原型:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

typedef struct pollfd {
        int fd;                         // 需要被檢測或選擇的文件描述符
        short events;                   // 對文件描述符fd上感興趣的事件
        short revents;                  // 文件描述符fd上當前實際發生的事件
} pollfd_t;

poll改變了文件描述符集合的描述方式,使用了pollfd結構而不是select的fd_set結構,使得poll支持的文件描述符集合限制遠大於select的1024

【參數說明】

struct pollfd *fds fds是一個struct pollfd類型的數組,用於存放需要檢測其狀態的socket描述符,並且調用poll函數之後fds數組不會被清空;一個pollfd結構體表示一個被監視的文件描述符,通過傳遞fds指示 poll() 監視多個文件描述符。其中,結構體的events域是監視該文件描述符的事件掩碼,由用戶來設置這個域,結構體的revents域是文件描述符的操作結果事件掩碼,內核在調用返回時設置這個域

nfds_t nfds 記錄數組fds中描述符的總數量

【返回值】
int 函數返回fds集合中就緒的讀、寫,或出錯的描述符數量,返回0表示超時,返回-1表示出錯;

Epoll

epoll在Linux2.6內核正式提出,是基於事件驅動的I/O方式,相對於select來說,epoll沒有描述符個數限制,使用一個文件描述符管理多個描述符,將用戶關心的文件描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。

Linux中提供的epoll相關函數如下:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1. epoll_create 函數創建一個epoll句柄,參數size表明內核要監聽的描述符數量。調用成功時返回一個epoll句柄描述符,失敗時返回-1。

2. epoll_ctl 函數註冊要監聽的事件類型。四個參數解釋如下:

  • epfd 表示epoll句柄
  • op 表示fd操作類型,有如下3種
    • EPOLL_CTL_ADD 註冊新的fd到epfd中
    • EPOLL_CTL_MOD 修改已註冊的fd的監聽事件
    • EPOLL_CTL_DEL 從epfd中刪除一個fd
  • fd 是要監聽的描述符
  • event 表示要監聽的事件

epoll_event 結構體定義如下:

struct epoll_event {
    __uint32_t events;  /* Epoll events */
    epoll_data_t data;  /* User data variable */
};

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

3. epoll_wait 函數等待事件的就緒,成功時返回就緒的事件數目,調用失敗時返回 -1,等待超時返回 0。

  • epfd 是epoll句柄
  • events 表示從內核得到的就緒事件集合
  • maxevents 告訴內核events的大小
  • timeout 表示等待的超時事件

epoll是Linux內核爲處理大批量文件描述符而作了改進的poll,是Linux下多路複用IO接口select/poll的增強版本,它能顯著提高程序在大量併發連接中只有少量活躍的情況下的系統CPU利用率。原因就是獲取事件的時候,它無須遍歷整個被偵聽的描述符集,只要遍歷那些被內核IO事件異步喚醒而加入Ready隊列的描述符集合就行了。

epoll除了提供select/poll那種IO事件的水平觸發(Level Triggered)外,還提供了邊緣觸發(Edge Triggered),這就使得用戶空間程序有可能緩存IO狀態,減少epoll_wait/epoll_pwait的調用,提高應用程序效率。

  • 水平觸發(LT):默認工作模式,即當epoll_wait檢測到某描述符事件就緒並通知應用程序時,應用程序可以不立即處理該事件;下次調用epoll_wait時,會再次通知此事件
  • 邊緣觸發(ET): 當epoll_wait檢測到某描述符事件就緒並通知應用程序時,應用程序必須立即處理該事件。如果不處理,下次調用epoll_wait時,不會再次通知此事件。(直到你做了某些操作導致該描述符變成未就緒狀態了,也就是說邊緣觸發只在狀態由未就緒變爲就緒時只通知一次)。

LT和ET原本應該是用於脈衝信號的,可能用它來解釋更加形象。Level和Edge指的就是觸發點,Level爲只要處於水平,那麼就一直觸發,而Edge則爲上升沿和下降沿的時候觸發。比如:0->1 就是Edge,1->1 就是Level。

ET模式很大程度上減少了epoll事件的觸發次數,因此效率比LT模式下高。

總結

一張圖總結一下select,poll,epoll的區別:

  select poll epoll
操作方式 遍歷 遍歷 回調
底層實現 數組 鏈表 哈希表
IO效率 每次調用都進行線性遍歷,時間複雜度爲O(n) 每次調用都進行線性遍歷,時間複雜度爲O(n) 事件通知方式,每當fd就緒,系統註冊的回調函數就會被調用,將就緒fd放到readyList裏面,時間複雜度O(1)
最大連接數 1024(x86)或2048(x64) 無上限 無上限
fd拷貝 每次調用select,都需要把fd集合從用戶態拷貝到內核態 每次調用poll,都需要把fd集合從用戶態拷貝到內核態 調用epoll_ctl時拷貝進內核並保存,之後每次epoll_wait不拷貝

epoll是Linux目前大規模網絡併發程序開發的首選模型。在絕大多數情況下性能遠超select和poll。目前流行的高性能web服務器Nginx正式依賴於epoll提供的高效網絡套接字輪詢服務。但是,在併發連接不高的情況下,多線程+阻塞I/O方式可能性能更好。


既然select,poll,epoll都是I/O多路複用的具體的實現,之所以現在同時存在,其實他們也是不同歷史時期的產物

  • select出現是1984年在BSD裏面實現的
  • 14年之後也就是1997年才實現了poll,其實拖那麼久也不是效率問題, 而是那個時代的硬件實在太弱,一臺服務器處理1千多個鏈接簡直就是神一樣的存在了,select很長段時間已經滿足需求
  • 2002, 大神 Davide Libenzi 實現了epoll
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章