Linux網絡編程之socket:epoll系列函數簡介,與select,poll函數的區別

一、epoll 系列函數簡介

#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
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. int epoll_create(int size)

創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大。這個參數不同於select()中的第一個參數,給出最大監聽的fd+1的值。需要注意的是,當創建好epoll句柄後,它就是會佔用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll後,必須調用close()關閉,否則可能導致fd被耗盡。

2.epoll_create1 產生一個epoll 實例,返回的是實例的句柄。flag 可以設置爲0 或者EPOLL_CLOEXEC,爲0時函數表現與epoll_create一致,EPOLL_CLOEXEC標誌與open 時的O_CLOEXEC 標誌類似,即進程被替換時會關閉打開的文件描述符。

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

epoll的事件註冊函數,它不同與select()是在監聽事件時告訴內核要監聽什麼類型的事件,而是在這裏先註冊要監聽的事件類型。

第一個參數是epoll_create()的返回值,

第二個參數表示動作,用三個宏來表示:
EPOLL_CTL_ADD:註冊新的fd到epfd中;
EPOLL_CTL_MOD:修改已經註冊的fd的監聽事件;
EPOLL_CTL_DEL:從epfd中刪除一個fd;
第三個參數是需要監聽的fd.

第四個參數是告訴內核需要監聽什麼事

struct epoll_event結構如下:

typedef union epoll_data {
               void        *ptr;
               int          fd;
               uint32_t     u32;
               uint64_t     u64;
           } epoll_data_t;

 struct epoll_event {
          uint32_t     events;      /* Epoll events */
                epoll_data_t  data;        /* User data variable */
           };
events可以是以下幾個宏的集合:
EPOLLIN :表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT:表示對應的文件描述符可以寫;
EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這裏應該表示有帶外數據到來);
EPOLLERR:表示對應的文件描述符發生錯誤;
EPOLLHUP:表示對應的文件描述符被掛斷;
EPOLLET: 將EPOLL設爲邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。

EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之後,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列裏

關於ET、LT兩種工作模式:

epoll 的EPOLLLT (電平觸發,默認)和 EPOLLET(邊沿觸發)模式的區別

1、EPOLLLT:完全靠kernel epoll驅動,應用程序只需要處理從epoll_wait返回的fds,這些fds我們認爲它們處於就緒狀態。此時epoll可以認爲是更快速的poll。LT(level triggered)是缺省的工作方式,並且同時支持block和no-block socket.在這種做法中,內核告訴你一個文件描述符是否就緒了,然後你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你的,所以,這種模式編程出錯誤可能性要小一點。傳統的select/poll都是這種模型的代表.


2、EPOLLET:此模式下,系統僅僅通知應用程序哪些fds變成了就緒狀態,一旦fd變成就緒狀態,epoll將不再關注這個fd的任何狀態信息,(從epoll隊列移除)直到應用程序通過讀寫操作(非阻塞)觸發EAGAIN狀態,epoll認爲這個fd又變爲空閒狀態,那麼epoll又重新關注這個fd的狀態變化(重新加入epoll隊列)。隨着epoll_wait的返回,隊列中的fds是在減少的,所以在大併發的系統中,EPOLLET更有優勢,但是對程序員的要求也更高,因爲有可能會出現數據讀取不完整的問題。

ET(edge-triggered)是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變爲就緒時,內核通過epoll告訴你。然後它會假設你知道文件描述符已經就緒,並且不會再爲那個文件描述符發送更多的就緒通知,直到你做了某些操作導致那個文件描述符不再爲就緒狀態了(比如,你在發送,接收或者接收請求,或者發送接收的數據少於一定量時導致了一個EWOULDBLOCK 錯誤)。但是請注意,如果一直不對這個fd作IO操作(從而導致它再次變成未就緒),內核不會發送更多的通知(only once)


舉例如下:

假設現在對方發送了2k的數據,而我們先讀取了1k,然後這時調用了epoll_wait,如果是邊沿觸發,那麼這個fd變成就緒狀態就會從epoll 隊列移除,很可能epoll_wait 會一直阻塞,忽略尚未讀取的1k數據,與此同時對方還在等待着我們發送一個回覆ack,表示已經接收到數據;如果是電平觸發,那麼epoll_wait 還會檢測到可讀事件而返回,我們可以繼續讀取剩下的1k 數據。


3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的產生,類似於select()調用。參數events用來從內核得到事件的集合,maxevents告之內核這個events有多大,這個 maxevents的值不能大於創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。


下面爲用epoll寫的C++程序:

#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/epoll.h>

