在I/O编程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O多路复用技术通过把多个I/O阻塞复用到同一个select阻塞上,从而实现系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程模型相比,I/O多路复用的最大优势就是系统开销小,系统不需要创建新的线程,也不需要维护这些线程的运行,降低了系统的维护工作量,节省了系统资源。
目前支持I/O多路复用的系统调用有select,pselect,poll,epoll,在Linux网络编程中,很长一段时间都使用select,但是select的一些固有缺陷导致它的应用收到了很大的限制,最终Linux不得不在新的内核版本中寻找select的替代方案,最终选择了epoll。本文对select、poll和epoll三者的区别进行介绍,这是Java后端开发面试中常问的问题。我会对三者的原理以及区别进行介绍。希望对你有所帮助。
1.1 概述
该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或超时的时候才唤醒它。作为一个例子,我们可以调用select,告知内核仅在下列情况发生时才返回:
也就是说,我们调用select告知内核,我们对哪些描述符的读、写或异常条件感兴趣以及愿意等待多长时间。
1.2 参数解释
int select( int maxfdp1,
fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
const struct timeval *timeout);
该函数返回值时一个int类型的整数。如果有就绪的描述符就返回其数目,若超时就返回0,若出错就返回-1。下面解释一下select函数的参数
timeout,这个参数有三种可能
readset,writeset,exceptset是fd_set类型,即集合,它们指定我们想让内核测试读、写和异常条件的描述符。可以看到这三个参数是指针,分别指向三个fd_set。这三个参数,如果我们对哪一个条件不感兴趣,就可以将其设置为空指针。
描述符集fd_set
select的描述符集,底层是一个整数数组,其中每个整数的每一位对应一个描述符。举例来说,假设使用32位整数,那么该数组的第一个元素对应于描述符0~31,第二个元素对应于描述符32~63。也就是说,可以将其看作一个位数组,每一位代表一个描述符。我们分配一个fd_set数据类型的描述符集,并用四个宏来设置或测试该集合中的每一位。这四个宏分别是:
void FD_ZERO(fd_set *set); //清空set中的所有位(在使用文件描述符集前,应该先清空一下)
void FD_SET(int fd, fd_set *set); //在set中设置文件描述符fd(置1)
void FD_CLR(int fd, fd_set *set); //清除set中的fd位(置0)
int FD_ISSET(int fd, fd_set *set); //判断set中是否设置了文件描述符fd(检查哪些位是1)
举个例子,以下代码用于定义一个fd_set类型的变量,然后打开描述符1、4、5对应的位。
fd_set rset;
FD_ZERO(&rset);
FD_SET(1, &rset);
FD_SET(4, &rset);
FD_SET(5, &rset);
maxfdp1参数指定待测试的描述符个数,它的值是待测试的最大描述符+1。描述符0,1,2......maxfdp1-1都将被测试,而后面的不会被测试。
select函数修改由指针readset,writeset,exceptset所指向的描述符集。调用该函数时,我们指定所关心的描述符的值,该函数返回时,结果将指示哪些描述符已就绪。该函数返回后,我们使用FD_ISSET来测试fd_set数据类型中的描述符。函数返回后,描述符集中与未就绪描述符对应的位将置0。所以,每次重新调用select函数时,我们都要把描述符集中所关心的位设置为1。
详细过程为,每次重新调用select函数时,我们都要把描述符集中所关心的位设置为1。select首先将readset,writeset,exceptset这三个参数指向的fd_set拷贝到内核,然后对每个被置为1的描述符调用进行polling(轮询),并记录在临时结果中(fdset),如果有事件发生,select会将临时结果写到用户空间并返回;当轮询一遍后没有任何事件发生时,如果指定了超时时间,则select会睡眠到超时,睡眠结束后再进行一次轮询,并将临时结果写到用户空间,然后返回。
select需要使用两个系统调用(select 和 recvfrom),而阻塞IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll不一定比使用多线程+阻塞IO性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接)
在IO多路复用的实际应用中中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。但其实process是被select这个函数阻塞,而不是被套接字的IO操作给阻塞的。
select缺点:
poll的原理和select很类似。它的优化主要体现在pollfd数组上。先看一下函数原型
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
其中fds指向一个数组,其元素是是一个poolfd结构。每个poolfd结构就是一个描述符。fd指示是哪一个描述符,events指示了该描述符感兴趣的条件,而revents指示了该描述符感兴趣的条件哪些满足了(即poll用revents返回了检测结果)。
正是这个数组以及结构体,使得poll相比select有所改进,体现在一下两点
但是,poll依然没有解决另外两个问题
epoll与select/poll的实现原理有很大的差异,效率也比select/poll高出许多。epoll有三个函数,epoll_create、epoll_ctl、epoll_wait。
调用epoll_create函数时,Linux内核创建一个eventpoll结构体,这里面有两个重要内容。一个是rbr,这是一个红黑树,用于存储epoll所监视的事件;一个是rdlist,是一个双向链表,用于存储就绪的事件。epoll_ctl用于向rbr红黑树中添加/删除/修改要监听的socket及其事件。epoll_wait用于阻塞进程以检查事件,当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
下面来介绍一下epoll相比select,主要的优化(3.1~3.5)
3.1 支持一个进程打开的socket描述符不受限制
select只支持1024个,但是epoll支持的FD上限是操作系统的最大文件句柄数。
3.2 通过回调机制避免内核扫描
在实际中,一个服务器可能连了很多客户端,建立了很多socket,但是由于网络延迟或者链路空闲,任意时刻只有少部分的socket处于活跃状态。但是select/poll的内核检测就绪事件的方法是线性扫描所有的socket,这回导致效率随着连接数的增加而线性下降。epoll就不存在这个问题,因为epoll在内核中检测就绪事件的方法是采用回调机制。
这个回调机制简单理解就是,每一个添加到eventpoll中的事件都会注册一个回调函数,当相应的事件发生时,这个回调函数会被调用。此方法在内核中叫ep_poll_callback。这个回调函数会将发生的事件放入到就绪队列rdlist中。
这样的话,内核就不用每次都对全体socket进行扫描了。
3.3 进程不用再次扫描
select/poll函数返回后,进程被唤醒后,不知道哪些描述符就绪,因此必须再遍历一遍,事件复杂度是O(n)。但是epoll不用,就是因为这个rdlist,rdlist是一个双向链表,内核将所有发生的事件都放在这个链表中。当epoll返回后,进程只需要遍历这个链表就行,时间复杂度是O(K),设K为发生的事件数。
3.4 不用每次都传递要监听事件
epoll用一颗红黑树rbr来维护被监听的socket及其事件,用双向链表rdlist存放发生的事件。大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改,epoll用在必要的时候用epoll_ctl进行维护。通过“需要监听的socket”和“就绪的socket”二者的分离,使得不像select那样每次调用select之前都要将“需要监听的socket”告知内核。
有的说epoll用到了mmap,其实没有用到。不过这里还是介绍一下mmap。
mmap是一个函数调用,是实现内存映射的接口。mmap把设备的物理内存映射到虚拟内存,则用户操作虚拟内存相当于直接操作设备了,省去了用户空间到内核空间的复制过程,相对IO操作来说,增加了数据的吞吐量。
下图是由mmap和无mmap时,read系统调用的过程对比。从图中可以看出,mmap要比普通的read系统调用少了一次copy的过程。因为read调用,进程是无法直接访问kernel space的,所以在read系统调用返回前,内核需要将数据从内核复制到进程指定的buffer。但mmap之后,进程可以直接访问mmap的数据。
书籍《Netty权威指南》
select模型的原理、优点、缺点
select用法&原理详解(源码剖析)
select poll epoll三者之间的比较
B站视频【并发】IO多路复用select/poll/epoll介绍
写的非常好 如果这篇文章说不清epoll的本质,那就过来掐死我吧! (1)
如果这篇文章说不清epoll的本质,那就过来掐死我吧! (2)
如果这篇文章说不清epoll的本质,那就过来掐死我吧! (3)
Linux内存管理 (9)mmap(补充)
【深入浅出Linux】关于mmap的解析