IO多路复用的艺术:深入剖析Select、Poll、Epoll的高效事件监听机制
引子
在高并发的网络编程领域,IO多路复用技术是提升系统性能和资源利用率的核心策略。本文将循序渐进,由浅入深的剖析Select到Epoll的设计和演进,揭秘它们是如何在纷繁复杂的网络事件中,以优雅的姿态实现高效的数据监听与处理。
为什么需要IO多路复用技术?
在没有IO多路复用技术的情况下,每个连接或文件描述符都需要绑定一个独立线程或进程以处理其中的读写操作,会迅速加剧资源紧张,尤其在高并发情境下,资源瓶颈更为严峻!相反,IO多路复用技术则允许单个线程或进程同时监管众多连接,高效处理响应的事件,极大的减少了资源消耗,从而实现了对系统资源的高效利用。IO多路复用技术确保了即使在面对数千甚至数万并发连接时,系统也能保持高效运行和快速响应,它也成为了支撑高吞吐量、低延迟服务的关键技术。此外,它简化了并发编程模型,使得开发者能够更加专注于业务逻辑的实现,而非复杂的线程管理和同步问题,进一步加速了软件的开发与维护周期。
I/O复用模型:
前置知识
文件描述符(File Descriptor,FD)
在Unix/Linux系统的设计核心中,"一切皆文件"是系统资源统一抽象的深刻实践。近乎所有的资源都是以文件的形式呈现,这意味着磁盘文件、管道、套接字甚至是进程间通信等等,都被视为文件来管理的。操作系统通过一种引用,即文件描述符(File Descriptor,FD),去管理这些文件,它作为线程或进程与操作系统内核进行I/O交互的唯一标识。这里可以类比一下Java创建对象的过程,Object obj = new Object();类比Linux的文件系统,后面new Object()实例化出来的对象可以当成是具体的文件内容,而前面的引用obj则可理解为是文件描述符。Linux通过FD操作文件,其实本质上与Java中通过reference引用操作对象的过程无异。
阻塞与非阻塞I/O
阻塞IO是指当程序执行IO操作时,如果数据没有准备好或者无法立即完成,程序会暂时挂起(即阻塞),直到IO操作完成;而非阻塞IO则是指当程序执行IO操作时,如果数据没有准备好或者无法立即完成,程序不会挂起(非阻塞),而是立即返回,通过轮询或其他方式继续执行其他任务。
事件驱动编程模型
事件驱动编程模型,立足于事件响应的核心机制,是一种编程策略。在此模式下,程序的流程不由代码的线性决定,而是依据外部激发的事件(用户交互、数据包络抵达等)来激活预先设定好的处理逻辑,实现了按需而动。通过注册事件监听器,程序表达对特定事件的关注,并在这些事件触发生瞬间通过回调函数即刻执行响应。事件循环是这一模型的心脏,它持续监听各类事件,确保程序能敏捷应对各类请求,无阻塞,从而提升运行效率与即时反馈速度。面对大量的事件响应时,此模型的挑战在于能否高效地识别并即刻响应这些状态变化,维持系统流畅与高效。
新型的网络连接模型
传统BIO网络模型中,对于每一个请求连接服务端都会创建一个单独的线程去绑定、处理其中的IO(读写)操作,设计简单,所以它成为了最初的网络IO模型,但这种方式的缺陷非常明显,那就是无法支撑高并发的流量访问。而新型的网络连接模型中,面对众多网络请求连接时,会将网络连接通过套接字和文件描述符进行关联,然后将这些被关联的文件描述符加入到一个FD数组中,然后通过单线程不断的去轮询监听网络连接的套接字,如果套接字中有数据,则从中将网络数据读取出来,然后将读取到的网络数据交给应用程序处理。
Select:多路复用的起点
select的工作流程
①数组初始化:首先,使用FD_ZERO(fd_set *set)初始化一个或多个fd_set集合,确保集合中没有文件描述符被设置,然后,通过FD_SET(int fd, fd_set *set)将需要监控的网络连接关联的文件描述符fd添加到相应的集合中(读、写、异常)。
②准备阶段:为select函数获取第一个参数nfds,值由所有待监控文件描述符中的最大编号再加1构成,它的作用就是告诉内核需要检查文件描述符的范围,不会错过任何可能发生的事件,同时也避免了不必要的性能开销。同时也会设定阻塞行为以及超时参数,为监控操作做好前期准备。
③配置Select函数:根据准备阶段中获取的参数,注册一个select函数,传入监控的描述符集合、超时时间等参数,然后调用它,程序会进入阻塞等待状态。如果内核监听到存在文件描述符的状态改变或者超时,唤醒select函数并返回。
④开启轮询:将不需要监听的FD忽略,需要监听的FD都向其等待队列注册一个entry。 ⑤开启循环:将所有需要监听的FD全部扫描一遍,判断FD对应的设备是否有数据可读写:
有:直接跳到步骤⑦。 没有:内核调用schedule让当前进程睡眠xx秒,让出cpu进入阻塞。
⑥如果有FD主动唤醒了当前进程,或xx秒后自己醒了,再次跳回步骤⑤。 ⑦如果从文件描述符集合中扫描到了有数据可读写的FD,记录相应的活跃个数。 ⑧处理就绪事件,将结果保存在fds的res_in、res_out、res_ex集合中,然后调用poll_freewait()函数移除各个驱动程序的等待队列头,最后返回对应的活跃FD数。
总结:
初始化fd_set集合,将网络连接对应的文件描述符逐一添加至其中,随后配置select函数进入阻塞状态,监控这些文件描述符的读、写事件或异常状况,以及等待指定的超时时间。一旦select监测到任何文件描述符状态变化或超时条件达成,便会向服务端发出通知。服务端接收到信号后,遍历整个fd_set集合,甄别出状态已更变的文件描述符,并将这些就绪的描述符指派给相应的处理逻辑进行高效处理。
select存在的问题
1.文件描述符使用fd_set集合来存储,fd_set是一个位数组,由32个long元素组成的fd_set,最大只能表示1024位,因此最多只能监听1024个socket,即存在较低的上限,所以对于高并发的I/O场景很难提供支持。
2.当监听到fd_set集合中某个Socket上有数据可读写后,会唤醒陷入睡眠的select,但select醒来后也不知道那个FD有数据(假如只有一个FD状态发生的变化),因此会重新将整个集合遍历一次,时间复杂度为O(n),造成了很大程度上的性能浪费。
3.监听FD的工作是内核完成的,所以每次调用select()时,都需将FD集合从用户态拷贝到内核态空间,这个过程开销会较大。
4.每次调用select函数时,由于需要监听的文件描述符不同,所以需要构建新的fd_set集合,也就是上一次使用过的fd_set不可被重用,造成较大的资源开销。
Poll:Select的进化版
Poll为什么只能说是Select的进化版呢,因为除了底层数据结构有变化之外,Select和Poll的工作流程大体一致。Poll的变化在于,为了克服Select的处理文件描述符上限,它摒弃了使用位数组来存储文件描述符的方式,转而采用数组+链表的结构,即动态数组,这使得它可以支持的文件描述符数目不再受限于预定义常量。然而,Poll在工作原理上仍然需要轮询所有文件描述符来检测状态变化,因此在高并发场景下,其性能提升并不显著。
新的数据结构:
//用于存放文件描述符
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监控事件,比如POLLIN(可读)、POLLOUT(可写)、POLLPRI(挂起)等 */
short revents; /* 实际发生的事件,返回时填充,由内核填入 */
};
//用于管理和组织pollfd结构
struct poll_list {
// 指向下一个poll_list结构体的指针,用于构成链表结构。当此节点为链表的最后一个时,next为NULL。
struct poll_list *next;
// 当前链表节点中存储的pollfd结构体的数量,即有效元素数量。
int len;
// 动态数组,大小为0,但实际在分配内存时会根据需要动态扩展,用于存储pollfd结构体
// pollfd结构通常包含文件描述符fd,监控的事件(如POLLIN、POLLOUT等)以及实际发生的事件(revents)
struct pollfd entries[0];
};
//poll函数
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
关于entries[0]的设计:
在C语言中,结构体定义中的entries[0]并不是真的表示一个大小为0的数组,而是一个技巧,被称为“结构体中的灵活数组成员”或“变长数组”。
这种技术允许在结构体的末尾部放置一个大小未知的数组,其实际大小在运行时动态分配内存时决定。这样做有以下好处:
1. 动态分配:编译器不知道数组的确切大小,因此在定义结构体时,这个数组不占用实际内存。当分配整个结构体时,可以指定一个足够大的空间以容纳期望的pollfd结构数组大小,例如malloc(sizeof(struct poll_list) + sizeof(pollfd) * desired_fds_count)。
2. 内存对齐:在结构体末尾部放置动态数组,可以确保结构体的其他成员依然遵循最佳内存对齐规则,同时数组可以无缝跟随在之后连续存储,无须担心对齐问题。
3. 灵活性:这种设计让结构体能够适应不同大小的需求,特别是当处理不确定或可变长的数据集合时,如文件描述符列表,非常有用。
因此,entries[0]实际上是个占位符,指示编译器在结构体的这个位置留出接口用于动态分配,具体的数组大小和内存管理则由程序员在运行时手动控制。
Poll机制和Select机制没有太大区别,除了针对文件描述符的存储做了优化,Poll机制也是通过轮询去扫描所有的文件描述符。新增的poll函数基于这个数组+链表的数据结构,解决了Select的问题1,但是会随着网络连接不断的增多,性能也会有所下降,另外,内核返回监听到的事件时,是通过pollfd.revents进行标记传递的,因此pollfd是可以被重用的,在下次使用时将pollfd.revents置零即可,意味着存储文件描述符的容器可复用,从而解决了Select的问题4。但是,对于剩下的两个问题仍旧没有很好的去解决,所以Poll大多数时候是会评定为和Select同一水平的,区别仅仅是处理请求连接的上限得到了些许提升,但性能提升并不显著。
Epoll:迈向高效的飞跃
忍受了Select和Poll近30年之后,2008年Linux内核2.5.44版本(有的说2.6版本)正式上线,引入了Epoll高效模型。作为多路复用革命性的更新,我们来看看Epoll是如何解决现有的问题的。
epoll没有类似select的文件描述符数量上限,理论上文件描述符的上限可以是无限,它仅由操作系统的资源限制(如内存)决定,而不是由任何固定的常量限制。所以,在系统资源允许的情况下,epoll存储文件描述符的空间是无限的!
在先前的讨论中,我们了解事件驱动编程模型,在面对大量的事件响应(文件描述符状态变化)时,能否高效的识别并即刻响应这些文件描述符的状态变化是重中之重。而红黑树在查询、增删,更新等操作方面时间复杂度为对数级别,非常高效,正好适配事件驱动编程模型的要求。因而,红黑树被选作为了epoll底层存储和管理文件描述符的高效载体,优化了高并发处理机制。
红黑树在Epoll中的作用:
高效增删:红黑树是一种遵循严格规则来保持平衡的二叉搜索树,能够确保任何节点的左右子树高度差不超过1,即使在大量的插入、删除的情景下也能保持树的高度平衡,避免二叉搜索树的退化(链表)或高度倾斜,意味着epoll能快速响应描述符状态变化,提高事件响应的速度,比如边缘触发模式下,减少误报文处理,提升实时性。
高效查询:红黑树的查询时间复杂度为O(log n),相比线性查找的O(n),能显著提高查找速度。在epoll中,红黑树用于存储文件描述符,使得快速定位和操作那些就绪的描述符。
内存管理:红黑树不依赖连续内存分配,允许文件描述符在内存中分散,减少内存碎片,更适应动态内存分配,内存管理灵活,利于大规模描述符管理。
除了红黑树Epoll的底层还有一个数据结构链表(就绪列表)。我们都知道链表的优势在于插入和删除,链表中的每个节点对应着一个文件描述符fd,当fd变为就绪或从就绪列表移除名(新增或删除)的操作是相对简单高效的。
了解Epoll的底层数据结构之后,我们来看看Epoll的工作流程。
Epoll的执行流程:
具体步骤:
1.初始化epoll实例(红黑树+链表):当一个网络请求连接进来后,会调用epoll_create(size_t size),Linux内核会创建一个eventpoll结构体,上图的淡黄色区域。
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
提供FD管理工具:
**2.epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)**来管理文件描述符fd。
**int epfd:**这是通过epoll_create()函数创建的epoll文件描述符。epfd是epoll实例的标识,通过它来操作(添加、修改、删除)epoll监控的文件描述符及其事件。这个参数告诉内核你要操作哪一个epoll实例。
**int op:**指定对epoll实例进行的操作类型,可以是以下值之一:
•EPOLL_CTL_ADD: 添加新的文件描述符到epoll实例中监控。
•EPOLL_CTL_MODIFY: 修改epoll实例中已有文件描述符的监控事件。
•EPOLL_CTL_DEL: 从epoll实例中删除一个文件描述符,不再监控。
**int fd:**要操作的文件描述符。这可以是socket、文件、管道等的描述符。这个参数告诉内核你要对哪个文件描述符进行添加、修改或删除操作。 **struct epoll_event event:**指向epoll实例传递的事件结构体指针,包含以下重要成员:
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//存储用户自定义的数据,在事件发生时会返回给用户
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
**2.1关联事件驱动,建立回调关系:**在调用epoll_ctl方法的后,会将所以有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。epoll会为关联关系建立一个epitem的结构体。
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
Epoll底层数据结构的关系:eventpoll、epitem
**3.epoll_wait():**前两步已经将文件描述符成功放入红黑树,并在就绪列表中进行关联标记,接下来就需要等待监听触发事件。
当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可。如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
4.事件触发:
了解了epoll_wait()方法之后,我们俩看看,系统是什么时机调用epoll_wait()方法的呢?
**4.1.水平触发:**只要缓冲区有数据,epoll_wait就会一直被触发,直到缓冲区为空;
例子:如果你有一个快递在快递站,快递站会一直给你打电话直到你去取出,则为水平触发;
**4.2.边缘触发:**只有所监听的事件状态改变或者有事件发生时,epoll_wait才会被触发;
例子:如果快递站只给你发一条短信叫你去取,随后就不管了,则为边缘触发。
5.系统CPU状态变化总结:
总结:
IO多路复用技术在高并发网络编程中起着至关重要的作用。通过分析Select、Poll、Epoll的演进,我们可以看到每一代技术在解决高并发处理上的不同策略和优化。
Select Select作为多路复用技术的起点,使用位数组存储文件描述符,受到1024个文件描述符的限制,且每次唤醒后需要遍历整个集合,效率较低,尤其在高并发环境中,性能表现不佳。
Poll Poll改进了Select的文件描述符存储方式,采用动态数组+链表结构,突破了1024个文件描述符的限制。然而,Poll仍然需要遍历所有文件描述符来检测状态变化,因此在性能上没有显著提升,只是稍微改善了存储和复用问题。
Epoll Epoll在内核中引入了红黑树和链表数据结构,极大地提升了高并发处理能力。Epoll不受文件描述符数量的限制,并通过红黑树实现高效的增删改查操作,利用链表进行就绪事件管理,避免了不必要的遍历操作。Epoll的边缘触发和水平触发机制使其在不同场景下更具灵活性和高效性,显著提升了系统的吞吐量和响应速度。
对比总结:
Select | Poll | Epoll | |
---|---|---|---|
性能 | 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 | 随着连接数的增加,性能急剧下降,处理成千上万的并发连接数时,性能很差 | 随着连接数的增加,性能基本没有变化 |
连接数 | 一般1024 | 理论上无限制 | 理论上无限制 |
内存拷贝 | 每调用select时,就需要拷贝 | 每调用poll时,就需要拷贝 | 文件描述符fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
数据结构 | 位数组 | 动态数组(变长数组) | 红黑树+链表(ready list) |
处理机制 | 线性轮询 | 线性轮询 | 事件驱动+触发回调 |
时间复杂度 | O(n) | O(n) | 红黑树O(logn)、链表O(1) |
参考文献
转载自:https://juejin.cn/post/7376857308494463017