I/O多路复用技术(select/poll/epoll)

I/O多路复用技术

在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. select函数的原理

1.1 概述

该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或超时的时候才唤醒它。作为一个例子,我们可以调用select,告知内核仅在下列情况发生时才返回:

  • 集合{1,4,5}中的任何描述符准备好读
  • 集合{2,7}中的任何描述符准备好写
  • 集合{1,4}中的任何描述符有异常条件待处理
  • 已经历了10.2秒

也就是说,我们调用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这个参数有三种可能

  • 永远等待,除非有至少一个事件发生。为此需要把该参数设置为空指针。
  • 等待一段事件,除非在这段时间内有至少一个事件发生。为此需要在该参数指向的timeval结构中指定时间。
  • 不等待,检查描述符后立即返回,不管检查结果如何,这称为轮询(polling)。为此需要将该参数指向的timeval结构中的时间设置为0。

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会睡眠到超时,睡眠结束后再进行一次轮询,并将临时结果写到用户空间,然后返回。

I/O多路复用技术(select/poll/epoll)_第1张图片

select需要使用两个系统调用(select 和 recvfrom),而阻塞IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll不一定比使用多线程+阻塞IO性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接)

在IO多路复用的实际应用中中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。但其实process是被select这个函数阻塞,而不是被套接字的IO操作给阻塞的。

I/O多路复用技术(select/poll/epoll)_第2张图片

select缺点:

  • select支持最大文件描述符数太少了,默认是1024。我们可以在头文件中修改FD_SETSIZE来改变这个值,但是必须重新编译内核才能使修改后的值有效。
  • 进程中的fd集合不可重用,因为在上一次调用已经被内核修改了,所以每次在调用select之前都要重新初始化fdset。
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • select返回之后,我们只知道有几个就绪fd,但是不知道是哪几个,因此需要再遍历一遍。
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

2. poll函数的原理

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返回了检测结果)。

I/O多路复用技术(select/poll/epoll)_第3张图片

正是这个数组以及结构体,使得poll相比select有所改进,体现在一下两点

  • 数组fds大小没有限制,即可以同时处理更多的客户端请求
  • events和revents,一个作为调用poll时传入的值,一个作为返回结果。内核不会修改events的值,所以不需要每次调用poll之前对events进行初始化。每一轮检测和处理之后,fds数组还是最初的样子,所以只需要在最开始时被初始化一次。

但是,poll依然没有解决另外两个问题

  • 要将数组从用户态拷贝到内核态
  • poll函数返回后,用户进程不知道哪些描述符就绪,因此必须遍历

3. epoll的原理

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的数据。

I/O多路复用技术(select/poll/epoll)_第4张图片

4. 总结

I/O多路复用技术(select/poll/epoll)_第5张图片

参考文献

书籍《Netty权威指南》

select模型的原理、优点、缺点

select用法&原理详解(源码剖析)

select poll epoll三者之间的比较

B站视频【并发】IO多路复用select/poll/epoll介绍

写的非常好 如果这篇文章说不清epoll的本质,那就过来掐死我吧! (1)

如果这篇文章说不清epoll的本质,那就过来掐死我吧! (2)

如果这篇文章说不清epoll的本质,那就过来掐死我吧! (3)

Linux内存管理 (9)mmap(补充)

【深入浅出Linux】关于mmap的解析

你可能感兴趣的:(网络IO模型,网络编程,select,epoll)