1. 程式人生 > 實用技巧 >I/O模式及select、 poll、 epoll

I/O模式及select、 poll、 epoll

I/O多路複用技術

複用技術(multiplexing)並不是新技術而是一種設計思想,在通訊和硬體設計中存在分頻多工、分時多工、波長分波多工、碼分複用等。在日常生活中複用的場景也非常多。從本質上來說,複用就是為瞭解決有限資源和過多使用者的不平衡問題,且此技術的理論基礎是 資源的可釋放性。

資源的可釋放性: 不可釋放場景:ICU病房的呼吸機是有限資源,病人一旦佔用且在未脫離危險之前是無法放棄佔用的,因此不可能幾個情況一樣的病人輪流使用。可釋放場景:對於一些其他資源比如醫護人員就可以實現對多個病人的同時監護。

I/O多路複用就是通過一種機制,一個程式可以監聽多個檔案描述符,一旦某個描述符就緒,可讀或可寫,能夠通知程式進行響應的操作。

使用者空間和核心空間

將大量的檔案描述符託管給核心,核心將最底層的I/O狀態封裝成讀寫事件,這樣就避免了由程式設計師去主動輪詢狀態變化的重複工作,開發者將回掉函式註冊到epoll,當檢測到相對應檔案描述符產生狀態變化時,就進行函式回撥。select/poll由於效率問題(使用輪詢檢測)基本已經被epoll和kqueue取代。

阻塞 I/O(blocking IO)

在linux中,預設情況下所有的socket都是blocking,一個典型的讀操作流程大概是這樣:

當使用者程式呼叫了recvfrom這個系統呼叫,kernel就開始了IO的第一個階段:準備資料(對於網路IO來說,很多時候資料在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的資料到來)。這個過程需要等待,也就是說資料被拷貝到作業系統核心的緩衝區中是需要一個過程的。而在使用者程式這邊,整個程式會被阻塞(當然,是程式自己選擇的阻塞)。當kernel一直等到資料準備好了,它就會將資料從kernel中拷貝到使用者記憶體,然後kernel返回結果,使用者程式才解除block的狀態,重新執行起來。

所以,blocking IO的特點就是在IO執行的兩個階段都被block了。

非阻塞I/O(nonblocking IO)

linux下,可以通過設定socket使其變為non-blocking。當對一個non-blocking socket執行讀操作時,流程是這個樣子:

當使用者程式發出read操作時,如果kernel中的資料還沒有準備好,那麼它並不會block使用者程式,而是立刻返回一個error。從使用者程式角度講 ,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果。使用者程式判斷結果是一個error時,它就知道資料還沒有準備好,於是它可以再次傳送read操作。一旦kernel中的資料準備好了,並且又再次收到了使用者程式的system call,那麼它馬上就將資料拷貝到了使用者記憶體,然後返回。

所以,nonblocking IO的特點是使用者程式需要不斷的主動詢問kernel資料好了沒有。

/I/O 多路複用( IO multiplexing)

IO multiplexing就是我們說的select,poll,epoll,有些地方也稱這種IO方式為event driven IO。select/epoll的好處就在於單個process就可以同時處理多個網路連線的IO。它的基本原理就是select,poll,epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有資料到達了,就通知使用者程式。

當使用者程式呼叫了select,那麼整個程式會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的資料準備好了,select就會返回。這個時候使用者程式再呼叫read操作,將資料從kernel拷貝到使用者程式。

所以,I/O 多路複用的特點是通過一種機制一個程式能同時等待多個檔案描述符,而這些檔案描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函式就可以返回。

非同步I/O(asynchronous I/O)

Linux下的asynchronous IO其實用得很少。先看一下它的流程:

使用者程式發起read操作之後,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它受到一個asynchronous read之後,首先它會立刻返回,所以不會對使用者程式產生任何block。然後,kernel會等待資料準備完成,然後將資料拷貝到使用者記憶體,當這一切都完成之後,kernel會給使用者程式傳送一個signal,告訴它read操作完成了。

blocking和non-blocking的區別

呼叫blocking IO會一直block住對應的程式直到操作完成,而non-blocking IO在kernel還準備資料的情況下會立刻返回

synchronous IO和asynchronous IO的區別

在說明synchronous IO和asynchronous IO的區別之前,需要先給出兩者的定義。POSIX的定義是這樣子的:

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
  • An asynchronous I/O operation does not cause the requesting process to be blocked;