#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

#include <vector>
#include <algorithm>

#include "sysutil.h"

typedef std::vector<struct epoll_event> EventList;//定義新類型,內部裝着epoll_event結構體的容器

/* 相比於select與poll,epoll最大的好處是不會隨着關心的fd數目的增多而降低效率 */
int main(void)
{
    int count = 0;
    int listenfd;
    if ((listenfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
        ERR_EXIT("socket");

    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(5188);
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

    int on = 1;
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
        ERR_EXIT("setsockopt");

    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
        ERR_EXIT("bind");
    if (listen(listenfd, SOMAXCONN) < 0)
        ERR_EXIT("listen");

    std::vector<int> clients;//創建一個int類型的vector對象即clients

    int epollfd;
    epollfd = epoll_create1(EPOLL_CLOEXEC); //epoll實例句柄

    struct epoll_event event;//臨時保存需要監聽的fd等待其加入
    event.data.fd = listenfd;//將監聽套接字listenfd 加入關心的套接字序列
    event.events = EPOLLIN | EPOLLET; //邊沿觸發

    epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &event);/*第一個參數爲epoll實例句柄,第二個參數爲對文件描述符的操作
                                                        第三個參數爲需要操作的目標文件描述符,第四個參數告訴內核需
                                                        要監聽什麼事,即第三個參數fd的event
                                                         */
    EventList events(16);/*即初始化容器的大小爲16,當返回的事件個數nready 已經等於16時,
                         需要增大容器的大小,使用events.resize 函數即可,容器可以動態增大,
                         這也是我們使用c++實現的其中一個原因
                         */

    struct sockaddr_in peeraddr;
    socklen_t peerlen;
    int conn;
    int i;

    int nready;
    while (1)
    {
        nready = epoll_wait(epollfd, &*events.begin(), static_cast<int>(events.size()), -1);
        /*第一個參數爲epoll句柄,第二個參數用來從內核得到事件的集合,這裏將得到的事件保存在events容器中,
        上面設置的初始大小爲16第三個參數告訴內核這個事件集合有多大,第四個參數爲等待I/O事件的超時值,
        -1表示永不超時,返回值爲需要處理的事件的個數。返回0表示已經超時
        */
        if (nready == -1)
        {
            if (errno == EINTR)
                continue;

            ERR_EXIT("epoll_wait error");
        }
        if (nready == 0)
            continue;

        if ((size_t)nready == events.size())//當返回的事件個數nready 已經等於16時,需要增大容器的大小
            events.resize(events.size() * 2);

        for (i = 0; i < nready; i++)
        {
            if (events[i].data.fd == listenfd)//第一個爲監聽套接字
            {
                peerlen = sizeof(peeraddr);
                conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen);
                if (conn == -1)
                    ERR_EXIT("accept error");

                printf("ip=%s port=%d\n", inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port));
                printf("count = %d\n", ++count);
                clients.push_back(conn);/*使用 std::vector<int> clients;,來保存每次accept 返回的conn,
                                        這裏表示向容器中添加一個新的值
                                        */

                activate_nonblock(conn);// 將conn 設置爲非阻塞

                event.data.fd = conn;
                event.events = EPOLLIN | EPOLLET;//設置爲邊沿觸發
                epoll_ctl(epollfd, EPOLL_CTL_ADD, conn, &event);//使用epoll_ctl 函數將conn其加入關心的套接字序列
            }
            else if (events[i].events & EPOLLIN)
            {
                conn = events[i].data.fd;
                if (conn < 0)
                    continue;

                char recvbuf[1024] = {0};
                int ret = read(conn, recvbuf, 1024);
                if (ret == -1)
                    ERR_EXIT("read error");
                if (ret == 0)
                {
                    printf("client close\n");
                    close(conn);

                    event = events[i];//從容器中取出上面關閉的conn中的事件
                    epoll_ctl(epollfd, EPOLL_CTL_DEL, conn, &event);//使用epoll_ctl函數將其刪除關心的套接字序列
                    clients.erase(std::remove(clients.begin(), clients.end(), conn), clients.end());
                    /*std:remove(first,last,val)返回一個迭代器,指向由begin到end區間上第一個要刪除的元素,
                    這裏是conn,然後在第一個要刪除的元素(conn)到clients.end()的區間上調用erase(),從而刪除
                    所有的要刪除的元素,使得vector只包含未被刪除的元素。
                    */
                }

                fputs(recvbuf, stdout);
                write(conn, recvbuf, strlen(recvbuf));
            }

        }
    }

    return 0;
}

