最近在学习《redis设计与实现》一书以及源码,在第12章讲到了事件,这里谈到I/O多路复用技术,由于参加工作以来一直都是从事数据转发方面的工作,对网络编程有所了解但是不系统,借此机会学习总结一下。
这里将引用知乎的大神们知乎select/epoll介绍中的买火车票的故事来介绍五种模型。
故事情节:老李去买火车票,假设票源紧张,三天后买到一张别人退退票。参演人员(老李,黄牛,售票员,快递员),往返车站耗费1小时。
老李去火车站买票,排队三天买到一张退票。
耗费:在车站吃喝拉撒睡 3天,其他事一件没干。
老李去火车站买票,隔12小时去火车站问有没有退票,三天后买到一张票。
耗费:往返车站6次,路上6小时,其他时间做了好多事。
老李去火车站买票,委托黄牛,然后每隔6小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票。
耗费:往返车站2次,路上2小时,黄牛手续费100元,打电话12次。
老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。
耗费:往返车站2次,路上2小时,黄牛手续费100元,无需打电话。
老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。
耗费:往返车站2次,路上2小时,黄牛手续费100元,无需打电话。
老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门。
耗费:往返车站1次,路上1小时,免黄牛费100元,无需打电话。
上面的故事可以看出,随着I/O模型的不断演进,对于老李来讲是越来越方便,老李最终实现最大程度的解放自己。
上面的前4中I/O模型都是同步的,都需要等待到结果之后才能执行后续的操作,区别就在与等待的过程中是否有去轮训结果(也是我理解阻塞与非阻塞的一个区别)。异步直接是等待来自外部的通知,通知到来之后继续完成未完成的事情。
阻塞、非阻塞、多路IO复用,都是同步IO,异步必定是非阻塞的,所以不存在异步阻塞和异步非阻塞的说法。真正的异步IO需要CPU的深度参与。换句话说,只有用户线程在操作IO的时候根本不去考虑IO的执行全部都交给CPU去完成,而自己只等待一个完成信号的时候,才是真正的异步IO。所以,拉一个子线程去轮询、去死循环,或者使用select、poll、epool,都不是异步。摘自博客
I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流.select, poll, epoll 都是I/O多路复用的具体的实现。
select 函数 int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds:关注的所有文件描述符的最大值+1
readfds:关注的可以读的文件描述符集合,如果有一个可以读,select函数返回值大于0,超时之后没有可读返回0,不关注则传入NULL。
writefds :关注的可以写的文件描述符集合,如果有一个可以写,select函数返回值大于0,超时之后没有可写返回0,不关注则传入NULL。
exceptfds :同上监视异常文件描述符。
timeout:这个参数至关重要,它可以使select处于三种状态,第一,若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止;第二,若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值;第三,timeout的值大于0,这就是等待的超时时间,即 select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回,返回值同上述。
返回值:超时返回0;失败返回-1;成功返回大于0的整数,这个整数表示就绪描述符的数目。
select 编程的一般步骤
select相关的常见的几个宏
#include
int FD_ZERO(int fd, fd_set *fdset); //一个 fd_set类型变量的所有位都设为 0
int FD_CLR(int fd, fd_set *fdset); //清除某个位时可以使用
int FD_SET(int fd, fd_set *fd_set); //设置变量的某个位置位
int FD_ISSET(int fd, fd_set *fdset); //测试某个位是否被置位
fd_set rset;
int fd;
FD_ZERO(&rset);
FD_SET(fd, &rset);
select(fd+1, &rset, NULL, NULL,NULL);
if(FD_ISSET(fd, &rset)
{
//do something.
}
struct pollfd{
int fd; // 文件描述符
short event;// 请求的事件
short revent;// 返回的事件
}
可以说这种方案是专门针对select/poll的缺点应运而生的,在高并发的场景下表现很好。高并发场景下,select本身不能监控数十万计的文件描述符,假设能够监控的,那么每当连接上有报文抵达都要运行一下select,如此才能不错过对这些活跃的连接上报文处理,因此select必须要不断的运行,得到结果之后还需要不断地遍历文件描述符集,准确的得到活跃的文件描述符,因此是比较低效的。poll同样如此,只是取消了最大文件描述符的限制。
epoll为何如此的高效,如下几个方面使得它表现很突出。
1.mmap : epoll是通过内核与用户空间mmap同一块内存实现的。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。内核可以直接看到epoll监听的句柄,效率高。
2.红黑树:红黑树将存储epoll所监听的套接字。上面mmap出来的内存如何保存epoll所监听的套接字,必然也得有一套数据结构,epoll在实现上采用红黑树去存储所有套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。
3.结果链表:一旦有事件发生,epoll就会将该事件添加到结果双向链表中,应用层不用再遍历没有事件发生的文件描述符,显然高效更多。
时间有限,天色已晚,epoll改日再写文章继续深入源码实现的学习和实践。暂时偷懒贴个感觉超详解的链接epoll详解。
系统调用 | select | poll | epoll |
---|---|---|---|
事件集合 | 用户通过3个参数分别传入感兴趣的可读,可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件,这使得用户每次调用select都要重置这3个参数 | 统一处理所有事件类型,因此只需要一个事件集参数,用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件 | 内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用epoll_wait时,无需反复传入用户感兴趣的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件 |
应用程序索引就绪文件描述符的时间复杂度 | O(n) | O(n) | O(1) |
最大支持文件描述符数 | 一般有最大值限制 | 65535 | 65535 |
工作模式 | LT | LT | 支持ET高效模式 |
内核实现和工作效率 | 采用轮询方式检测就绪事件,时间复杂度:O(n) | 采用轮询方式检测就绪事件,时间复杂度:O(n) | 采用回调方式检测就绪事件,时间复杂度:O(1) |
感谢大神们做出的总结。