有人會說,non-blocking IO並沒有被block啊。這裡有個非常“狡猾”的地方,定義中所指的”IO operation”是指真實的IO操作,就是例子中的recvfrom這個system call。non-blocking IO在執行recvfrom這個system call的時候,如果kernel的資料沒有準備好,這時候不會block程式。但是,當kernel中資料準備好的時候,recvfrom會將資料從kernel拷貝到使用者記憶體中,這個時候程式是被block了,在這段時間內,程式是被block的。

而asynchronous IO則不一樣,當程式發起IO 操作之後,就直接返回再也不理睬了,直到kernel傳送一個訊號,告訴程式說IO完成。在這整個過程中,程式完全沒有被block。

通過上面的圖片,可以發現non-blocking IO和asynchronous IO的區別還是很明顯的。在non-blocking IO中,雖然程式大部分時間都不會被block,但是它仍然要求程式去主動的check,並且當資料準備完成以後,也需要程式主動的再次呼叫recvfrom來將資料拷貝到使用者記憶體。而asynchronous IO則完全不同。它就像是使用者程式將整個IO操作交給了他人(kernel)完成,然後他人做完後發訊號通知。在此期間,使用者程式不需要去檢查IO操作的狀態,也不需要主動的去拷貝資料。

I/O 多路複用之select、poll、epoll詳解

Select

#include <sys/select.h>

/*According to earlier standards*/
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); // select 四個巨集
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set, *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set); # ifndef FD_SETSIZE
#define FD_SETSIZE 1024
#endif

假定fd_set長度為1位元組,則一位元組長度的fd_set最大可以對應8個fd。

Select的呼叫過程如下:

  1. 執行FD_ZERO(&set),則set用位表示是0000,0000
  2. 若fd=5, 執行FD_SET(fd, &set); set後 bitmap 變為 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 特點

  1. 可監控的檔案描述符個數取決於 sizeof(fd_set) 的值。假設伺服器上 sizeof(fd_set)=512,每 bit 表示一個檔案描述符,則伺服器上支援的最大檔案描述符是 512*8=4096。fd_set 的大小調整可參考 【原創】技術系列之 網路模型(二) 中的模型 2,可以有效突破 select 可監控的檔案描述符上限。一般來說這個數目和系統記憶體關係很大,具體數目可以cat /proc/sys/fs/file-max察看。32位機預設是1024個。64位機預設是2048.

select缺點:

  1. 最大併發數限制:select 的一個缺點在於單個程式能夠監視的檔案描述符的數量存在最大限制,在Linux上一般為1024, 可以通過修改巨集定義設定重新編譯核心的方式提升這一限制,但是這樣也會造成效率的降低。有以下2,3點瓶頸
  2. 每次呼叫select , 都需要把fd集合從使用者態拷貝到核心態,這個開銷在fd很多時會很大
  3. 效能衰減嚴重:每次kernel都需要線性掃描整個fd_set,所以隨著監控fd數量增加,I/0效能線性下降

網路卡如何接收資料,當網路卡把資料寫入記憶體後,網路卡向CPU發出一箇中斷訊號,作業系統便能得知有新資料到來,再通過網路卡中斷程式去處理資料。

Poll

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

Poll的實現和select相似,只是儲存fd的結構不同,polll使用pollfd結構而不是bitmap, pollfd是一個陣列,陣列就解決得了最大檔案描述符數量限制的問題。pollfd結構包含了要監視的event和發生的event,不再使用selectc引數-值傳遞的方式。但是同樣需要從使用者態拷貝fd到核心態,且是線性迴圈遍歷所有fd集合來判斷可讀連線的,所以本質上沒有區別。poll返回後,仍然需要輪詢pollfd來獲取就緒的描述符。

struct pollfd {
int fd; /*file descriptor*/
short events; /*requested events to watch*/
short revents; /*returned events witnessed*/
}

從上面看,select和poll都需要在返回後,通過遍歷檔案描述符來獲取已經就緒的socket。事實上,同時連線的大量客戶端在一時刻可能只有很少的處於就緒狀態,因此隨著監視的描述符數量的增長,其效率也會線性下降。

epoll

相對於select和poll來說,epoll更加靈活,沒有描述符限制。epoll使用一個檔案描述符管理多個描述符,將使用者關係的檔案描述符的事件存放到核心的一個事件表中,這樣在使用者空間和核心空間的copy只需一次。

epoll 的API設計以下3的函式

#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/* op引數
EPOLL_CTL_ADD
EPOLL_CTL_MOD
EPOLL_CTL_DEL
*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); /*
timeout:
-1: 阻塞
0:立即返回
>0:指定毫秒
*/
  • epoll_create 建立一個epoll例項並返回epollfd,size用來告訴核心這個監聽的數目一共多大,並不是限制了epoll所能監聽的描述符最大數,只是對核心初始分配 紅黑樹節點個資料的一個建議

