深入理解IO複用技術之epoll

個人博客請訪問 http://www.x0100.top              

1.寫在前面

今天一起來學習一下高併發實現的的重要基礎:I/O複用技術 & epoll原理

通過本文你將瞭解到以下內容:

  • IO複用的概念

  • epoll出現之前的IO複用工具

  • epoll三級火箭

  • epoll底層實現

  • ET模式&LT模式

  • 一道騰訊面試題

  • epoll驚羣問題

 

溫馨提示:技術文章涉及很多細節都會比較晦澀,反覆琢磨才能掌握。

2.初識複用技術和IO複用

在瞭解epoll之前,我們先看下複用技術的概念和IO複用到底在說什麼?

2.1 複用概念和資源特性

2.1.1 複用的概念

複用技術 multiplexing 並不是新技術而是一種設計思想,在通信和硬件設計中存在頻分複用、時分複用、波分複用、碼分複用等,在日常生活中複用的場景也非常多,因此不要被專業術語所迷惑。

從本質上來說,複用就是爲了解決有限資源和過多使用者的不平衡問題,從而實現最大的利用率,處理更多的問題。

2.1.2 資源的可釋放

舉個例子:
不可釋放場景:ICU 病房的呼吸機作爲有限資源,病人一旦佔用且在未脫離危險之前是無法放棄佔用的,因此不可能幾個情況一樣的病人輪流使用。

可釋放場景:對於一些其他資源比如醫護人員就可以實現對多個病人的同時監護,理論上不存在一個病人佔用醫護人員資源不釋放的場景。

所以我們可以想一下,多個 IO 共用的資源(處理線程)是否具備可釋放性?

2.1.3 理解IO複用

I/O的含義:在計算機領域常說的IO包括磁盤 IO 和網絡 IO,我們所說的IO複用主要是指網絡 IO ,在Linux中一切皆文件,因此網絡IO也經常用文件描述符 FD 來表示。

複用的含義:那麼這些文件描述符 FD 要複用什麼呢?在網絡場景中複用的就是任務處理線程,所以簡單理解就是多個IO共用1個處理線程

IO複用的可行性:IO請求的基本操作包括read和write,由於網絡交互的本質性,必然存在等待,換言之就是整個網絡連接中FD的讀寫是交替出現的,時而可讀可寫,時而空閒,所以IO複用是可用實現的。

綜上認爲:IO複用技術就是協調多個可釋放資源的FD交替共享任務處理線程完成通信任務,實現多個fd對應1個任務處理線程的複用場景

現實生活中IO複用就像一隻邊牧管理幾百只綿羊一樣:

 

圖片來自網絡:多隻綿羊共享1只邊牧的管理

 

2.1.4 IO複用的出現背景

 

業務需求是推動技術演進的源動力。

在網絡併發量非常小的原始時期,即使 per req per process 地處理網絡請求也可以滿足要求,但是隨着網絡併發量的提高,原始方式必將阻礙進步,所以就刺激了 IO 複用機制的實現和推廣。

高效 IO 複用機制要滿足:協調者消耗最少的系統資源、最小化FD的等待時間、最大化FD的數量、任務處理線程最少的空閒、多快好省完成任務等。

 

畫外音:上面的一段話可能讀起來有些繞,樸素的說法就是讓任務處理線程以更小的資源消耗來協調更多的網絡請求連接,IO複用工具也是逐漸演進的,經過前後對比就可以發現這個原則一直貫穿其中。

 

理解了IO複用技術的基本概念,我們接着來看Linux系統中先後出現的各種IO複用工具以及各自的特點,加深理解。

 

3. Linux的IO複用工具概覽

 

在 Linux 中先後出現了select、poll、epoll等,FreeBSD的 kqueue也是非常優秀的 IO 複用工具,kqueue 的原理和 epoll 很類似,本文以 Linux 環境爲例,並且不討論過多 select 和 poll 的實現機制和細節。

 

3.1 先驅者select

 

select 是 2000年左右出現的,對外的接口定義:

 

/* According to POSIX.1-2001 */
#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);
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);

3.1.1 官方提示

 

作爲第一個IO複用系統調用,select 使用一個宏定義函數按照 bitmap原理填充 fd,默認大小是 1024個,因此對於fd的數值大於 1024都可能出現問題,看下官方預警:

Macro: int FD_SETSIZE
The value of this macro is the maximum number of file descriptors that a fd_set object can hold information about. On systems with a fixed maximum number, FD_SETSIZE is at least that number. On some systems, including GNU, there is no absolute limit on the number of descriptors open, but this macro still has a constant value which controls the number of bits in an fd_set; if you get a file descriptor with a value as high as FD_SETSIZE, you cannot put that descriptor into an fd_set.

 

簡單解釋一下這段話的含義:

當fd的絕對數值大於1024時將不可控,因爲底層的位數組的原因,官方不建議超過 1024,但是我們也無法控制 fd的絕對數值大小,之前針對這個問題做過一些調研,結論是系統對於 fd的分配有自己的策略,會大概率分配到1024以內,對此我並沒有充分理解,只是提及一下這個坑。

 

3.1.2 存在的問題和客觀評價

 

由於底層實現方式的侷限性,select 存在一些問題,主要包括:

 

  • 可協調fd數量和數值都不超過1024 無法實現高併發

  • 使用O(n)複雜度遍歷fd數組查看fd的可讀寫性 效率低

  • 涉及大量kernel和用戶態拷貝 消耗大

  • 每次完成監控需要再次重新傳入並且分事件傳入 操作冗餘

 

select 以樸素的方式實現了IO複用,將併發量提高的最大K級,但是對於完成這個任務的代價和靈活性都有待提高。

無論怎麼樣 select作爲先驅對IO複用有巨大的推動,並且指明瞭後續的優化方向,不要無知地指責 select。

 

3.2 繼承者epoll

 

在 epoll出現之前,poll 對 select進行了改進,但是本質上並沒有太大變化,因此我們跳過 poll直接看 epoll。

epoll 最初在2.5.44內核版本出現,後續在2.6.x版本中對代碼進行了優化使其更加簡潔,先後面對外界的質疑在後續增加了一些設置來解決隱藏的問題,所以 epoll也已經有十幾年的歷史了。

在《Unix網絡編程》第三版(2003年)還沒有介紹 epoll,因爲那個時代epoll還沒有出現,書中只介紹了 select和poll,epoll對select中存在的問題都逐一解決,epoll的優勢包括:

 

  • 對fd數量沒有限制(當然這個在poll也被解決了)

  • 拋棄了bitmap數組實現了新的結構來存儲多種事件類型

  • 無需重複拷貝fd 隨用隨加 隨棄隨刪

  • 採用事件驅動避免輪詢查看可讀寫事件

 

epoll的應用現狀
epoll出現之後大大提高了併發量對於C10K問題輕鬆應對,即使後續出現了真正的異步IO,也並沒有(暫時沒有)撼動epoll的江湖地位。

因爲epoll可以解決數萬數十萬的併發量,已經可以解決現在大部分的場景了,異步IO固然優異,但是編程難度比epoll更大,權衡之下epoll仍然富有生命力。

 

4. 初識epoll

 

epoll 繼承了select 的風格,留給用戶的接口非常簡潔,可以說是簡約而不簡單,我們一起來感受一下。

 

4.1 epoll的基礎API和數據結構

 

epoll主要包括epoll_data、epoll_event、三個api,如下所示:

 

//用戶數據載體
typedef union epoll_data {
   void    *ptr;
   int      fd;
   uint32_t u32;
   uint64_t u64;
} epoll_data_t;

//fd裝載入內核的載體
 struct epoll_event {
     uint32_t     events;    /* Epoll events */
     epoll_data_t data;      /* User data variable */
 };
 
 //三板斧api
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);

4.2 epoll三級火箭的科班理解

  • epoll_create
    該接口是在內核區創建一個epoll相關的一些列結構,並且將一個句柄fd返回給用戶態,後續的操作都是基於此fd的,參數size是告訴內核這個結構的元素的大小,類似於stl的vector動態數組,如果size不合適會涉及複製擴容,不過貌似4.1.2內核之後size已經沒有太大用途了;

  • epoll_ctl
    該接口是將fd添加/刪除於epoll_create返回的epfd中,其中epoll_event是用戶態和內核態交互的結構,定義了用戶態關心的事件類型和觸發時數據的載體epoll_data;

  • epoll_wait
    該接口是阻塞等待內核返回的可讀寫事件,epfd還是epoll_create的返回值,events是個結構體數組指針存儲epoll_event,也就是將內核返回的待處理epoll_event結構都存儲下來,maxevents告訴內核本次返回的最大fd數量,這個和events指向的數組是相關的;

 