在程序的最開始定義一個新類型EventList,內部裝着struct epoll_event 結構體的容器。

接下面的socket,bind,listen 都跟以前說的一樣,不述。接着使用epoll_create1 創建一個epoll 實例,再來看下面四行代碼:

struct epoll_event event;
 event.data.fd = listenfd;
 event.events = EPOLLIN | EPOLLET; //邊沿觸發
 epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &event);

根據前面的函數分析,這四句意思就是將監聽套接字listenfd 加入關心的套接字序列。

在epoll_wait 函數中的第二個參數,其實events.begin() 是個迭代器,但其具體實現也是struct epoll_event* 類型,雖然 &*events.begin() 得到的也是struct epoll_event* ,但不能直接使用events.begin() 做參數,因爲類型不匹配,編譯會出錯。

EventList events(16); 即初始化容器的大小爲16,當返回的事件個數nready 已經等於16時,需要增大容器的大小,使用events.resize 函數即可,容器可以動態增大,這也是我們使用c++實現的其中一個原因。

當監聽套接字有可讀事件,accept 返回的conn也需要使用epoll_ctl 函數將其加入關心的套接字隊列。

還需要調用 activate_nonblock(conn); 將conn 設置爲非阻塞,man 7 epoll 裏有這樣一句話:

An application that employs the EPOLLET flag should use nonblocking file descriptors to avoid having a  blocking  read  or
 write  starve  a  task  that  is  handling multiple file descriptors.

當下次循環回來某個已連接套接字有可讀事件,則讀取數據,若read 返回0表示對方關閉,需要使用epoll_ctl 函數將conn 從隊列中清除,我們使用 std::vector<int> clients; 來保存每次accept 返回的conn,所以現在也需要將其擦除掉,調用clients.erase() 函數。


先運行服務器程序,再運行客戶端,輸出如下:



客戶端



解釋:

爲什麼服務器端的count 只有1019呢,因爲除去012,一個監聽套接字還有一個epoll 實例句柄,所以1024 - 5 = 1019。

爲什麼客戶端的錯誤提示跟這裏的不一樣呢?這正說明epoll 處理效率比poll和select 都高,因爲處理得快,來一個連接就accept一個,當服務器端accept 完第1019個連接,再次accept 時會因爲文件描述符總數超出限制,打印錯誤提示,而此時客戶端雖然已經創建了第1020個sock,但在connect 過程中發現對等方已經退出了,故打印錯誤提示,連接被對等方重置。如果服務器端處理得慢的話,那麼客戶端會connect 成功1021個連接,然後在創建第1022個sock 的時候出錯,打印錯誤提示:socket: Too many open files,當然因爲文件描述符的限制,服務器端也只能從已完成連接隊列中accept 成功1019個連接。


二、epoll與select、poll區別

1、相比於select與poll,epoll最大的好處在於它不會隨着監聽fd數目的增長而降低效率。內核中的select與poll的實現是採用輪詢來處理的,輪詢的fd數目越多,自然耗時越多。


2、epoll的實現是基於回調的,如果fd有期望的事件發生就通過回調函數將其加入epoll就緒隊列中,也就是說它只關心“活躍”的fd,與fd數目無關。


3、內核 / 用戶空間 內存拷貝問題,如何讓內核把 fd消息通知給用戶空間呢?在這個問題上select/poll採取了內存拷貝方法。而epoll採用了內核和用戶空間共享內存的方式。


4、epoll不僅會告訴應用程序有I/0 事件到來,還會告訴應用程序相關的信息,這些信息是應用程序填充的,因此根據這些信息應用程序就能直接定位到事件,而不必遍歷整個fd集合。


5、當已連接的套接字數量不太大,並且這些套接字都非常活躍,那麼對於epoll 來說一直在調用callback 函數(epoll 內部的實現更復雜,更復雜的代碼邏輯),可能性能沒有poll 和 select 好,因爲一次性遍歷對活躍的文件描述符處理,在連接數量不大的情況下,性能更好,但在處理大量連接的情況時,epoll 明顯佔優。


補充:

select的特點:select 選擇句柄的時候,是遍歷所有句柄,也就是說句柄有事件響應時,select需要遍歷所有句柄才能獲取到哪些句柄有事件通知,因此效率是非常低。但是如果連接很少的情況下, select和epoll的LT觸發模式相比, 性能上差別不大。
這裏要多說一句,select支持的句柄數是有限制的, 同時只支持1024個,這個是句柄集合限制的,如果超過這個限制,很可能導致溢出,而且非常不容易發現問題, TAF就出現過這個問題, 調試了n天,才發現:)當然可以通過修改linux的socket內核調整這個參數。
epoll的特點:epoll對於句柄事件的選擇不是遍歷的,是事件響應的,就是句柄上事件來就馬上選擇出來,不需要遍歷整個句柄鏈表,因此效率非常高,內核將句柄用紅黑樹保存的。
對於epoll而言還有ET和LT的區別,LT表示水平觸發,ET表示邊緣觸發,兩者在性能以及代碼實現上差別也是非常大的。



