1. 程式人生 > >深入理解Linux的I/O複用之epoll機制

深入理解Linux的I/O複用之epoll機制

0.概述

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

  1. I/O複用的定義和產生背景
  2. Linux系統的I/O複用工具演進
  3. epoll設計的基本構成
  4. epoll高效能的底層實現
  5. epoll的ET模式和LT模式
  6. epoll相關的一道有意思的面試題

1.複用技術和I/O複用

  • 複用的概念

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

  • 資源的可釋放性

舉個實際生活的例子:

不可釋放場景:

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

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

  • 理解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複用就像一隻邊牧管理幾百只綿羊一樣:

  • IO複用的設計原則和產生背景

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

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

 

2.Linux中IO複用工具

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

  • 開拓者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);

作為第一個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以內,對此我並沒有充分理解,只是提及一下這個坑。

存在的問題:

  1. 可協調fd數量和數值都不超過1024 無法實現高併發
  2. 使用O(n)複雜度遍歷fd陣列檢視fd的可讀寫性 效率低
  3. 涉及大量kernel和使用者態拷貝 消耗大
  4. 每次完成監控需要再次重新傳入並且分事件傳入 操作冗餘

綜上可知,select以樸素的方式實現了IO複用,將併發量提高的最大K級,但是對於完成這個任務的代價和靈活性都有待提高。無論怎麼樣select作為先驅對IO複用有巨大的推動,並且指明瞭後續的優化方向,不要無知地指責select。

  • 繼承者epoll

epoll最初在2.5.44核心版本出現,後續在2.6.x版本中對程式碼進行了優化使其更加簡潔,先後面對外界的質疑在後續增加了一些設定來解決隱藏的問題,所以epoll也已經有十幾年的歷史了。在《Unix網路程式設計》第三版(2003年)還沒有介紹epoll,因為那個時代epoll還沒有出現,書中只介紹了select和poll,epoll對select中存在的問題都逐一解決,簡單來說epoll的優勢包括:

  1. 對fd數量沒有限制(當然這個在poll也被解決了)
  2. 拋棄了bitmap陣列實現了新的結構來儲存多種事件型別
  3. 無需重複拷貝fd 隨用隨加 隨棄隨刪
  4. 採用事件驅動避免輪詢檢視可讀寫事件

綜上可知,epoll出現之後大大提高了併發量對於C10K問題輕鬆應對,即使後續出現了真正的非同步IO,也並沒有(暫時沒有)撼動epoll的江湖地位,主要是因為epoll可以解決數萬數十萬的併發量,已經可以解決現在大部分的場景了,非同步IO固然優異,但是程式設計難度比epoll更大,權衡之下epoll仍然富有生命力。

 

3.epoll的基本實現

  • epoll的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);
  1. poll_create是在核心區建立一個epoll相關的一些列結構,並且將一個控制代碼fd返回給使用者態,後續的操作都是基於此fd的,引數size是告訴核心這個結構的元素的大小,類似於stl的vector動態陣列,如果size不合適會涉及複製擴容,不過貌似4.1.2核心之後size已經沒有太大用途了;
  2. epoll_ctl是將fd新增/刪除於epoll_create返回的epfd中,其中epoll_event是使用者態和核心態互動的結構,定義了使用者態關心的事件型別和觸發時資料的載體epoll_data;
  3. epoll_wait是阻塞等待核心返回的可讀寫事件,epfd還是epoll_create的返回值,events是個結構體陣列指標儲存epoll_event,也就是將核心返回的待處理epoll_event結構都儲存下來,maxevents告訴核心本次返回的最大fd數量,這個和events指向的陣列是相關的;
  4. epoll_event是使用者態需監控fd的代言人,後續使用者程式對fd的操作都是基於此結構的;
  • 通俗描述:

可能上面的描述有些抽象,不過其實很好理解,舉個現實中的例子:

  1. epoll_create場景
    大學開學第一週,你作為班長需要幫全班同學領取相關物品,你在學生處告訴工作人員,我是xx學院xx專業xx班的班長,這時工作人員確定你的身份並且給了你憑證,後面辦的事情都需要用到(也就是呼叫epoll_create向核心申請了epfd結構,核心返回了epfd控制代碼給你使用);
  2. epoll_ctl場景
    你拿著憑證在辦事大廳開始辦事,分揀辦公室工作人員說班長你把所有需要辦理事情的同學的學生冊和需要辦理的事情都記錄下來吧,於是班長開始在每個學生手冊單獨寫對應需要辦的事情:李明需要開實驗室許可權、孫大熊需要辦游泳卡......就這樣班長一股腦寫完並交給了工作人員(也就是告訴核心哪些fd需要做哪些操作);
  3. epoll_wait場景
    你拿著憑證在領取辦公室門前等著,這時候廣播喊xx班長你們班孫大熊的游泳卡辦好了速來領取、李明實驗室許可權卡辦好了速來取....還有同學的事情沒辦好,所以班長只能繼續(也就是呼叫epoll_wait等待核心反饋的可讀寫事件發生並處理);
  • 官方DEMO

