IO复用
一、什么是IO复用?
1.1 IO
首先,什么是IO?
1.2 五种IO模型
IO模型都有哪些?
《Unix网络编程卷1:套接字联网API》(即UNP)中第六章对unix 系统将IO模型分为五类:阻塞IO,非阻塞IO,IO复用,信号驱动,异步IO。
以上两节在此文章中有详细的解释,我们首先从大的框架中了解IO模型,以及它们各自的运行流程,以及优缺点,之后再详细了解select、poll和epoll模型。
理解一下5种IO模型、阻塞IO和非阻塞IO、同步IO和异步IO
1.3 IO多路复用
1.3.1 引出IO多路复用
首先,从常用的IO操作谈起,比如read和write,这是阻塞IO,也就是说当你调用read时,如果没有数据收到,那么线程或者进程就会被挂起,直到收到数据。
考虑一个场景:
假设服务器需要处理 1000 个连接,会需要 1000 个进程和线程,而正常情况下,1000个线程大部分是被阻塞起来的。
由于CPU的核数或超线程数一般都不大,比如4,8,16,32,64,128,比如4个核要跑1000个线程,那么每个线程的时间槽非常短,而线程切换非常频繁。这样是有问题的:
1、线程是有内存开销的,1个线程可能需要512K(或2M)存放栈,那么1000个线程就要512M(或2G)内存。
2、线程的切换,或者说上下文切换是有CPU开销的,当大量时间花在上下文切换的时候,分配给真正的操作的CPU就要少很多。
于是引出了非阻塞IO,通过fcntl(POSIX)或ioctl(Unix)设为非阻塞模式,这时,当你调用read时,如果有数据收到,就返回数据,如果没有数据收到,就立刻返回一个错误,如EWOULDBLOCK。这样是不会阻塞线程了,但是你还是要不断的轮询来读取或写入。
但是即使不需要等待了,对系统的性能提升还是非常小的,它们本质上都是一个 进程/线程 处理一个 IO。我们有什么办法让一个 进程/线程去处理多个IO呢,这样效率就大大提高了。这就引出了第三个IO模型,即IO多路复用。
对以上过程的描述,这篇文章中也有,并且附带了代码,感兴趣的可以看看:
彻底理解 IO 多路复用实现机制
1.3.2 IO多路复用介绍
IO多路复用(Multiplexing I/O) 是一种用于同时监视和处理多个输入/输出IO的技术,它允许一个进程同时监听和处理多个文件描述符(socket、文件、管道等),从而实现高效的事件驱动模型。
应用程序可以将多个I/O源注册到多路复用器中,并在多路复用器上等待事件的发生,一旦有事件就绪,程序就可以针对这些就绪的事件进行相应的操作。
这样在处理1000个连接时,只需要1个线程监控就绪状态,对就绪的每个连接开一个线程处理就可以了,这样需要的线程数大大减少,减少了内存开销和上下文切换的CPU开销。
IO多路复用有三种:select、poll 和 epoll
二、select、poll
有关它们的执行过程,优缺点,这个视频有动画,讲的很直观:
b站视频讲解
三、epoll
3.1 epoll 引入
有关select和poll,它们两个都是采用了轮询的方式扫描是否有就绪事件,而且都需要将文件描述符集合(fd)从用户态到内核态,内核态到用户态整个拷贝,这个开销在 fd 很多的时候会变得很大。
上文假设了有 1000 个连接的情况,再考虑一个场景:有100万用户同时与一个进程保持着TCP连接,而每一时刻只有几十个或几百个TCP连接是活跃的(接收TCP包),也就是说在每一时刻进程只需要处理这100万连接中的一小部分连接。那么,如何才能高效的处理这种场景呢?
如果每次收集事件时,都把100万连接的套接字传给操作系统(这首先是用户态内存到内核态内存的大量复制),而由操作系统内核寻找这些连接上有没有未处理的事件,将会是巨大的资源浪费,然后select和poll就是这样做的,因此它们最多只能处理几千个并发连接。而epoll不这样做,它在Linux内核中申请了一个简易的文件系统,把原先的一个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);
1、 调用 epoll_create 建立一个 epoll 对象(在epoll文件系统中给这个句柄分配资源);
2、 调用 epoll_ctl 向 epoll 对象中添加这100万个连接的套接字;
3、 调用 epoll_wait 收集发生事件的连接。
这样只需要在进程启动时建立 1 个 epoll 对象,并在需要的时候向它添加或删除连接就可以了,因此,在实际收集事件时,epoll_wait 的效率就会非常高,因为调用 epoll_wait 时并没有向它传递这100万个连接,内核也不需要去遍历全部的连接。
与select相比,epoll分清了频繁调用和不频繁调用的操作。例如,epoll_ctl是不太频繁调用的,而epoll_wait是非常频繁调用的。这时,epoll_wait却几乎没有入参,这比select的效率高出一大截,而且,它也不会随着并发连接的增加使得入参越发多起来,导致内核执行效率下降。
3.2 epoll 介绍
本节是参考了此文章:Linux下的I/O复用与epoll详解 , 讲的很好,我就不cv了
要深刻理解epoll,首先得了解epoll的三大关键要素:mmap(内存映射)、红黑树、链表。
… …
3.3 epoll 函数
在上文 3.1 epoll 引入 中提到这三个函数,下面详细介绍。
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);
1、 调用 epoll_create 建立一个 epoll 对象
(在epoll文件系统中给这个句柄分配资源)
int epoll_create(int size);
/*
size 用来告诉内核这个监听的数目一共有多大,这个参数不同于 select () 中的第一个参数,给出最大监听的 fd+1 的值,参数 size 并不是限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。当创建好 epoll 句柄后,它就会占用一个 fd 值,在 linux 下如果查看 /proc/ 进程 id/fd/,是能够看到这个 fd 的,所以在使用完 epoll 后,必须调用 close () 关闭,否则可能导致 fd 被耗尽。
*/
2、 调用 epoll_ctl 向 epoll 对象中添加这100万个连接的套接字;
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
函数是对指定描述符 fd 执行 op 操作。- epfd:是 epoll_create () 的返回值。- op:表示 op 操作,用三个宏来表示:添加 EPOLL_CTL_ADD,删除 EPOLL_CTL_DEL,修改 EPOLL_CTL_MOD。分别添加、删除和修改对 fd 的监听事件。- fd:是需要监听的 fd(文件描述符)- epoll_event:是告诉内核需要监听什么事,struct epoll_event 结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
3、 调用 epoll_wait 收集发生事件的连接。
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
等待 epfd 上的 io 事件,最多返回 maxevents 个事件。参数 events 用来从内核得到事件的集合,maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create () 时的 size,参数 timeout 是超时时间(毫秒,0 会立即返回,-1 将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回 0 表示已超时。
3.4 epoll 原理
参见这篇文章,讲的很好:
微信公众号-Linux 高性能服务 epoll 的本质,真的不简单
3.5 epoll 的两种触发模式(LT、ET)
二者之间的特点:
ET模式(边缘触发)只有数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返回;
LT 模式(水平触发,默认)只要有数据都会触发,缓冲区剩余未读尽的数据会导致epoll_wait返回。
epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。
LT(水平触发)模式下,只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作;
ET(边缘触发)模式下,在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN 为止,否则下次的 epoll_wait 不会返回余下的数据,会丢掉事件。
如果ET模式不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。
还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
四、面试题
前言
IO多路复用目前在大厂的面试中,一般在两个地方可能会被问到,一个是在问到网络这一块的时候,另一个是在问到 Redis 这一块的时候,因为 Redis 底层也是使用了IO多路复用,所以整体来说 IO多路复用,也算是一道比较高频的一个面试题。
题目
1、为什么 Redis 中要使用 I/O 多路复用这种技术呢?
2、select、poll、epoll之间的区别
3、epoll 水平触发(LT)与 边缘触发(ET)的区别?
epoll 参考文章
Linux下的I/O复用与epoll详解
微信公众号-Linux 高性能服务 epoll 的本质,真的不简单
Linux IO模式及 select、poll、epoll详解(含部分实例源码)