图文详解:IO多路复用(select/poll/epoll)

6,754 阅读14分钟

介绍

对于传统BIO来讲每来一个请求,系统就会为这个请求分配一个进程(线程)。随着互联网普及率大大提升,用户群体几何倍增长(C10K问题),为了维护海量的用户就需要海量的服务器,成本巨大。为此就出现了IO多路复用技术。本文将从BIO、NIO、select、poll、epoll逐一详解每个技术的执行过程以及优缺点。为了帮助理解,在看正文前先思考以下几个问题,并带着问题来看文章:

  1. 同步、异步、阻塞和非阻塞到底在指什么?
  2. 为什么要有io多路复用?
  3. 这么多IO多路复用,他们都解决了什么问题?
  4. 为什么已经有了epoll,还有系统用select?

原理

BIO

BIO是传统的同步阻塞I/O模型;当进行输入输出操作时,如果读写操作未完成,执行IO的线程会被阻塞住,直到数据读写完成。由于每次客户端请求都会服务端都会分配一个线程,当有大量客户端请求时,服务的性能会急剧下降,最终可能导致资源不足无法继续对外提供服务。
通常BIO模型都有一个独立的Acceptor线程负责将停客户端的链接请求,一旦接收到连接请求,会为每个客户端连接请求创建一个新的线程进行处理。处理完成后,通过输出流返回给客户端,线程销毁。这种模型是典型的一请求一应答模型,适用于低并发场景‌1。
下图是BIO的流程图,前置的网络连接部分已经省去后续会在其他文章中介绍,大家先看下图。

image.png BIO流程:

  1. 服务端发起read调用,进行上下文切换;
  2. 文件标识符处于读未就绪状态,此时当前线程会一直处于挂起状态;
  3. 客户端发送来给网卡设备,网卡设备会把数据存放在网卡的环形缓冲区中;
  4. 通过DMA拷贝把网卡环形缓冲区中的数据拷贝到内核缓冲区
  5. 把文件标识符设置为读就绪状态;
  6. 通过cpu拷贝把内核缓冲区数据拷贝到用户缓冲区;
  7. 此时就可以解除线程阻塞进行业务处理。

read函数会一直阻塞住,并且在阻塞过程中无法接受其他客户端的请求,只有完全处理完,才能接受下一个客户端的链接。这里注意BIO没有使用mmap技术,所以第5步需要进行cpu拷贝的。

NIO

NIO可以支持多个客户端请求复用一个线程,它也是一种多路复用I/O,相比BIO它提供了非阻塞的通信方式,可以处理更多的客户端链接。

下图是NIO的整体流程图:

image.png

NIO流程:

  1. 服务端发起read调用,NIO的调用会直接返回,哪怕没有数据(读文件描述符没有准备好)也不会阻塞而是返回-1;
  2. 客户端传输数据到服务端网卡设备;
  3. 网卡设备把数据存放在环形缓冲区中;
  4. 通过DMA拷贝把数据拷贝到内核缓冲区中;
  5. 文件标识符设置为读就绪状态,用户态再次调用read时当前线程会被阻塞住;
  6. 等待cpu拷贝把内核缓冲区的数据拷贝到用户缓冲区;
  7. read解除阻塞拿到真正获取的字节,开始进行业务处理;

这里可以看到NIO也并不是完全非阻塞的,只有在数据到达内核缓冲区之前是非阻塞的,在后续通过内核缓冲区拷贝到用户缓冲区之后才会解除阻塞进行业务处理。

下面我贴一张经典BIO\NIO过程的对比图,根据图可以更清楚的了解到NIO哪部分阻塞哪部分非阻塞。

image.png

除此之外可以了解到NIO可以监听多个客户端链接,这是因为它内部有一个Selector类,Selector通过底层select、poll、epoll、kqueue等来实现多路复用。整体过程分为:

  1. 创建Selector对象。
  2. 为想要被监控的通道(Channel)注册感兴趣的事件(如:读事件、写事件)。
  3. 执行Selectorselect方法,这个方法会阻塞,直到注册的通道有事件发生。
  4. 一旦有事件发生,select方法会返回发生事件的通道数量。

select

经过上面BIO、NIO的介绍,可以知道select是NIO中多路复用的具体实现。select模型它能够同时监视多个文件描述符是否就绪,只有监听的文件描述符就绪了或者超时了相应进程/线程才会被唤醒。因为它可以有效地管理多个连接,而不需要为每个连接创建一个新的进程或线程,从而可以提高资源利用率和系统性能‌。
首先我们来看一下select方法:

int select(int maxfdp1,fd_set *readset,fd_set *writeset,
            fd_set *exceptset,const struct timeval *timeout);
  • 第一个参数:最大描述符数;传最大描述符编号+1,因为下标是从0开始的。
  • 第二个参数:监听可读的fd_set;
  • 第三个参数:监听可写的fd_set;
  • 第四个参数:监听错误的fd_set;
  • 第五个参数:超时时间;
  • 返回值为有几个描述符准备好了。