EPOLL的LT與ET的深入說明:

LT:水平觸發,效率會低於ET觸發,尤其在大併發,大流量的情況下。但是LT對代碼編寫要求比較低,不容易出現問題。LT模式服務編寫上的表現是:只要有數據沒有被獲取,內核就不斷通知你,因此不用擔心事件丟失的情況。
ET:邊緣觸發,效率非常高,在併發,大流量的情況下,會比LT少很多epoll的系統調用,因此效率高。但是對編程要求高,需要細緻的處理每個請求,否則容易發生丟失事件的情況。


下面舉一個列子來說明LT和ET的區別(都是非阻塞模式,阻塞就不說了,效率太低):

採用LT模式下, 如果accept調用有返回就可以馬上建立當前這個連接了,再epoll_wait等待下次通知,和select一樣。
但是對於ET而言,如果accpet調用有返回,除了建立當前這個連接外,不能馬上就epoll_wait還需要繼續循環accpet,直到返回-1,且errno==EAGAIN,TAF裏面的示例代碼:

if(ev.events& EPOLLIN)
{
    do
    {
        struct sockaddr_in stSockAddr;
        socklen_t iSockAddrSize = sizeof(sockaddr_in);
        TC_Socket cs;
        cs.setOwner(false);
        //接收連接
        TC_Socket s;
        s.init(fd, false, AF_INET);
        int iRetCode = s.accept(cs, (struct sockaddr*) &stSockAddr, iSockAddrSize);
        if (iRetCode > 0)
        {
            ...建立連接
        }
        else
        {
            //直到發生EAGAIN纔不繼續accept
            if(errno == EAGAIN)
            {
                break;
            }
        }
    }while(true);
}

同樣,recv/send等函數, 都需要到errno==EAGAIN

讀數據的時候需要考慮的是當recv()返回的大小如果等於請求的大小,那麼很有可能是緩衝區還有數據未讀完,也意味着該次事件還沒有處理完,所以還需要再次讀取

