欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Select_poll_epoll详解

程序员文章站 2022-06-14 11:28:52
...

Select_poll_epoll详解


参考链接

  1. epoll简介及触发模式(accept、read、send)
  2. epoll内核源码详解+自己总结的流程
  3. linux man page

epoll函数

注意: epoll不属于任何namespace。

#include <sys/epoll.h>

int epoll_create(int size);  // return epollfd, 失败return -1

/*
op:
EPOLL_CTL_ADD
EPOLL_CTL_MOD
EPOLL_CTL_DEL 如果是delete的话, epoll_ctl的最后一个参数event可以是NULL
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  // 成功return0, 失败return -1
int epoll_wait(int epfd, struct epoll_event *events,
                int maxevents, int timeout);  // 成功return nready. 失败return -1

//epoll_event
/*
其实这个epoll_data只是给用户自行使用的,epoll不关心里面的内容。 这个dta回随着epoll_data 返回的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 */
};

close

其实在外面关闭一个fd之后,就可以不用再在epoll list里面删除了,但是为了安全起见,还是用EPOLL_CTL_DEL删掉吧。详情可以看 epoll(7) man page FAQ。

epoll event

  1. EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
  2. EPOLLOUT:表示对应的文件描述符可以写;
  3. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  4. EPOLLERR:表示对应的文件描述符发生错误;
  5. EPOLLHUP:表示对应的文件描述符被挂断;
  6. EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
  7. EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

EL/LT

有关ET/LT 阻塞/非阻塞的操作,网络上基本都是错的,只要你安排的好,既可以用阻塞,也可以用非阻塞。(linux man page上也让你用阻塞)

ET Edge Trigger 边沿触发工作模式

  1. 必须使用非阻塞 工作模式,因为在循环调用epoll_wait的时候,有可能某个句柄已知会ready, 如果用阻塞操作,会导致一个文件句柄的阻塞操作把多个文件描述符饿死。
    1. 基于非阻塞文件句柄
    2. 只有当read(2)或者write(2)返回EAGAIN时才需要挂起,等待(退出read/write返回epoll_wait)。但这并不是说每次read()时都需要循环读,直到读到产生一个EAGAIN才认为此次事件处理完成,当read()返回的读到的数据长度小于请求的数据长度时(即小于sizeof(buf)),就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。
    3. 阻塞IO的事件处理原则:
      1. recv() > 0:(并且小于请求的数据长度sizeof(buf)), 表示接收数据完毕,返回值即是接收到的字节数。
      2. recv() == 0: 表示链接已经正常断开,这个时候就可以把fd关掉,从epoll里面移除了
      3. recv() < 0 && errno == EAGAIN: 表示recv操作还未完成
      4. recv() < 0 && errno != EAGAIN: 表示操作遇到系统errno
  2. 边缘触发但是这种模式下在读数据的时候一定要注意,因为如果一次可写事件我们没有把数据读完,如果没有读完,在socket没有新的数据可读时epoll就不回返回了,只有在新的数据到来时,我们才能读取到上次没有读完的数据。最差的情况是client在发送的n个byte之后已经关闭了,但是epoll由于接收缓冲区没有清空,这个fd在服务端并不会关掉。
  3. 使用ET模式,就算接收缓冲区里的数据没有读完,如果再接收到新的数据, epoll_wait 还是会触发可读事件的。
  4. 设置为EPOLLET之后仍然会对同一事件多次触发的原因:
    1. 接收缓冲区过小,无法容纳所有发送过来的数据(这个我自己没有复现出来)
    2. 用EPOLL_CTL_MOD更改了epollevent,会重置之前的触发

LT Level Trigger 水平触发工作模式

  1. poll(), select() 都是水平触发
  2. 如果我们用水平触发不用担心数据有没有读完因为下次epoll返回时,没有读完的socket依然会被返回
  3. 但是要注意这种模式下的写事件,因为是水平触发,每次socket可写时epoll都会返回,当我们写的数据包过大时,一次写不完,要多次才能写完或者每次socket写都写一个很小的数据包时,每次写都会被epoll检测到,因此长期关注socket写事件会无故cpu消耗过大甚至导致cpu跑满,所以在水平触发模式下我们一般不关注socket可写事件而是通过调用socket write或者send api函数来写socket
  4. 但是如果使用LT模式,每次读事件只要调用一次recv()就可以了。不用像ET一样反复调用recv()直到返回EAGAIN,对于追求低延迟的系统调用来说,这么做是搞笑的,并且也不用担心因为某个连接上数据量过大导致影响其他连接处理消息。
  5. 我们可以看到这种模式在效率上是没有边缘触发高的,因为每个socket读或者写可能被返回两次甚至多次