當建立好epoll控制程式碼後,它就會佔用一個fd值,在linux下如果檢視/proc/程式id/fd/,是能夠看到這個fd的,所以在使用完epoll後,必須呼叫close()關閉,否則可能導致fd被耗盡

  • epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

    • -epfd: epoll_create()的返回值

    • -op:表示操作,用三個巨集來表示: EPOLL_CTL_ADD(新增),EPOLL_CTL_MOD(修改), EPOLL_CTL_DEL(刪除)

    • -fd:需要監聽的fd檔案描述符

    • epoll_event:告訴核心需要監聽什麼,struct_epoll_event結構如下

    • struct epoll_event {
      unit32_t events;
      epoll_data_t data;
      } //events 可以是一下幾個巨集的集合
      EPOLLIN: 表示對應的檔案描述符可以讀
      EPOLLOUT: 表示對應檔案描述符可以寫
      EPOLLPRI: 表示對應檔案描述符有緊急的資料可讀
      EPOLLERR: 表示檔案描述符發生錯誤
      EPOLLHUP: 表示對應的檔案描述符被結束通話
      EPOLLET: 將EPOLL設為邊緣出發模式
      EPOLLONNESHOT: 只監聽一個事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL佇列裡
    • typedef union epoll_data {
      void *ptr;
      int fd;
      unit32_t u32;
      unit64_t u64;
      } epoll_data_t;
  • epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)

    引數events用來從核心得到事件集合,maxevents告訴核心這個events多大,不能大於epoll_create()的size.

    則是阻塞監聽epoll例項上所有的 file descriptor的I/O事件,接收使用者空間上的一塊記憶體地址(events陣列) ,kernel會在有I/O事件發生的時候會把檔案描述符列表 rdlist 複製到 這塊記憶體上,然後epoll_wail解阻塞並返回。

epoll 工作模式

  • LT模式(預設模式):當epoll_wait檢測到描述符事件發生並將此事件通知應用程式,應用程式可以不立即處理該事件,下次呼叫epoll_wait時,會再次相應應用程式並通知此事件
  • ET模式:當epoll_wait檢測描述符事件發生並將此事件通知應用程式,應用程式必須立即處理該事件,如果不處理,下次呼叫epoll_wait時,不會再次響應程式並通知此事件。一般會設定一個定期的事件清除未處理快取。ET模式在很大程度上減少了epoll事件被重複觸發的次數,因此效率要比LT模式高epoll工作在ET模式的時候,必須使用非阻塞套介面,以避免由於一個檔案控制程式碼的阻塞讀/阻塞寫操作把處理多個檔案描述符的任務餓死。

epoll 監聽事件的資料結構及其原理

在實現上 epoll 採用紅黑樹來儲存所有監聽的 fd,而紅黑樹本身插入和刪除效能比較穩定,時間複雜度 O(logN)。通過 epoll_ctl 函式新增進來的 fd 都會被放在紅黑樹的某個節點內,所以,重複新增是沒有用的。當把 fd 新增進來的時候時候會完成關鍵的一步:該 fd 會與相應的裝置(網路卡)驅動程式建立回撥關係,也就是在核心中斷處理程式為它註冊一個回撥函式,在 fd 相應的事件觸發(中斷)之後(裝置就緒了),核心就會呼叫這個回撥函式,該回撥函式在核心中被稱為: ep_poll_callback這個回撥函式其實就是把這個 fd 新增到 rdllist 這個雙向連結串列(就緒連結串列)中epoll_wait 實際上就是去檢查 rdlist 雙向連結串列中是否有就緒的 fd,當 rdlist 為空(無就緒 fd)時掛起當前程式,直到 rdlist 非空時程式才被喚醒並返回。

圖片來源:《深入理解Nginx:模組開發與架構解析(第二版)》,陶輝

epoll實現細節
  • 就緒佇列的資料結構,就緒列表引用就緒的socket, 所以能夠快速插入資料。程式可能隨時呼叫epoll_ctl新增監視socket, 也可能隨時刪除。當刪除時,若該socket已經存放在就緒列表中,它也應該被移除。所以就緒列表應該是一種能快速插入和刪除的資料結構。雙向連結串列是epoll中實現就緒佇列的資料結構
  • 既然 epoll 將“維護監視佇列”和“程式阻塞”分離,也意味著需要有個資料結構來儲存監視的 socket,至少要方便地新增和移除,還要便於搜尋,以避免重複新增。紅黑樹是一種自平衡二叉查詢樹,搜尋、插入和刪除時間複雜度都是O(log(N)),效率較好,epoll 使用了紅黑樹作為索引結構(對應上圖的 rbr)

