多路复用的三种方式特点 编程高手不得不知的多路复用IO
我们已经知道,Unix-like系统中一共有5种I/O模型:
- 阻塞I/O(Blocking I/O);
- 非阻塞I/O(Non-blocking I/O);
- I/O多路复用(I/O Multiplexing);
- 信号驱动I/O(Signal-driven I/O);
- 异步I/O(Asynchronous I/O)。
本文只讲I/O多路复用,不会展开说其他每种I/O模型的细节。看官如果想全面了解,可以参考W. Richard Stevens等人所著的《UNIX网络编程卷1:套接字联网API(第3版)》。
我们先对I/O多路复用下一个自己的定义:
所谓I/O多路复用,就是指单个线程可以感知到多个I/O流的状态。当I/O流就绪时,触发执行相应的操作。
也就是说,“多路”的是I/O流,“复用”的是线程。由于在Linux系统中一切皆文件,因此“I/O流”这个词就能理解为文件描述符(file descriptor, fd),它可以代表真正的文件,也可以代表磁盘、设备、Socket等等。I/O多路复用的根本目的,是使得应用能够处理更多的并发,提高服务器的吞吐量。根据应用场景的不同,I/O多路复用有时也被称作事件驱动模型(Event-driven model),比如Redis里基于I/O多路复用自行实现的事件驱动库ae。
下图是《Unix网络编程》书中给出的I/O多路复用流程图,以select()和UDP的recvfrom()系统调用为例。
在该示例中,客户端程序会首先调用I/O多路复用的系统调用select(),如果所有Socket里都没有数据报,该调用就会阻塞。一旦某个Socket有数据报准备好,select()就会返回可读,然后就调用recvfrom()将数据报中的数据从内核空间复制到用户空间。
下图示出5种I/O模型的对比。I/O多路复用本质上仍然是一种同步操作,但是与阻塞I/O相比,效率更高,不必一直“干等着”。而与非阻塞I/O相比,CPU也不必持续地检查流是否就绪,节省了很多CPU时间。
I/O多路复用的实现
在Linux系统中,存在有3种典型的I/O多路复用实现,即select、poll和epoll,它们的出现是有先后的。下面分别来看个大概,之后有时间的话,再根据内核源码来分析它们。
select
select方式早在上世纪80年代就已经出现了,在上一节图中出现过的select()系统调用的签名如下所示。
int select( int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);
其中,readfds、writefds、errorfds是三个fd_set,即文件描述符的集合,分别代表读取、写入和异常的I/O流集合。nfds则表示检查[0, nfds - 1]这个范围内的fd,所以其值不应该是df的总个数,而是最大的fd值 1。timeout表示阻塞超时,为0代表立即返回,为NULL则代表一直阻塞直到有fd就绪。
select()系统调用的大致执行流程是:
- 将各个fd_set从用户空间复制到内核空间;
- 遍历[0, nfds - 1]范围内的每个fd,调用fd的poll()函数,检查其对应设备中是否有可用的流;
- 如果有流就绪,根据类型,将其加入对应的fd_set。否则就按照timeout设定阻塞当前线程,直到有流就绪或等待超时;
- select()调用返回可用的fd个数,并将各个fd_set从内核空间复制回用户空间。
select方式的实现比较简单,并且跨平台性非常好。当然其缺点也比较明显:
- fd的最大值太小,一般为1024,由FD_SETSIZE宏来指定。
- 每次调用都需要线性轮询每个描述符,高并发情况下开销比较大。
- fd_set从用户空间到内核空间来回复制,没有必要。
poll
poll()系统调用的签名如下所示。
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
实际上,poll方式与select方式的实现几乎是相同的,不过它用pollfd结构替代了上面的fd_set结构而已。另外,它改用链表实现fd的存储,因此消除了select方式中fd的最大值限制,但其他方面没有明显的改善。
epoll
epoll直到Linux 2.6版本的内核才出现,它是对select/poll真正意义上的改进。它提供了3个系统调用。
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()函数的作用是创建一个epoll句柄(可以理解为一个epoll专用的fd),size参数指定需要检查多少个fd。
epoll_ctl()函数则是向句柄epfd注册、删除或修改需要监听的fd及事件event。epoll用epoll_event结构来描述事件类型与数据,在内核缓存中用红黑树保存,epfd作为根。另外,还会给中断处理程序注册一个回调,使得内核在句柄的中断到来时,标记fd为就绪。
epoll_wait()函数等待注册在句柄epfd上的事件发生,发生之后,就将事件和已就绪的fd放入event数组中,或者阻塞直到timeout超时。epoll使用链表来维护已经就绪的事件和fd,因此链表中有无数据就可以代表有无事件发生。
epoll相对于select的优点如下:
- 没有fd数的限制;
- 不采用轮询的方式检测fd是否可用,而是在事件触发后采用类似callback的机制通知,由O(n)变为O(1);
- 利用mmap(在关于零拷贝的文章中讲过)映射内存空间,减少复制和修改的开销。
epoll的事件有两种触发方式,即水平触发(Level triggering, LT)和边缘触发(Edge triggering, ET)。这是源自电子学的术语,下面两个图分别示出高电位触发和上升沿触发。写到这里,还是要感谢一下我邮啊。
epoll中的水平触发是默认工作方式。当内核通知一个fd已经就绪时,程序就可以进行I/O操作了。但是如果本次不处理该fd,当下一次调用epoll_wait()时,内核仍然会再次通知,直到该fd被处理为止。水平触发也是select和poll采用的工作方式。
边缘触发则是epoll特有的工作方式,当fd在事件的发生的当时从未就绪变为已就绪的状态,内核会通知该fd的状态变化,并假定程序已经感知到了这种变化。如果未处理该fd,在下一次调用epoll_wait()时,内核也不会再通知了。
由此可见,水平触发与边缘触发各有各的好处。水平触发能够保证数据的完整性,但是仍然存在内核空间到用户空间的拷贝。边缘触发由于只需通知一次,大大减少了内核的资源占用,但同时也不再保证数据完整,需要程序做额外的处理。
作者:LittleMagic
链接:https://www.jianshu.com/p/9cb9344499f8
来源:简书
,
免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com