while(rs)
{
  buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
  if(buflen < 0)
  {
    // 由於是非阻塞的模式,所以當errno爲EAGAIN時,表示當前緩衝區已無數據可讀
    // 在這裏就當作是該次事件已處理處.
    if(errno == EAGAIN)
     break;
    else
     return;
   }
   else if(buflen == 0)
   {
     // 這裏表示對端的socket已正常關閉.
   }
   if(buflen == sizeof(buf)
     rs = 1;   // 需要再次讀取
   else
     rs = 0;
}

還有,假如發送端流量大於接收端的流量(意思是epoll所在的程序讀比轉發的socket要快),由於是非阻塞的socket,那麼send()函數雖然返回,但實際緩衝區的數據並未真正發給接收端,這樣不斷的讀和發,當緩衝區滿後會產生EAGAIN錯誤(參考man send),同時,不理會這次請求發送的數據.所以,需要封裝socket_send()的函數用來處理這種情況,該函數會盡量將數據寫完再返回,返回-1表示出錯。在socket_send()內部,當寫緩衝已滿(send()返回-1,且errno爲EAGAIN),那麼會等待後再重試.這種方式並不很完美,在理論上可能會長時間的阻塞在socket_send()內部,但暫沒有更好的辦法.

ssize_t socket_send(int sockfd, const char* buffer, size_t buflen)
{
  ssize_t tmp;
  size_t total = buflen;
  const char *p = buffer;

  while(1)
  {
    tmp = send(sockfd, p, total, 0);
    if(tmp < 0)
    {
      // 當send收到信號時,可以繼續寫,但這裏返回-1.
      if(errno == EINTR)
        return -1;

      // 當socket是非阻塞時,如返回此錯誤,表示寫緩衝隊列已滿,
      // 在這裏做延時後再重試.
      if(errno == EAGAIN)
      {
        usleep(1000);
        continue;
      }

      return -1;
    }

    if((size_t)tmp == total)
      return buflen;

    total -= tmp;
    p += tmp;
  }

  return tmp;
}


從本質上講:與LT相比,ET模型是通過減少系統調用來達到提高並行效率的。

EPOLL ET詳解:

ET模型的邏輯:內核的讀buffer有內核態主動變化時,內核會通知你, 無需再去mod。寫事件是給用戶使用的,最開始add之後,內核都不會通知你了,你可以強制寫數據(直到EAGAIN或者實際字節數小於 需要寫的字節數),當然你可以主動mod OUT,此時如果句柄可以寫了(send buffer有空間),內核就通知你。
這裏內核態主動的意思是:內核從網絡接收了數據放入了讀buffer(會通知用戶IN事件,即用戶可以recv數據)
並且這種通知只會通知一次,如果這次處理(recv)沒有到剛纔說的兩種情況(EAGIN或者實際字節數小於 需要讀寫的字節數),則該事件會被丟棄,直到下次buffer發生變化。
與LT的差別就在這裏體現,LT在這種情況下,事件不會丟棄,而是隻要讀buffer裏面有數據可以讓用戶讀,則不斷的通知你。

另外對於ET而言,當然也不一定非send/recv到前面所述的結束條件才結束,用戶可以自己隨時控制,即用戶可以在自己認爲合適的時候去設置IN和OUT事件:
1 如果用戶主動epoll_mod OUT事件,此時只要該句柄可以發送數據(發送buffer不滿),則epoll
_wait就會響應(有時候採用該機制通知epoll_wai醒過來)。
2 如果用戶主動epoll_mod IN事件,只要該句柄還有數據可以讀,則epoll_wait會響應。
這種邏輯在普通的服務裏面都不需要,可能在某些特殊的情況需要。 但是請注意,如果每次調用的時候都去epoll mod將顯著降低效率。

因此採用et寫服務框架的時候,最簡單的處理就是:
建立連接的時候epoll_add IN和OUT事件, 後面就不需要管了
每次read/write的時候,到兩種情況下結束:
1 發生EAGAIN
2 read/write的實際字節數小於 需要讀寫的字節數
對於第二點需要注意兩點:
A:如果是UDP服務,處理就不完全是這樣,必須要recv到發生EAGAIN爲止,否則就丟失事件了
因爲UDP和TCP不同,是有邊界的,每次接收一定是一個完整的UDP包,當然recv的buffer需要至少大於一個UDP包的大小
隨便再說一下,一個UDP包到底應該多大?
對於internet,由於MTU的限制,UDP包的大小不要超過576個字節,否則容易被分包,對於公司的IDC環境,建議不要超過1472,否則也比較容易分包。

B 如果發送方發送完數據以後,就close連接,這個時候如果recv到數據是實際字節數小於讀寫字節數,根據開始所述就認爲到EAGIN了從而直接返回,等待下一次事件,這樣是有問題的,close事件丟失了!
因此如果依賴這種關閉邏輯的服務,必須接收數據到EAGIN爲止,例如lb。


按照我目前的瞭解,EPOLL模型似乎只有一種格式,所以大家只要參考我下面的代碼,就能夠對EPOLL有所瞭解了,代碼的解釋都已經在註釋中:

while(TRUE)
{
  int nfds = epoll_wait (m_epoll_fd, m_events, MAX_EVENTS, EPOLL_TIME_OUT);//等待EPOLL時間的發生,相當於監聽,至於相關的端口,需要在初始化EPOLL的時候綁定。
  if (nfds <= 0)
    continue;
  m_bOnTimeChecking = FALSE;
  G_CurTime = time(NULL);
  for (int i=0; i<nfds; i++)
  {
    try
    {
      if (m_events[i].data.fd == m_listen_http_fd)//如果新監測到一個HTTP用戶連接到綁定的HTTP端口,建立新的連接。由於我們新採用了SOCKET連接,所以基本沒用。
      {
        OnAcceptHttpEpoll ();
      }
      else if (m_events[i].data.fd == m_listen_sock_fd)//如果新監測到一個SOCKET用戶連接到了綁定的SOCKET端口,建立新的連接。
      {
        OnAcceptSockEpoll ();
      }
      else if (m_events[i].events & EPOLLIN)//如果是已經連接的用戶,並且收到數據,那麼進行讀入。
      {
        OnReadEpoll (i);
      }
      OnWriteEpoll (i);//查看當前的活動連接是否有需要寫出的數據。
    }
    catch (int)
    {
      PRINTF ("CATCH捕獲錯誤/n");
      continue;
    }
  }
  m_bOnTimeChecking = TRUE;
  OnTimer ();//進行一些定時的操作,主要就是刪除一些短線用戶等。
}









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