小結

I/O多路複用 select poll epoll
時間複雜度 O(n) O(n) O(k) k表示被啟用的事件fd個數
實現機制 無差別輪詢監聽的fd(三組流資料:可讀,可寫,異常) 無差別輪詢,檢視每個fd繫結的事件狀態 事件回掉,將fd與網路卡裝置繫結一個回到函式,當fd就緒,呼叫回撥函式,將就緒fd放入 rdlist連結串列中,kernel把有I/O事件發生的檔案描述符列表 rdlist 複製到 epoll_wait使用者指定的記憶體events內, epoll_wait 解阻塞並返回給應用
資料結構 三個bitmap 陣列存放struct -pollfd 紅黑樹(socket) + 雙向連結串列(就緒佇列)
最大連線數 1024(x86) / 2048(x64) 無上限,同epoll解釋 無上限,在1GB記憶體的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看
優缺點 1. 可移植性好,有些Unix系統不支援poll(), 對超時值提供了微妙級別的精確度
2. 單程式可監聽fd的數量有限制
3. 使用者態需要每次select之前復位所有fd,然後select之後還得遍歷所有fd找到可讀寫或異常的fd,IO效率隨FD的增加而線性下降
4. 每次select呼叫都需要把fd集合從使用者態拷貝到核心態
1. 沒有最大連線限制
2. 與select一樣,poll返回後,需要輪詢整個pollfd集合來獲取就緒的描述符,IO效率隨FD的增加而線性下降
3. 大量的fd的陣列被整體複製於使用者態和核心地址空間之間,浪費資源
1.沒有最大連線樹限制
2.核心拷貝只返回活躍的連線,而跟連線總數無關,因此在實際的網路環境中,Epoll的效率就會遠遠高於select和poll。
工作模式 LT LT LT/ET

select,poll實現需要自己不斷輪詢所有fd集合,直到裝置就緒,期間可能要睡眠和喚醒多次交替。

而epoll其實也需要呼叫 epoll_ wait不斷輪詢就緒連結串列,期間也可能多次睡眠和喚醒交替,但是它是裝置就緒時,呼叫回撥函式,把就緒fd放入就緒連結串列中,並喚醒在 epoll_wait中進入睡眠的程式。 

雖然都要睡眠和交替,但是select和poll在“醒著”的時候要遍歷整個fd集合,而epoll在“醒著”的 時候只要判斷一下就緒連結串列是否為空就行了,這節省了大量的CPU時間,這就是回撥機制帶來的效能提升

高效能網路模式之 Reactor模式

反應器設計模式(Reator pattern)是一種基於事件驅動的設計模式,常用於高併發場景下,常見的像Node.js、Netty、Vert.x中都有著Reactor模式的身影

Reactor 模式本質上指的是使用 I/O 多路複用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式

高併發模式最好的就是 非阻塞I/O+epoll ET 模式

驚群效應

使用多個程式監聽同一埠就繞不開驚群這個話題, fork子程式, 子程式共享listen socket fd, 多個子程式同時accept阻塞, 在請求到達時核心會喚醒所有accept的程式, 然而只有一個程式能accept成功, 其它程式accept失敗再次阻塞, 影響系統效能, 這就是驚群. Linux 2.6核心更新以後多個程式accept只有一個程式會被喚醒, 但是如果使用epoll還是會產生驚群現象.

Nginx為瞭解決epoll驚群問題, 使用程式間互斥鎖, 只有拿到鎖的程式才能把listen fd加入到epoll中, 在accept完成後再釋放鎖.

但是在高併發情況下, 獲取鎖的開銷也會影響效能, 一般會建議把鎖配置關掉. 直到Nginx 1.9.1更新支援了socket的SO_REUSEPORT選項, 驚群問題才算解決, listen socket fd不再是在master程式中建立, 而是每個worker程式建立一個通過SO_REUSEPORT 選項來複用埠, 核心會自行選擇一個fd來喚醒, 並且有負載均衡演演算法.Gunicorn與uWSGI之我見

檔案描述符FD(file descriptor)

Linux中一切皆檔案

0 ----> 標準輸入

1 ----> 標準輸出

2 ----> 標準錯誤

0,1,2 固定描述符,所以檔案描述符是從3開始

參考資料

轉載自---Linux IO模式及 select、poll、epoll詳解

epoll 或者 kqueue 的原理是什麼?

如果這篇文章說不清epoll的本質,那就過來掐死我吧-羅培羽