網絡高併發服務器之 epoll 接口、epoll 反應堆模型詳解及代碼實現

epoll 接口是爲解決 Linux 內核處理大量文件描述符而提出的方案。該接口屬於 Linux 下多路 I/O 複用接口中 select/poll 的增強。其經常應用於 Linux 下高併發服務型程序,特別是在大量併發連接中只有少部分連接處於活躍下的情況 (通常是這種情況),在該情況下能顯著的提高程序的 CPU 利用率。

epoll 採用的是事件驅動,並且設計的十分高效。在用戶空間獲取事件時,不需要去遍歷被監聽描述符集合中所有的文件描述符,而是遍歷那些被內核 I/O 事件異步喚醒之後加入到就緒隊列並返回到用戶空間的描述符集合。

epoll 提供了兩種觸發模式,水平觸發 (LT) 和邊沿觸發(ET)。當然,涉及到 I/O 操作也必然會有阻塞和非阻塞兩種方案。目前效率相對較高的是 epoll+ET + 非阻塞 I/O 模型,在具體情況下應該合理選用當前情形中最優的搭配方案。

接下來的講解順序爲:

(1) epoll 接口的一般使用

(2) epoll 接口 + 非阻塞

(3) epoll 接口 + 非阻塞 + 邊沿觸發

(4) epoll 反應堆模型 (重點,Libevent 庫的核心思想)

一、epoll 接口的基本思想概述

epoll 的設計:

(1)epoll 在 Linux 內核中構建了一個文件系統,該文件系統採用紅黑樹來構建,紅黑樹在增加和刪除上面的效率極高,因此是 epoll 高效的原因之一。有興趣可以百度紅黑樹瞭解,但在這裏你只需知道其算法效率超高即可。

(2)epoll 紅黑樹上採用事件異步喚醒,內核監聽 I/O,事件發生後內核搜索紅黑樹並將對應節點數據放入異步喚醒的事件隊列中。

(3)epoll 的數據從用戶空間到內核空間採用 mmap 存儲 I/O 映射來加速。該方法是目前 Linux 進程間通信中傳遞最快, 消耗最小, 傳遞數據過程不涉及系統調用的方法。

epoll 總結描述 01

epoll 接口相對於傳統的 select/poll 而言,有以下優點:

(1)支持單個進程打開大數量的文件描述符。受進程最大打開的文件描述符數量限制,而不是受自身實現限制。而 select 單個進程能夠打開的文件描述符的數量存在最大限制,這個限制是 select 自身實現的限制。通常是 1024。poll 採用鏈表,也是遠超 select 的。

(2)Linux 的 I/O 效率不會隨着文件描述符數量的增加而線性下降。較之於 select/poll,當處於一個高併發時 (例如 10 萬,100 萬)。在如此龐大的 socket 集合中,任一時間裏其實只有部分的 socket 是“活躍” 的。select/poll 的處理方式是,對用如此龐大的集合進行線性掃描並對有事件發生的 socket 進行處理,這將極大的浪費 CPU 資源。因此 epoll 的改進是,由於 I/O 事件發生,內核將活躍的 socket 放入隊列並交給 mmap 加速到用戶空間,程序拿到的集合是處於活躍的 socket 集合,而不是所有 socket 集合。

(3)使用 mmap 加速內核與用戶空間的消息傳遞。select/poll 採用的方式是,將所有要監聽的文件描述符集合拷貝到內核空間(用戶態到內核態切換)。接着內核對集合進行輪詢檢測,當有事件發生時,內核從中集合並將集合複製到用戶空間。再看看 epoll 怎麼做的,內核與程序共用一塊內存,請看 epoll 總體描述 01 這幅圖,用戶與 mmap 加速區進行數據交互不涉及權限的切換 (用戶態到內核態,內核態到用戶態)。內核對於處於非內核空間的內存有權限進行讀取。

接下來我們結合 epoll 總體描述 01 與上述的內容,將圖示進行升級爲 epoll 總體描述 02。

epoll 總結描述 02

讓我們來看看圖的紅黑樹文件系統。以 epfd 爲根,掛載了一個監聽描述符和 5 個與客戶端建立連接的 cfd。fd 的增刪按照紅黑樹的操作方式,每一個文件描述符都有一個對應的結構,該結構爲

/*
 *  -[ epoll結構體描述01 ]-
 */
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;

