多路复用模型是五种常见I/O模型之一,使用 select/poll 实现的多路复用 I/O 模型是使用最为广泛的事件驱动 I/O 模型,但是由于 select/poll 实现的不完善,这种 I/O 模型的缺陷也逐渐暴露出来。
typedef struct
{
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
所谓将描述符加入描述符集就是将描述符所对应的位置位。
int select(int max_fd, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout);
第一个参数 max_fd设置为三个描述符集中被置 1 的位中最大的一个的数组索引加 1。
最后一个参数表示当经过一定的时间后,如果没有描述符准备好,select 超时返回。如果需要 select 无限期等待,也传 NULL 即可。
中间三个参数就是我们关心的文件描述符集readset、writeset、exceptset,不关心的我们可以设置为NULL
select函数的返回值:
成功就绪描述符的数目
超时时返回0
出错返回-1;
进入系统调用后,描述符集被从用户空间拷贝到内核空间。内核扫描描述符集,根据结果为相应的描述符所对应的 struct file 结构创建轮询表项struct poll_table_entry 并加入轮询表 struct poll_table_struct 中,然后再把进程放入对应的等待队中。
当描述符状态发生改变时,进程被唤醒,内核再次扫描描述符集,根据轮询表状态将描述符集中活动的描述符(即描述符按照用户所关心的方式发生了改变)对应的位置 1,然后将描述符集拷贝回用户空间。
应用程序用 FD_ISSET 宏扫描返回的描述符集以决定某个描述符是否发生了改变,然后采取相应的动作常用的其他宏扫描还有如下(函数的使用都可以去看man手册):
void FD_CLR(int fd, fd_set *set);//清除某个位时可以使用
int FD_ISSET(int fd, fd_set *set);//测试某个位是否被置位
void FD_SET(int fd, fd_set *set);//设置变量的某个位置位
void FD_ZERO(fd_set *set);//一个 fd_set类型变量的所有位都设为 0
工作过程和select差不多,但相对 select 而言,poll 的情况稍好一些,首先没有文件描述符数量的限制,而且拷贝问题没那么严重。poll 只把用户关心的描述符所对应的 struct pollfd 结构拷贝到内核,而不是整个描述符集。但是在 poll 返回的时候,所有的这些 struct pollfd 结构都被拷贝回内核空间,而非仅仅活动的描述符所对应的结构,所以依然有不必要的拷贝操作。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
第一个参数:用来指向一个struct pollfd类型的数组
struct pollfd
{
int fd; /* 文件描述符 */
short events; /* 关心的事件类型 */
short revents; /* 实际返回的事件类型 */
};
第二个参数:指定数组中监听的元素个数
第三个参数:timeout指定等待的毫秒数,无论I/O是否准备好 ,poll都会返回
为负数值表示无限超时,使poll()一直挂起直到一个指定事件发生;
为0指示poll调用立即返回并列出准备好I/O的文件描述符,但并不等待其它的事件,一旦选举出来,立即返回。
EPOLLIN:表示对应的文件描述符可以读;
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读;
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将 EPOLL 设为边缘触发 ET 模式,这是相对于水平触发 LT 来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续
select/poll 存在着必须遍历整个监听的描述符集才能获取事件的问题,也使得它必定会在高并发的场景落幕
影响 select 性能的主要因素是两次用户/内核之间的拷贝和三次扫描(轮询)。
扫描的范围是索引从 0 到 n-1 的位置,这意味着当描述符的范围增加时,扫描带来的开销也随之增加。同样,在大多数情况下,大部分描述符处于非活动状态,扫描是对 CPU 时间的极大浪费。并且,当描述符状态发生改变时,内核经过扫描以后已经知道哪些描述符是活动的,但是当描述符集被拷贝回用户空间以后应用程序还要再扫描一次或多次来确定某个描述符是否在活动的描述符集中。这种重复的工作对于一个同时有几千个甚至更多打开描述符的服务器将会带来急剧的性能下降,而且单个进程能够监视的文件描述符的数量存在最大限制一般是1024个 。
相对 select 而言,poll 的情况稍好一些,没有了文件描述符个数的限制,拷贝的量也没那么大,但是在从内核拷贝到用户空间时所以依然有不必要的拷贝操作
poll不像前面的两个一样,它并不是一个单独的函数而是由一组函数组成,它有3个系统调用
int epoll_create(int size);
若成功返回文件描述符,若出错返回-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
若成功返回0,若出错返回-1。
op对应三个宏如下:
EPOLL_CTL_ADD:注册事件,将新的fd加入到epoll专用描述符epfd中;
EPOLL_CTL_MOD:修改事件,修改已注册的 fd的监听事件;
EPOLL_CTL_DEL:删除时间,将一个fd从epoll专用描述符 epfd 中删除;
struct epoll_event 结构体内容如下:
struct epoll_event
{
__uint32_t events; /* 监听的 Epoll 事件*/
epoll_data_t data; /* 用户可以传递的变量*/
};
data是一个联合结构体,定义如下:
typedef union epoll_data
{
void *ptr; /* 指向需要传递的数据*/
int fd; /* 套接字描述符*/
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
events由下面几个宏组成:
EPOLLIN:表示对应的文件描述符可以读;
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读;
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将 EPOLL 设为边缘触发 ET 模式,这是相对于水平触发 LT 来说的,这种模式下内核会不断的通知进程让它去处理监听的结果。
EPOLLONESHOT:只监听一次事件,内核默认通知你一次之后你已经知道了就不会一直通知你,当监听完这次事件之后,如果还需要继续
监听这个 socket 的话,需要重新把这个 socket 加入到 EPOLL 队列里
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
成功:返回整个epoll_wait函数的返回值是就绪态事件个数
如果在timeout超时间隔内没有任何文件描述符处于就绪态:返回0
出错:返回-1并在errno中设定错误码以表示错误原因。
select能监听的文件描述符最大数量只能1024对吧!,那么epoll则是,连接上限与系统内存有关,即内存越大能并发的连接就越多
前面的select各种缺陷,内核与用户间大量的文件描述符拷贝是吧!,那么epoll则是,通过内核与用户空间mmap同一块内存实现的,避免了不必要的内存拷贝。
select为了监听描述符集中发生的事情是轮询查找是吧!,那么epoll则是,只会对“活跃”的描述符进行操作,这是因为在内核实现中epoll是根据每个描述符上面的callback函数实现的,只有活跃的描述符才会主动去调用callback函数,调用之后内核检测到活跃的文件描述符,为活跃的文件描述符创建就绪态list链表,当调用epoll_wait()时就是通过直接查看就绪态链表是否为空来完成就绪态文件描述符信息返回的,如果在链表中查找到有事件就绪时,将就绪事件填写到传入到epoll_wait()中的数组中并返回,没有数据就阻塞timeout时间,然后epoll_wait()返回.
没错epoll就是如此优秀就是为了高并发场景而量身定制的,