epoll 源码解析

https://blog.csdn.net/wangyin159/article/details/48895287

epoll_wait

  1. 检查MAXEXENT参数
  2. 用access_ok() 检查event指针是否可写,如果这个指针是空指针或者指向内核态的指针,那么会设置errno EFAULT。
    1. Just because a pointer was supplied by userspace doesn't mean that it's definitely a userspace pointer - in many cases "kernel pointer" simply means that it's pointing within a particular region of the virtual address space.https://*.com/questions/12357752/what-is-the-point-of-using-the-linux-macro-access-ok
  3. 获取epfd对应的eventpoll文件实例,如果取不到,errno:EBADF
  4. 检查eventpoll文件是不是真的是一个epoll文件, 如果不是说值errno EINVAL
  5. 其实epoll_wait 中如果出错了,那么基本上应该是程序本身的问题,比如陷入死循环之类
  6. 调用ep_epoll函数,这个函数在做一些配置之后就会主动让出处理器,进入睡眠状态,等待文件就绪(回调函数唤醒本进程)或者超时或者信号中断
  • 缺省的工作模式

一道腾讯后台开发面试题

Q:使用Linux epoll模型,水平(LT)触发模式,当socket可写时,会不停的触发socket可写的事件,如何处理?

  1. 第一种最普遍的方式:
    • 需要向socket写数据的时候才把socket加入epoll,等待可写事件。接受到可写事件后,调用write或者send发送数据。当所有数据都写完后,把socket移出epoll(用EPOLLONESHOT也行)。
    • 这种方式的缺点是,即使发送很少的数据,也要把socket加入epoll,写完后在移出epoll,有一定操作代价。
  2. 一种改进的方式:
    • 开始不把socket加入epoll,需要向socket写数据的时候,直接调用write或者send发送数据。如果返回EAGAIN(缓冲区满了,后面还需要继续发),把socket加入epoll,在epoll的驱动下写数据,全部数据发送完毕后,再移出epoll。
    • 这种方式的优点是:数据不多的时候可以避免epoll的事件处理,提高效率。

ET/LT 比较

  1. 因为ET要基于非阻塞IO, LT在读写的时候不必等待EAGAIN的出现,可以节省系统调用次数,降低延迟
  2. ET可以保证每次只触发一个

epoll 优点

  1. 对应select()的缺点, epoll都有解决的方法

    1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
      • 使用epoll_ctl()函数,只有在注册、修改、删除的时候才会对内核进行操作。
    2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
      • epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)
    3. select支持的文件描述符数量太小了,默认是1024
      • epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,我的1GB内存阿里云ECS是999999,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
  2. poll每次返回整个文件描述符数组, 用户需要遍历数组已找到哪些文件描述符上有IO事件。 而epoll_wait(2)返回的是活动fd的列表,需要遍历的数组通常会小很多,在并发连接数较大而活动连接比例不高时,epoll(4)比epoll(2)更高效。

epoll 源码解读

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:

struct eventpoll{
    ....
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
    ....
    };

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。

struct epitem{
    struct rb_node  rbn;//红黑树节点
    struct list_head    rdllink;//双向链表节点
    struct epoll_filefd  ffd;  //事件句柄信息
    struct eventpoll *ep;    //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
    }

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

Select_poll_epoll详解

select()

select()简介

  1. select()函数是阻塞的, 只有某些端口状态转换了或者达到timeout才会返回
  2. 该函数可以允许进程指示等待多个事件中任何一个的发生
  3. select(), poll() 都是水平触发

为什么需要select()?

  1. 多路复用io mutiplexing
    1. 如果不采用多路复用,要么使用阻塞IO(会使线程长时间处于阻塞状态,无法执行任何计算或者响应任何网络请求),要么使用非阻塞IO:(要用while循环调用recv函数,大幅占用CPU资源), 复用的优势在于可以同时处理多个连接

select()函数

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfdp1,fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

返回: 若有描述符就绪,则返回就绪描述符的数量,若超时则为0, 若出错则为1

1. timeout-->timeval

struct timeval {
  long tv_sec;  // seconds
  log tv_usec;  // microseconds
}
  • 用于指定timeout的秒数和微秒数
  • 如果输入为0,那么select函数会一直等下去一直到某个描述符准备好
  • 如果输入这个参数,那么最长等待时间就确定了
  • 如果输入这个结构,但是其中的两个值为0,那么就不等待-->轮询机制
延伸 gettimeofday()
  • 用gettimeofday() 可以获得微秒(us)级别的时间。
  • 会把目前的时间tv所指的结构返回,当地时区的信息则放到tz所指的结构中。
  • 1970年1月1日到现在的时间
  • 调用两次gettimeofday(), 前后做减法,从而达到计算时间的目的。
