IO多路复用之Select/Poll和Epoll

1. 多路复用概述

Linux的内核将所有的外部设备都看作是一个文件来操作。对一个文件的操作会调用内核提供的系统命令,然后返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有相应描述符,称为socketfd(socket描述符),描述符是一个数字,它指向内核中的一个结构体。
select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说把数据从内核拷贝到用户空间是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间

与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

2. select原理
2.1 数据结构

该函数准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒。函数原型如下:

//返回值:就绪描述符的数目,超时返回0,出错返回-1
int select (int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

两个结构体:

  • struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄,这可以是我们所说的普通意义的文件,当然Unix下任何设备、管道、FIFO等都是文件形式,全部包括在内,所以毫无疑问一个socket就是一个文件,socket句柄就是一个文件描述符。fd_set集合可以通过一些宏由人为来操作,比如清空集合FD_ZERO(fd_set ),将一个给定的文件描述符加入集合之中FD_SET(int ,fd_set ),将一个给定的文件描述符从集合中删除FD_CLR(int ,fd_set),检查集合中指定的文件描述符是否可以读写FD_ISSET(int ,fd_set )。

源码如下:

#define __NFDBITS (8 * sizeof(unsigned long))                //每个ulong型可以表示多少个bit,
#define __FD_SETSIZE 1024                                          //socket最大取值为1024
#define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS)     //bitmap一共有1024个bit,共需要多少个ulong
 
typedef struct {
    unsigned long fds_bits [__FDSET_LONGS];                 //用ulong数组来表示bitmap
} __kernel_fd_set;
 
typedef __kernel_fd_set   fd_set;

//每个ulong为32位,可以表示32个bit。
//fd  >> 5 即 fd / 32,找到对应的ulong下标i;fd & 31 即fd % 32,找到在ulong[i]内部的位置
#define __FD_SET(fd, fdsetp)   (((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] |= (1<<((fd) & 31)))             //设置对应的bit
#define __FD_CLR(fd, fdsetp)   (((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] &= ~(1<<((fd) & 31)))            //清除对应的bit
#define __FD_ISSET(fd, fdsetp)   ((((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] & (1<<((fd) & 31))) != 0)     //判断对应的bit是否为1
#define __FD_ZERO(fdsetp)   (memset (fdsetp, 0, sizeof (*(fd_set *)(fdsetp))))                             //memset bitmap
  • struct timeval是一个常用的结构,用来代表时间值,有两个成员,一个是秒数,另一个是毫秒数。
2.2 参数剖析:
  • int maxfdp是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1,在Windows中这个参数的值无所谓
  • fd_set *readfds是指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的读变化的,即我们关心是否可以从这些文件中读取数据了,如果这个集合中有一个文件可读,select就会返回一个大于0的值,表示有文件可读,如果没有可读的文件,则根据timeout参数再判断是否超时,若超出timeout的时间,select返回0,若发生错误返回负值。可以传入NULL值,表示不关心任何文件的读变化。
  • fd_set *writefds是指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的写变化的,即我们关心是否可以向这些文件中写入数据了,如果这个集合中有一个文件可写,select就会返回一个大于0的值,表示有文件可写,如果没有可写的文件,则根据timeout参数再判断是否超时,若超出timeout的时间,select返回0,若发生错误返回负值。可以传入NULL值,表示不关心任何文件的写变化。
  • fd_set *errorfds同上面两个参数的意图,用来监视文件错误异常。
  • struct timeval* timeout是select的超时时间,这个参数至关重要,它可以使select处于三种状态,第一,若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止;第二,若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值;第三,timeout的值大于0,这就是等待的超时时间,即select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回,返回值同上述。
  • 返回值: 负值:select错误,正值:某些文件可读写或出错, 0:等待超时,没有可读写或错误的文件 。
2.3 调用过程:
  1. 建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一socket或文件发生了可读或可写事件。
  2. 从用户空间拷贝fd_set到内核空间;
  3. 注册回调函数__pollwait;
  4. 遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
  5. 这里的poll是一个文件操作,它有两个参数,一个是文件fd本身,一个是当设备尚未就绪时调用的回调函数__pollwait,这个函数把设备自己特有的等待队列传给内核,让内核把当前的进程挂载到其中;
  6. poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
  7. 如果所有设备返回的掩码都没有显示任何的事件触发,就去掉回调函数的函数指针,进入有限时的睡眠状态,再恢复和不断做poll,再作有限时的睡眠,直到其中一个设备有事件触发为止。 只要有事件触发,系统调用返回,将fd_set从内核空间拷贝到用户空间,回到用户态,用户就可以对相关的fd作进一步的读或者写操作了。
2.4 select的缺点:
  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大(PS:select方法一般是放在f循环中)
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  3. select支持的文件描述符数量太小了,默认是1024

poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多。

3. epoll原理

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll操作过程需要三个接口,相当于把select拆成了三步。

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

epoll_create:
int epoll_create ( int size ); 在epoll早期的实现中,对于监控文件描述符的组织并不是使用红黑树,而是hash表。这里的size实际上已经没有意义。方法的返回值是创建好的eventpoll句柄,后面会有用到(epdf)该方法内部做了哪些事情?

  1. 内核帮我里建了个eventpoll结构体,可看做为匿名文件,用于保存红黑树的根节点和链表;
  2. 在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket;
  3. 建立一个list链表,用于存储准备就绪的事件
  4. 返回eventpoll结构体epfd

调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个重要的成员与epoll的使用方式密切相关。每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

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

epoll_ctl:
int epoll_ctl ( int epfd, int op, int fd, struct epoll_event *event );
方法说明:

  • epfd:epoll的描述符,epoll_create返回的值
  • fd:要操作的文件描述符
  • op:指定操作类型 ,有三种类型:EPOLL_CTL_ADD:往事件表中注册fd上的事件,EPOLL_CTL_MOD:修改fd上的注册事件, EPOLL_CTL_DEL:删除fd上的注册事件。
  • event:指定事件,它是epoll_event结构指针类型
struct epoll_event {
  __uint32_t events; // epoll事件
  epoll_data_t data; // 用户数据变量
}

此方法作用是在红黑树上增加、修改或者删除一个节点(socket),当向系统中添加,就创建一个epitem结构体,这是内核管理epoll的基本数据结构即红黑树节点的节点

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

主要过程:把socket放到epoll文件系统里file对象对应的红黑树上,红黑树中是否存在,立即返回,不存在则添加到红黑树上。 所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
Epoll

epoll_wait
int epoll_wait ( int epfd, struct epoll_event* events, int maxevents, int timeout );
函数说明:

  • timeout:指定epoll的超时时间,单位是毫秒。当timeout为-1是,epoll_wait调用将永远阻塞,直到某个时间发生。当timeout为0时,epoll_wait调用将立即返回。
  • maxevents:指定最多监听多少个事件
  • events:检测到事件,将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组中。
  • 返回:成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno

观察list链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。

和select的区别也就在这里

select/poll每次调用都要传递所要监控的所有fd给select/poll系统调用,这意味着每次调用都要将fd列表从用户态拷贝到内核态,当fd数目很多时,这会造成低效。而每次调用epoll_wait时,不需要再传递fd列表给内核,因为已经在epoll_ctl中将需要监控的fd告诉了内核(epoll_ctl不需要每次都拷贝所有的fd,只需要进行增量式操作)。所以,在调用epoll_create之后,内核已经在内核态开始准备数据结构存放要监控的fd了。每次epoll_ctl只是对这个数据结构进行简单的维护。

4. 边缘触发和水平触发

Epoll支持两种工作模式

  • 水平触发(level-triggered,也被称为条件触发)LT: 只要满足条件,就触发一个事件(只要有数据没有被获取,内核就不断通知你)

  • 边缘触发(edge-triggered)ET: 每当状态变化时,触发一个事件

举个读socket的例子,假定经过长时间的沉默后,现在来了100个字节,这时无论边缘触发和条件触发都会产生一个read ready notification通知应用程序可读。应用程序读了50个字节,然后重新调用api等待io事件。这时条件触发的api会因为还有50个字节可读从 而立即返回用户一个read ready notification。而边缘触发的api会因为可读这个状态没有发生变化而陷入长期等待。 因此在使用边缘触发的api时,要注意每次都要读到socket返回EWOULDBLOCK为止,否则这个socket就算废了。而使用条件触发的api 时,如果应用程序不需要写就不要关注socket可写的事件,否则就会无限次的立即返回一个write ready notification。大家常用的select就是属于条件触发这一类,长期关注socket写事件会出现CPU 100%的毛病。

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值