其中三個api中貫穿了一個數據結構:epoll_event,它可以說是用戶態需監控fd的代言人,後續用戶程序對fd的操作都是基於此結構的;

 

4.3 epoll三級火箭的通俗解釋

 

可能上面的描述有些抽象,舉個現實中的例子,來幫助大家理解:

 

  • epoll_create場景
    大學開學第一週,你作爲班長需要幫全班同學領取相關物品,你在學生處告訴工作人員,我是xx學院xx專業xx班的班長,這時工作人員確定你的身份並且給了你憑證,後面辦的事情都需要用到(也就是調用epoll_create向內核申請了epfd結構,內核返回了epfd句柄給你使用);

  • epoll_ctl場景
    你拿着憑證在辦事大廳開始辦事,分揀辦公室工作人員說班長你把所有需要辦理事情的同學的學生冊和需要辦理的事情都記錄下來吧,於是班長開始在每個學生手冊單獨寫對應需要辦的事情:李明需要開實驗室權限、孫大熊需要辦游泳卡......就這樣班長一股腦寫完並交給了工作人員(也就是告訴內核哪些fd需要做哪些操作);

  • epoll_wait場景
    你拿着憑證在領取辦公室門前等着,這時候廣播喊xx班長你們班孫大熊的游泳卡辦好了速來領取、李明實驗室權限卡辦好了速來取....還有同學的事情沒辦好,所以班長只能繼續(也就是調用epoll_wait等待內核反饋的可讀寫事件發生並處理);

4.4 epoll官方demo

 

通過man epoll可以看到官方的demo,雖然只有50行,但是乾貨滿滿,如下:

 

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Set up listening socket, 'listen_sock' (socket(),
  bind(), listen()) */

epollfd = epoll_create(10);
if(epollfd == -1) {
   perror("epoll_create");
   exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if(epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
   perror("epoll_ctl: listen_sock");
   exit(EXIT_FAILURE);
}

for(;;) {
   nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
   if (nfds == -1) {
       perror("epoll_pwait");
       exit(EXIT_FAILURE);
   }

   for (n = 0; n < nfds; ++n) {
       if (events[n].data.fd == listen_sock) {
           //主監聽socket有新連接
           conn_sock = accept(listen_sock,
                           (struct sockaddr *) &local, &addrlen);
           if (conn_sock == -1) {
               perror("accept");
               exit(EXIT_FAILURE);
           }
           setnonblocking(conn_sock);
           ev.events = EPOLLIN | EPOLLET;
           ev.data.fd = conn_sock;
           if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                       &ev) == -1) {
               perror("epoll_ctl: conn_sock");
               exit(EXIT_FAILURE);
           }
       } else {
           //已建立連接的可讀寫句柄
           do_use_fd(events[n].data.fd);
       }
   }
}

 

特別注意: 在epoll_wait時需要區分是主監聽線程fd的新連接事件還是已連接事件的讀寫請求,進而單獨處理。

5. epoll的底層細節

epoll底層實現最重要的兩個數據結構:epitem和eventpoll。

可以簡單的認爲epitem是和每個用戶態監控IO的fd對應的,eventpoll是用戶態創建的管理所有被監控fd的結構,我們從局部到整體,從內到外看一下epoll相關的數據結構。

 

5.1 底層數據結構

紅黑樹節點定義:

#ifndef  _LINUX_RBTREE_H
#define  _LINUX_RBTREE_H
#include <linux/kernel.h>
#include <linux/stddef.h>
#include <linux/rcupdate.h>

struct rb_node {
  unsigned long  __rb_parent_color;
  struct rb_node *rb_right;
  struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));
/* The alignment might seem pointless, but allegedly CRIS needs it */
struct rb_root {
  struct rb_node *rb_node;
};

epitem定義:

struct epitem {
  struct rb_node  rbn;
  struct list_head  rdllink;
  struct epitem  *next;
  struct epoll_filefd  ffd;
  int  nwait;
  struct list_head  pwqlist;
  struct eventpoll  *ep;
  struct list_head  fllink;
  struct epoll_event  event;
}

eventpoll定義:

struct eventpoll {
  spin_lock_t       lock;
  struct mutex      mtx;
  wait_queue_head_t     wq;
  wait_queue_head_t   poll_wait;
  struct list_head    rdllist;   //就緒鏈表
  struct rb_root      rbr;      //紅黑樹根節點
  struct epitem      *ovflist;
}