#include <sys/time.h>
int gettimeofday(struct timeval *tv,struct timezone *tz);

2. readset, writeset, exceptset

#include <sys/select.h>

struct fd_set myset;
//四个相关的宏函数

void FD_ZERO(fd_set *fdset);  // clean all bits at fdset
void FD_SET(int fd, fd_set *fdset);  // turn on the bit for fd in fdset
void FD_CLR(int fd, fd_set *fdset);  // turn on the bit for fd in fdset
void FD_ISSET(int fd, fd_set *fdset);  //is the bit for fd on in fdset? 如果set了,返回1
  1. fd_set 每一位表示一个fd, set其中的某一位就表示要监视某个fd.
  2. 指针输入, 输入的时候把我们所关心的fd置为1. 返回时,他将指示哪些描述符已经就绪了。因此,每次重新调用select时,我们都需要再次把所有我们关心的描述符置为1。

3. maxfdp1

  1. maxfdp1 = 最大描述符+1
  2. 最大描述符系统内是有定义的 FD_SETSIZE

例子

select\strcliselect01.c

void
str_cli(FILE *fp, int sockfd)
{
  int maxfdp1;
  fd_set  rset;
  char  sendline[MAXLINE], recvline[MAXLINE];

  FD_ZERO(&rset);
  for ( ; ; ) {
    FD_SET(fileno(fp), &rset);
    FD_SET(sockfd, &rset);
    maxfdp1 = max(fileno(fp), sockfd) + 1;
    Select(maxfdp1, &rset, NULL, NULL, NULL);

    if (FD_ISSET(sockfd, &rset)) {  /* socket is readable */
      if (Readline(sockfd, recvline, MAXLINE) == 0)
        err_quit("str_cli: server terminated prematurely");
      Fputs(recvline, stdout);
    }

    if (FD_ISSET(fileno(fp), &rset)) {  /* input is readable */
      if (Fgets(sendline, MAXLINE, fp) == NULL)
        return;		/* all done */
      Writen(sockfd, sendline, strlen(sendline));
    }
  }
}

select 缺点

  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  3. select支持的文件描述符数量太小了,默认是1024

select() 文件描述符上限

这个问题的关键其实要先理解select关于文件描述符上限的原因

  1. linux系统本身就有文件描述符上限,文件描述符的建立会连带建立很多其它表项,具体可以搜索文件描述符的详解,也就是说文件描述符一定会占用资源,那在有限的硬件条件下,文件描述符必定会有上限,我在ubuntu14.04的ECS里通过
cat /proc/sys/fs/file-max //结果99999
  1. 进程文件描述符上限user limit中nofile的soft limit,实际上这是单个用户的文件描述符上限,通过
ulimit -n //结果65535

soft limit可以修改,但是不能超过hard limit

ulimit -Hn //结果65535
  1. select函数本身限制,主要是头文件中FD_SETSIZE的大小,一般来说是1024,这就限定了select函数中的文件描述符上限,当然可以做修改,但是需要重新编译内核,而且效果由于select的实现机制,会比较差

poll()

#include <poll.h>
#include <limits.h>  /* for OPEN_MAX */ // 描述了poll的最大数量

int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);

返回: 若有描述符就绪,则返回就绪描述符的数量,若超时则为0, 若出错则为1

fdarray

指向一个数组结构第一个元素的指针,没一个元素都是一个pollfd结构,使用这个结构,避免了select中使用一个参数既表示我们关心的值,又表示结果。

struct pollfd {
  int fd;  // 描述符
  short events;  // 我们关心的状态
  short revents;  // 返回的结果
}

nfds

第一个参数中的数组元素的个数

timeout

timeout 说明
INFTIM 永远等待
0 立即返回,不阻塞进程
> 0 等待指定的毫秒

poll() 文件描述符上限

poll虽然不像select一样受到select() 中FD_SETSIZE 的限制,但是仍然受到ulimit中设定的一个进程所能打开的最大文件描述符的限制

ulimit -n //结果65535

poll()/select()的区别

  1. poll() 解决了select文件描述符最大只有1024的限制
  2. select和poll都需要自己不断轮询所有fd集合,直到设备就绪,(首先把所有的fd挂到对应的等待队列上,然后睡眠,在设备收到一条消息或者填写完文件数据之后,会唤醒设备等待队列上的进程,进程会再次扫描整个注册文件描述符的集合,并返回就绪文件描述符的数目给用户)期间可能要睡眠和唤醒多次交替(存疑),虽然epoll也需要唤醒,但是唤醒之后只需要检测就绪链表是否为空就行了。


本篇文章由一文多发平台ArtiPub自动发布