在 epoll 總體描述 02 中,每個 fd 上關聯的即是結構體 epoll_event。它由需要監聽的事件類型和一個聯合體構成。一般的 epoll 接口將傳遞自身 fd 到聯合體。

因此,使用 epoll 接口的一般操作流程爲:

(1)使用 epoll_create() 創建一個 epoll 對象,該對象與 epfd 關聯,後續操作使用 epfd 來使用這個 epoll 對象,這個 epoll 對象纔是紅黑樹,epfd 作爲描述符只是能關聯而已。

(2)調用 epoll_ctl() 向 epoll 對象中進行增加、刪除等操作。

(3)調用 epoll_wait() 可以阻塞 (或非阻塞或定時) 返回待處理的事件集合。

(4)處理事件。

/*
 *  -[  一般epoll接口使用描述01  ]-
 */
int main(void)
{
 /* 
  *   此處省略網絡編程常用初始化方式(從申請到最後listen)
  *   並且部分的錯誤處理省略,我會在後面放上所有的源碼,這裏只放重要步驟
  *   部分初始化也沒寫
  */ 
  // [1] 創建一個epoll對象
  ep_fd = epoll_create(OPEN_MAX);       /* 創建epoll模型,ep_fd指向紅黑樹根節點 */
  listen_ep_event.events  = EPOLLIN;    /* 指定監聽讀事件 注意:默認爲水平觸發LT */
  listen_ep_event.data.fd = listen_fd;  /* 注意:一般的epoll在這裏放fd */ 
  // [2] 將listen_fd和對應的結構體設置到樹上
  epoll_ctl(ep_fd, EPOLL_CTL_ADD, listen_fd, &listen_ep_event);

  while(1) { 
      // [3] 爲server阻塞(默認)監聽事件,ep_event是數組,裝滿足條件後的所有事件結構體
      n_ready = epoll_wait(ep_fd, ep_event, OPEN_MAX, -1); 
      for(i=0; i<n_ready; i++) {
         temp_fd = ep_event[i].data.fd;

         if(ep_event[i].events & EPOLLIN){
            if(temp_fd == listen_fd) {  //說明有新連接到來
               connect_fd = accept(listen_fd, (struct sockaddr *)&client_socket_addr, &client_socket_len);
               // 給即將上樹的結構體初始化
               temp_ep_event.events  = EPOLLIN;
               temp_ep_event.data.fd = connect_fd;
               // 上樹
               epoll_ctl(ep_fd, EPOLL_CTL_ADD, connect_fd, &temp_ep_event);
             }
             else {                      //cfd有數據到來
               n_data = read(temp_fd , buf, sizeof(buf));
               if(n_data == 0)  {        //客戶端關閉
                   epoll_ctl(ep_fd, EPOLL_CTL_DEL, temp_fd, NULL) //下樹
                   close(temp_fd);
                }
                else if(n_data < 0) {}

                do {
                   //處理數據
                 }while( (n_data = read(temp_fd , buf, sizeof(buf))) >0 ) ;
             }
          }

         else if(ep_event[i].events & EPOLLOUT){
                //處理寫事件
         }
         else if(ep_event[i].events & EPOLLERR) {
                //處理異常事件
         }
      }      
   }
  close(listen_fd);
  close(ep_fd);
}

二、epoll 水平觸發 (LT)、epoll 邊沿觸發 (ET)

(1) epoll 水平觸發, 此方式爲默認情況。

當設置了水平觸發以後,以可讀事件爲例,當有數據到來並且數據在緩衝區待讀。即使我這一次沒有讀取完數據,只要緩衝區裏還有數據就會觸發第二次,直到緩衝區裏沒數據。

(2) epoll 邊沿觸發,此方式需要在設置

listen_ep_event.events  = EPOLLIN | EPOLLET;   /*邊沿觸發 */

當設置了邊沿觸發以後,以可讀事件爲例,對 “有數據到來” 這件事爲觸發。

epoll 觸發描述 01

總結:

  1. 用高低電平舉例子就是

水平觸發:0 爲無數據,1 爲有數據。緩衝區有數據則一直爲 1,則一直觸發。

邊沿觸發:0 爲無數據,1 爲有數據,只要在 0 變到 1 的上升沿才觸發。

緩衝區有數據可讀,觸發 ⇒ 水平觸發

緩衝區有數據到來,觸發 ⇒ 邊沿觸發

那麼,爲什麼說邊沿觸發 (ET) 的效率更高呢?

(1) 邊沿觸發只在數據到來的一刻才觸發,很多時候服務器在接受大量數據時會先接受數據頭部 (水平觸發在此觸發第一次,邊沿觸發第一次)。

(2) 接着服務器通過解析頭部決定要不要接這個數據。此時,如果不接受數據,水平觸發需要手動清除,而邊沿觸發可以將清除工作交給一個定時的清除程序去做,自己立刻返回。

(3) 如果接受,兩種方式都可以用 while 接收完整數據。

三、epoll + 非阻塞 I/O

在第一大部分中的 一般 epoll 接口使用描述 01 代碼中,我們使用的讀取數據函數爲 read 函數,在默認情況下此類函數是阻塞式的,在沒有數據時會一直阻塞等待數據到來。

(1)數據到來 100B,在 epoll 模式下調用 read 時,即使 read() 是阻塞式的也不會在這裏等待,因爲既然運行到 read(),說明數據緩衝區已經有數據,因此這處無影響。

(2)在服務器開發中,一般不會直接用採用類似 read() 函數這一類系統調用 (只有內核緩衝區),會使用封裝好的一些庫函數 (有內核緩衝區 + 用戶緩衝區) 或者自己封裝的函數。

例如:使用 readn() 函數,設置讀取 200B 返回,假設數據到來 100B, 可讀事件觸發,而程序要使用 readn() 讀 200B,那麼此時如果是阻塞式的,將在此處形成死鎖

流程是:100B ⇒ 觸發可讀事件 ⇒ readn() 調用 ⇒ readn() 都不夠 200B, 阻塞 ⇒ cfd 又到來 200B ⇒ 此時程序在 readn() 處暫停,沒有機會調用 epoll_wait() ⇒ 完成死鎖

解決:

將該 cfd 在上樹前設置爲非阻塞式

/* 修改cfd爲非阻塞讀 */
flag  = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(connect_fd, F_SETFL, flag);

四、epoll + 非阻塞 I/O + 邊沿觸發

/*
 *  -[  epoll+非阻塞+邊沿觸發 使用描述01  ]-
 */

 /* 其他情況不變,與*一般epoll接口使用描述01*描述一致 
  * 變化的僅有第二大部分和第三大部分的這兩段代碼即可
  */ 

/* ... ... */
listen_ep_event.events  = EPOLLIN | EPOLLET;   /*邊沿觸發 */
/* ... ... */
/* 修改cfd爲非阻塞讀 */
flag  = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(connect_fd, F_SETFL, flag); 
/* ... ...  */

五、epoll 反應堆模型 (Libevent 庫核心思想)

第一步,epoll 反應堆模型雛形 —— epoll 模型

我們將 epoll 總體描述 02 進行升級爲 epoll 反應堆模型總體描述 01

這裏和一般的 epoll 接口不同的是,現在正式稱之爲 epoll 模型 (epoll+ET + 非阻塞 + 自定義結構體)

(1) 還記得每一個在紅黑樹上的文件描述符所對應的結構體嗎?它的結構描述在第一大部分的 epoll 結構體描述 01 中。在 epoll 接口使用代碼中,我們在該結構體中的聯合體上傳入的是文件描述符本身,那麼 epoll 模型和 epoll 接口最本質的區別在於 epoll 模型中,傳入聯合體的是一個自定義結構體指針,該結構體的基本結構至少包括

struct my_events {  
    int        m_fd;                             //監聽的文件描述符
    void       *m_arg;                           //泛型參數
    void       (*call_back)(void *arg);          //回調函數
    /*
     *  你可以在此處封裝更多的數據內容
     *  例如用戶緩衝區、節點狀態、節點上樹時間等等
     */
};
/*
 * 注意:用戶需要自行開闢空間存放my_events類型的數組,並在每次上樹前用epoll_data_t裏的  
 *      ptr指向一個my_events元素。
 */

根據該模型,我們在程序中可以讓所有的事件都擁有自己的處理函數,你只需要使用 ptr 傳入即可。

(2) epoll_wait() 返回後,epoll 模型將不會採用一般 epoll 接口使用描述 01 代碼中的事件分類處理的辦法,而是直接調用事件中對應的回調函數,就像這樣

/*
 *  -[ epoll模型使用描述01  ]-
 */
 while(1) {
      /* 監聽紅黑樹, 1秒沒事件滿足則返回0 */ 
      int n_ready = epoll_wait(ep_fd, events, MAX_EVENTS, 1000);
      if (n_ready > 0) {
         for (i=0; i<n_ready; i++) 
            events[i].data.ptr->call_back(/* void *arg */);
       }
       else
          /*  
           * (3) 這裏可以做很多很多其他的工作,例如定時清除沒讀完的不要的數據
           *     也可以做點和數據庫有關的設置
           *     玩大點你在這裏搞搞分佈式的代碼也可以
           */
 }

epoll 反應堆模型總體描述 01

第二步,epoll 反應堆模型成型

到了這裏,也將是 epoll 的最終成型,如果從前面到這裏你都明白了,epoll 的知識你已經十之七八了

讓我們先回想以下 epoll 模型的那張圖,我們來理一理思路。

(1) 程序設置邊沿觸發以及每一個上樹的文件描述符設置非阻塞

(2) 調用 epoll_create() 創建一個 epoll 對象

(3) 調用 epoll_ctl() 向 epoll 對象中進行增加、刪除等操作

上樹的文件描述符與之對應的結構體,該結構體應該滿足填充事件與自定義結構體 ptr,此時,監聽的事件與回調函數已經確定了對吧?

(4) 調用 epoll_wait()(定時檢測) 返回待處理的事件集合。

(5) 依次調用事件集合中的每一個元素中的 ptr 所指向那個結構體中的回調函數。

以上爲雛形版本,那麼 epoll 反應堆模型還要比這個雛形版本多了什麼呢?

請看第三步的粗體字,當我們把描述符和自定義結構體上樹以後,如果放的是監聽可讀事件並做其對應的回調操作。也就是說,它將一直作爲監聽可讀事件而存在。

其流程是:

監聽可讀事件 (ET) ⇒ 數據到來 ⇒ 觸發事件 ⇒ epoll_wait() 返回 ⇒ 處理回調 ⇒ 繼續 epoll_wait() ⇒ 直到程序停止前都是這麼循環

那麼接下來升級爲成型版 epoll 反應堆模型

其流程是:

監聽可讀事件 (ET) ⇒ 數據到來 ⇒ 觸發事件 ⇒ epoll_wait() 返回 ⇒

讀取完數據 (可讀事件回調函數內) ⇒ 將該節點從紅黑樹上摘下 (可讀事件回調函數內) ⇒ 設置可寫事件和對應可寫回調函數 (可讀事件回調函數內) ⇒ 掛上樹 (可讀事件回調函數內) ⇒ 處理數據 (可讀事件回調函數內)

⇒ 監聽可寫事件 (ET) ⇒ 對方可讀 ⇒ 觸發事件 ⇒ epoll_wait() 返回 ⇒

寫完數據 (可寫事件回調函數內) ⇒ 將該節點從紅黑樹上摘下 (可寫事件回調函數內)⇒ 設置可讀事件和對應可讀回調函數 (可寫讀事件回調函數內) ⇒ 掛上樹 (可寫事件回調函數內) ⇒ 處理收尾工作 (可寫事件回調函數內)⇒ 直到程序停止前一直這麼交替循環

至此,結束

(1) 如此頻繁的增加刪除不是浪費 CPU 資源嗎?

答:對於同一個 socket 而言,完成收發至少佔用兩個樹上的位置。而交替只需要一個。任何一種設計方式都會有浪費 CPU 資源的時候,關鍵看你浪費得值不值,此處的耗費能否換來更大的收益纔是衡量是否浪費的標準。和第二個問題綜合來看,這裏不算浪費

(2) 爲什麼要可讀以後設置可寫,然後一直交替?

答:服務器的基本工作無非數據的收發,epoll 反應堆模型準從 TCP 模式,一問一答。服務器收到了數據,再給與回覆,是目前絕大多數服務器的情況。

(2-1) 服務器能收到數據並不是一定能寫數據

假設一 :服務器接收到客戶端數據,剛好此時客戶端的接收滑動窗口滿,我們假設不進行可寫事件設置,並且客戶端是有意讓自己的接收滑動窗口滿的情況 (黑客)。那麼,當前服務器將隨客戶端的狀態一直阻塞在可寫事件,除非你自己在寫數據時設置非阻塞 + 錯誤處理

假設二 :客戶端在發送完數據後突然由於異常原因停止,這將導致一個 FIN 發送至服務器,如果服務器不設置可寫事件監聽,那麼在接收數據後寫入數據會引發異常 SIGPIPE,最終服務器進程終止。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3a4ExrJLRQMrP4MWvQaSSw