下面是select的执行流程图:

image.png

  1. 将当前进行所有的描述符都从用户态拷贝到内核态。注意该方法为阻塞方法,需要一直等待。
  2. 内核会检测fd_set中的fd是否有已经就绪/超时的;这里fd会绑定channel对象;
  3. 当数据通过DMA拷贝从网卡设备拷贝到数据接收队列;把对应文件描述符标识置为1;
  4. 文件描述符集合返回给用户态;唤醒等待队列中的进程/线程;
  5. 用户态循环遍历查看哪个fd已经就绪了。

根据上述介绍可以简单介绍一下select存在的问题点:

  1. bitmap有大小限制,一般32位操作系统为1024,64位操作系统为2048;
  2. fd_set不可重复使用,每次都需要新建;
  3. 用户态、内核态需要反复拷贝fd_set;
  4. select函数返回的是有几个描述符已经就绪了,需要再次遍历时间复杂度为O(n)。

poll

根据上述select模型可以了解到目前select模型有一些限制,而poll模型有效的解决了一部分select存在的问题,我们来看一下poll模型解决了哪些select模型缺点。 首先看一下poll使用的实体代码:

int poll (struct pollfd *fdarray, unsigned long nfds, int timeout);

struct pollfd {
  int     fd;       /* descriptor to check */
  short   events;   /* events of interest on fd */
  short   revents;  /* events that occurred on fd */
};
  • 第一个参数:pollfd集合;
  • 第二个参数:pollfd数量;
  • 第三个参数:超时时间。

它的工作原理和select类似,主要是在fd上做了优化,这里我们可以着重看来pollfd。 fd代表的就是fd文件描述符。
events需要关注的事件,分为读事件/写事件;如果都需要则值为 读事件标识 & 写事件标识。
revents对events的回馈。

下面是poll的执行流程图:

image.png 执行步骤:

  1. 将当前进程/线程全部描述符封装态pollfds,一次性从用户态拷贝到内核态;
  2. 在内核遍历fds,查看是否有就绪的;
  3. 当有就绪fd时把对应pollfd中的revents更改为对应标识,例如已可读了,则更改为POLLIN,并返回已就绪fd的数量给用户态;
  4. 用户态循环遍历每个fd,如果发现revents的标识为events中需要关注的标识则把revents再次置为0并开始处理业务逻辑。

根据上述介绍可以了解到poll解决了select中存在的。

  1. bitmap有大小限制,一般32位操作系统为1024,64位操作系统为2048;
  2. fd_set不可重复使用,每次都需要新建;

但还存在频繁拷贝pollfds、以及每次返回值都需要重新遍历一遍查看具体哪个fd就绪了。

  1. 用户态、内核态需要反复拷贝fd_set;
  2. poll函数返回的是有几个描述符已经就绪了,需要再次遍历时间复杂度为O(n)。

epoll

epoll模型是linux内核为处理大批量文件描述符而进行改进的一种IO多路复用模型。它是select/poll的增强版本,能够显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。
首先我们先来了解一下epoll用到的结构体:

struct epoll_event {
    // 需要关注的事件
    uint32_t     events;  
    // 数据内容
    epoll_data_t data;    
};

typedef union epoll_data {
    void        *ptr;
    // 文件描述符
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;


struct epitem {
    //红黑树节点
    struct rb_node rbn;
    //socket文件描述符信息
    struct epoll_filefd ffd;
    //所归属的 eventpoll 对象
    struct eventpoll *ep;
    //等待队列
    struct list_head pwqlist;
}

struct eventpoll { 	
    //sys_epoll_wait用到的等待队列
    wait_queue_head_t wq;
    //接收就绪的描述符都会放到这里
    struct list_head rdylist;
    //每个epoll对象中都有一颗红黑树
    struct rb_root rbr;
    ......
}

由于epoll相比select、poll的流程要复杂,所以这里我贴了一段epoll的代码,方便大家更好的理解。

int main() {

    // 先是创建一个epoll
    int epfd = epoll_create(1);
    
    // 创建客户端链接
    struct epoll_event event;
    event.events = EPOLLIN;
    event.data.fd = STDIN_FILENO;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) {
        // 错误处理
    }
    struct epoll_event events[10];
    // 唤起线程
    int nfds = epoll_wait(epfd, events, 10, -1);
 
    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == STDIN_FILENO) {
            // 读取标准输入
        }
    }
    close(epfd);
    return 0;
}

在使用epoll时,会先创建epoll,当客户端请求过来的时会创建event并放进epoll中,然后开始等待唤醒。

下图为epoll的流程图:

image.png 整体步骤:

  1. 调用epoll_create创建eventpoll;
  2. 调用epoll_ctl创建epitem,并把epitem中的各个属性都赋值到上述的eventpoll中;
  3. 调用epoll_wait,先检查rdylist中是否有就绪的fd,如果有则直接返回,如果没有则阻塞等待,并把当前进程/线程放到wq队列中等待。
  4. 当有数据到达并且拷贝到数据接收队列时,会触发epoll_event_callback函数,该函数根据sockid从红黑树中找到对应的epitem节点,把它放到就绪队列中并唤起wq队列中阻塞的进程。

epoll_event_callback有以下五种情况会被调用,在这些地方添加epoll的回调函数,即可使得epoll正常接收到 IO 事件。:

  1. 客户端connect()连入,服务器处于SYN_RCVD状态时;
  2. 三路握手完成,服务器处于ESTABLISHED状态时;
  3. 客户端close()断开连接,服务器处于FIN_WAIT_1和FIN_WAIT_2状态时;
  4. 客户端send/write()数据,服务器可读时;
  5. 服务器可以发送数据时;

经过上述描述可以发现epoll把剩下的两种缺点也解决掉了。

  • 不需要频繁赋值fd;
  • 时间复杂度为O(1),这里指的是epoll_create和epoll_ctl时;对于wait无论是放到wq队列中,还是回掉事件通过红黑树找到epitem都至少是O(logN)的事件复杂度。

同时epoll还提供了水平触发边缘触发两种发出机制:

水平触发:只要底层有事件就绪,只要不处理就会不断触发epoll回调函数;
边缘触发:与之前的event相比,如果event发生改变才会触发epoll回调函数。

总结epoll的一些优点:

  1. 红黑树就绪链表:通过使用红黑树和就绪链表,epoll能够高效地进行文件描述符的管理和事件的触发。红黑树提供了快速的查找和插入操作,保证了监视的文件描述符集合的高效访问;而就绪链表提供了快速的事件通知,避免了遍历整个文件描述符集合的开销。
  2. 高效处理大量并发连接‌:epoll能够有效地处理大量的文件描述符,不会随着文件描述符数量的增加而降低效率,这在高并发场景下尤为重要。
  3. 事件驱动的I/O模型‌:epoll采用事件驱动的I/O模型,可以理解为event poll,用来替代传统的selectpoll模型。这种模型允许程序仅对活跃的描述符进行操作,从而减少了CPU和系统资源的浪费。
  4. 提供水平触发边缘触发:除了提供select/poll那种IO事件的水平触发外,epoll还提供了边缘触发,这使得用户空间程序有可能缓存IO状态,减少epoll_wait的调用,提高应用程序效率。

总结

同步、异步、阻塞和非阻塞到底在指什么?

这里我举四个例子方便大家理解。目前我的服务有一些视频任务要处理,可以通过视频中心帮助我处理这些视频任务,并存储起来。视频中心提供了4种方案。
1、同步阻塞:视频中心提供一个接口,每次调用需要等待视频中心处理完成,并把处理后的视频返回给我,我再把视频存储起来。
2、同步非阻塞:视频中心提供两个接口,一个是提交任务接口,一个是查询任务结果接口,把任务提交给视频中心,我的服务就可以去做别的视频,但需要不断的轮训查询视频中心获取视频,并把视频存放起来。
3、异步非阻塞:视频中心提供一个接口,接口参数不仅有任务内容还有存放地址,调用这个接口视频中心直接返回,完全不需要我参与。视频中心会把处理完的视频放到给定的地址上。 4、异步阻塞:视频中心提供一个接口,接口参数不仅有任务内容还有存放地址,调用这个接口后视频中心会把处理完的视频放到给定的地址上;直到存储完再返回我成功。

通过这四个例子可以了解到:

阻塞/非阻塞指的是当前线程/进程是否需要等待; 同步/异步指的是结果是否需要当前线程/进程参与。


为什么要有io多路复用?

对于传BIO来讲每来一个请求,系统就会为这个请求分配一个进程(线程)。随着互联网普及率大大提升,用户群体几何倍增长(C10K问题),为了维护海量的用户就需要海量的服务器,成本巨大。为此就出现了IO多路复用技术。


这么多IO多路复用,他们都解决了什么问题?

1、select解决每个客户端请求都创建一个链接,资源浪费问题;
2、poll解决了select中fd_set无法复用,最大链接有限制的问题;
3、epoll解决了需要频繁拷贝fd,同时在用户态需要遍历全部fd查询哪个已经就绪时间复杂度为O(1)的问题。


为什么已经有了epoll,还有系统用select?

1、select函数中的超时时间是微秒,而poll、epoll是毫秒,时间更加精确;
2、select的绝大部分平台都支持,而epoll是linux2.6推出的,像mac os就不支持,而是采用的kqueue;
3、epoll的常数时间复杂度高,需要维护红黑树、双向链表等;如果是很少量线程更适合使用select,因为线程很少N几乎是一个常量级,并且来回复制的fdset也不大。

零拷贝

IO相关除了IO多路复用,还有零拷贝也是面试时常考察的重点。零拷贝文章链接:juejin.cn/post/740403…