5.2 底層調用過程

 

epoll_create會創建一個類型爲struct eventpoll的對象,並返回一個與之對應文件描述符,之後應用程序在用戶態使用epoll的時候都將依靠這個文件描述符,而在epoll內部也是通過該文件描述符進一步獲取到eventpoll類型對象,再進行對應的操作,完成了用戶態和內核態的貫穿。

epoll_ctl底層調用epoll_insert實現:

 

  • 創建並初始化一個strut epitem類型的對象,完成該對象和被監控事件以及epoll對象eventpoll的關聯;

  • 將struct epitem類型的對象加入到epoll對象eventpoll的紅黑樹中管理起來;

  • 將struct epitem類型的對象加入到被監控事件對應的目標文件的等待列表中,並註冊事件就緒時會調用的回調函數,在epoll中該回調函數就是ep_poll_callback();

  • ovflist主要是暫態處理,調用ep_poll_callback()回調函數的時候發現eventpoll的ovflist成員不等於EP_UNACTIVE_PTR,說明正在掃描rdllist鏈表,這時將就緒事件對應的epitem加入到ovflist鏈表暫存起來,等rdllist鏈表掃描完再將ovflist鏈表中的元素移動到rdllist鏈表;

 

如圖展示了紅黑樹、雙鏈表、epitem之間的關係:

 

5.3 易混淆的數據拷貝

一種廣泛流傳的錯誤觀點:

epoll_wait返回時,對於就緒的事件,epoll使用的是共享內存的方式,即用戶態和內核態都指向了就緒鏈表,所以就避免了內存拷貝消耗

 

共享內存?不存在的!

關於epoll_wait使用共享內存的方式來加速用戶態和內核態的數據交互,避免內存拷貝的觀點,並沒有得到2.6內核版本代碼的證實,並且關於這次拷貝的實現是這樣的:

 

revents = ep_item_poll(epi, &pt);//獲取就緒事件
if (revents) {
  if (__put_user(revents, &uevent->events) ||
  __put_user(epi->event.data, &uevent->data)) {
    list_add(&epi->rdllink, head);//處理失敗則重新加入鏈表
    ep_pm_stay_awake(epi);
    return eventcnt ? eventcnt : -EFAULT;
  }
  eventcnt++;
  uevent++;
  if (epi->event.events & EPOLLONESHOT)
    epi->event.events &= EP_PRIVATE_BITS;//EPOLLONESHOT標記的處理
  else if (!(epi->event.events & EPOLLET)) {
    list_add_tail(&epi->rdllink, &ep->rdllist);//LT模式處理
    ep_pm_stay_awake(epi);
  }
}

6.LT模式和ET模式

 

epoll的兩種模式是留給用戶的發揮空間,也是個重點問題。

 

6.1 LT/ET的簡單理解

 

默認採用LT模式,LT支持阻塞和非阻塞套,ET模式只支持非阻塞套接字,其效率要高於LT模式,並且LT模式更加安全。

LT和ET模式下都可以通過epoll_wait方法來獲取事件,LT模式下將事件拷貝給用戶程序之後,如果沒有被處理或者未處理完,那麼在下次調用時還會反饋給用戶程序,可以認爲數據不會丟失會反覆提醒;

ET模式下如果沒有被處理或者未處理完,那麼下次將不再通知到用戶程序,因此避免了反覆被提醒,卻加強了對用戶程序讀寫的要求;

 

6.2 LT/ET的深入理解

 

上面的解釋在網上隨便找一篇都會講到,但是LT和ET真正使用起來,還是存在難度的。

 

6.2.1 LT的讀寫操作

 

LT對於read操作比較簡單,有read事件就讀,讀多讀少都沒有問題,但是write就不那麼容易了,一般來說socket在空閒狀態時發送緩衝區一定是不滿的,假如fd一直在監控中,那麼會一直通知寫事件,不勝其煩。

所以必須保證沒有數據要發送的時候,要把fd的寫事件監控從epoll列表中刪除,需要的時候再加入回去,如此反覆。

天下沒有免費的午餐,總是無代價地提醒是不可能的,對應write的過度提醒,需要使用者隨用隨加,否則將一直被提醒可寫事件。

 

6.2.2 ET的讀寫操作

 

fd可讀則返回可讀事件,若開發者沒有把所有數據讀取完畢,epoll不會再次通知read事件,也就是說如果沒有全部讀取所有數據,那麼導致epoll不會再通知該socket的read事件,事實上一直讀完很容易做到。

若發送緩衝區未滿,epoll通知write事件,直到開發者填滿發送緩衝區,epoll纔會在下次發送緩衝區由滿變成未滿時通知write事件。

ET模式下只有socket的狀態發生變化時纔會通知,也就是讀取緩衝區由無數據到有數據時通知read事件,發送緩衝區由滿變成未滿通知write事件。

 

6.2.3 一道騰訊面試題

 

彷彿有點蒙圈,那來一道面試題看看:

 

使用Linux epoll模型的LT水平觸發模式,當socket可寫時,會不停的觸發socket可寫的事件,如何處理?

 

確實是一道很好的問題啊!我們來分析領略一下其中深意。

這道題目對LT和ET考察比較深入,驗證了前文說的LT模式write問題。

普通做法:
當需要向socket寫數據時,將該socket加入到epoll等待可寫事件。接收到socket可寫事件後,調用write或send發送數據,當數據全部寫完後, 將socket描述符移出epoll列表,這種做法需要反覆添加和刪除。

改進做法:
向socket寫數據時直接調用send發送,當send返回錯誤碼EAGAIN,纔將socket加入到epoll,等待可寫事件後再發送數據,全部數據發送完畢,再移出epoll模型,改進的做法相當於認爲socket在大部分時候是可寫的,不能寫了再讓epoll幫忙監控。

上面兩種做法是對LT模式下write事件頻繁通知的修復,本質上ET模式就可以直接搞定,並不需要用戶層程序的補丁操作。

 

6.2.4 ET模式的線程飢餓問題

 

如果某個socket源源不斷地收到非常多的數據,在試圖讀取完所有數據的過程中,有可能會造成其他的socket得不到處理,從而造成飢餓問題。

解決辦法:
爲每個已經準備好的描述符維護一個隊列,這樣程序就可以知道哪些描述符已經準備好了但是並沒有被讀取完,然後程序定時或定量的讀取,如果讀完則移除,直到隊列爲空,這樣就保證了每個fd都被讀到並且不會丟失數據。

流程如圖:

 

6.2.5 EPOLLONESHOT設置

 

A線程讀完某socket上數據後開始處理這些數據,此時該socket上又有新數據可讀,B線程被喚醒讀新的數據,造成2個線程同時操作一個socket的局面 ,EPOLLONESHOT保證一個socket連接在任一時刻只被一個線程處理。

 

6.2.6 LT和ET的選擇

 

通過前面的對比可以看到LT模式比較安全並且代碼編寫也更清晰,但是ET模式屬於高速模式,在處理大高併發場景使用得當效果更好,具體選擇什麼根據自己實際需要和團隊代碼能力來選擇。

在知乎上有關於ET和LT選擇的對比,有很多大牛在其中發表觀點,感興趣可以前往查閱。

 

7.epoll的驚羣問題

 

如果你不知道什麼是驚羣效應,想象一下:

你在廣場喂鴿子,你只投餵了一份食物,卻引來一羣鴿子爭搶,最終還是隻有一隻鴿子搶到了食物,對於其他鴿子來說是徒勞的。

這種想象在網絡編程中同樣存在。

在2.6.18內核中accept的驚羣問題已經被解決了,但是在epoll中仍然存在驚羣問題,表現起來就是當多個進程/線程調用epoll_wait時會阻塞等待,當內核觸發可讀寫事件,所有進程/線程都會進行響應,但是實際上只有一個進程/線程真實處理這些事件。

在epoll官方沒有正式修復這個問題之前,Nginx作爲知名使用者採用全局鎖來限制每次可監聽fd的進程數量,每次只有1個可監聽的進程,後來在Linux 3.9內核中增加了SO_REUSEPORT選項實現了內核級的負載均衡,Nginx1.9.1版本支持了reuseport這個新特性,從而解決驚羣問題。

EPOLLEXCLUSIVE是在2016年Linux 4.5內核新添加的一個 epoll 的標識,Ngnix 在 1.11.3 之後添加了NGX_EXCLUSIVE_EVENT選項對該特性進行支持。EPOLLEXCLUSIVE標識會保證一個事件發生時候只有一個線程會被喚醒,以避免多偵聽下的驚羣問題。

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