通過man epoll可以看到官方的demo:

#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);
       }
   }
}

 

4.epoll的底層實現

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

可以簡單的認為epitem是和每個使用者態監控IO的fd對應的,eventpoll是使用者態建立的管理所有被監控fd的結構,詳細的定義如下:

#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; };
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; }; 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; };
  • 底層呼叫過程

epoll_create會建立一個型別為struct eventpoll的物件,並返回一個與之對應檔案描述符,之後應用程式在使用者態使用epoll的時候都將依靠這個檔案描述符,而在epoll內部也是通過該檔案描述符進一步獲取到eventpoll型別物件,再進行對應的操作,完成了使用者態和核心態的貫穿。

epoll_ctl底層主要呼叫epoll_insert實現操作:

  1. 建立並初始化一個strut epitem型別的物件,完成該物件和被監控事件以及epoll物件eventpoll的關聯;
  2. 將struct epitem型別的物件加入到epoll物件eventpoll的紅黑樹中管理起來;
  3. 將struct epitem型別的物件加入到被監控事件對應的目標檔案的等待列表中,並註冊事件就緒時會呼叫的回撥函式,在epoll中該回調函式就是ep_poll_callback();
  4. ovflist主要是暫態處理,比如呼叫ep_poll_callback()回撥函式的時候發現eventpoll的ovflist成員不等於EP_UNACTIVE_PTR,說明正在掃描rdllist連結串列,這時將就緒事件對應的epitem加入到ovflist連結串列暫存起來,等rdllist連結串列掃描完再將ovflist連結串列中的元素移動到rdllist連結串列中;

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

注:rbr表示rb_root,rbn表示rb_node 上文給出了其在核心中的定義

  • epoll_wait的資料拷貝
常見錯誤觀點: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);
  }
}

 

5.ET模式和LT模式

  • 簡單理解

預設採用LT模式,LT支援阻塞和非阻塞套,ET模式只支援非阻塞套接字,其效率要高於LT模式,並且LT模式更加安全。LT和ET模式下都可以通過epoll_wait方法來獲取事件,LT模式下將事件拷貝給使用者程式之後,如果沒有被處理或者未處理完,那麼在下次呼叫時還會反饋給使用者程式,可以認為資料不會丟失會反覆提醒;ET模式下如果沒有被處理或者未處理完,那麼下次將不再通知到使用者程式,因此避免了反覆被提醒,卻加強了對使用者程式讀寫的要求;

  • 深入理解

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

  • LT的讀寫操作

LT對於read操作比較簡單,有read事件就讀,讀多讀少都沒有問題,但是write就不那麼容易了,一般來說socket在空閒狀態時傳送緩衝區一定是不滿的,假如fd一直在監控中,那麼會一直通知寫事件,不勝其煩。所以必須保證沒有資料要傳送的時候,要把fd的寫事件監控從epoll列表中刪除,需要的時候再加入回去,如此反覆。

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

  • ET的讀寫操作

fd可讀則返回可讀事件,若開發者沒有把所有資料讀取完畢,epoll不會再次通知read事件,也就是說如果沒有全部讀取所有資料,那麼導致epoll不會再通知該socket的read事件,事實上一直讀完很容易做到。若傳送緩衝區未滿,epoll通知write事件,直到開發者填滿傳送緩衝區,epoll才會在下次傳送緩衝區由滿變成未滿時通知write事件。ET模式下只有socket的狀態發生變化時才會通知,也就是讀取緩衝區由無資料到有資料時通知read事件,傳送緩衝區由滿變成未滿通知write事件。

  • 一道面試題
使用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模式就可以直接搞定,並不需要使用者層程式的補丁操作。

  • ET模式的執行緒飢餓問題

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

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

流程如圖:

 

  • EPOLLONESHOT設定

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

  • 兩種模式的選擇

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


6.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標識會保證一個事件發生時候只有一個執行緒會被喚醒,以避免多偵聽下的驚群問題。

7.巨人的肩膀

http://harlon.org/2018/04/11/networksocket5/

https://devarea.com/linux-io-multiplexing-select-vs-poll-vs-epoll/#.XfmWG6qFOUl

https://jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll/