Select_poll_epoll详解

Select_poll_epoll详解

  • Select_poll_epoll详解
    • 参考链接
    • epoll函数
      • close
      • epoll event
      • EL/LT
      • ET Edge Trigger 边沿触发工作模式
      • LT Level Trigger 水平触发工作模式
      • epoll 源码解析
        • epoll_wait
      • 一道腾讯后台开发面试题
      • ET/LT 比较
      • epoll 优点
      • epoll 源码解读
    • select()
      • select()简介
      • 为什么需要select()?
      • select()函数
        • 1. timeout-->timeval
          • 延伸 gettimeofday()
        • 2. readset, writeset, exceptset
        • 3. maxfdp1
        • 例子
      • select 缺点
      • select() 文件描述符上限
    • poll()
      • fdarray
      • nfds
      • timeout
      • poll() 文件描述符上限
      • poll()/select()的区别

参考链接

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

epoll函数

注意: epoll不属于任何namespace。

#include 

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. 我们可以看到这种模式在效率上是没有边缘触发高的,因为每个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://stackoverflow.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的出现,可以节省系统调用次数,降低延迟

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详解_第1张图片
image.png

select()

select()简介

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

为什么需要select()?

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

select()函数

#include 
#include 

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 
int gettimeofday(struct timeval *tv,struct timezone *tz);

2. readset, writeset, exceptset

#include 

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 
#include          /* 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也需要唤醒,但是唤醒之后只需要检测就绪链表是否为空就行了。

你可能感兴趣的:(Select_poll_epoll详解)