Linux网络编程之socket:epoll系列函数简介,与select,poll函数的区别

一、epoll 系列函数简介

#include 
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 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#include 
#include 
#include 
#include 

#include 
#include 

#include "sysutil.h"

typedef std::vector 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 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(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 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 clients; 来保存每次accept 返回的conn,所以现在也需要将其擦除掉,调用clients.erase() 函数。


先运行服务器程序,再运行客户端,输出如下:

Linux网络编程之socket:epoll系列函数简介,与select,poll函数的区别_第1张图片


客户端

Linux网络编程之socket:epoll系列函数简介,与select,poll函数的区别_第2张图片


解释:

为什么服务器端的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









你可能感兴趣的:(Linux与网络通信,网络编程)