目录
一、引言:为什么需要 I/O 多路复用?
二、select
1.函数介绍
2.原理
3.样例代码
4.优缺点总结
三、poll
1.函数介绍
2.样例代码
3.优缺点总结
四、epoll
1.函数介绍
2.原理
3.LT和ET两种工作模式
4.优缺点总结
五、核心机制对比:select vs poll vs epoll
六、高频面试题解析
七、结语
一、引言:为什么需要 I/O 多路复用?
I/O 多路复用(I/O Multiplexing)是为了解决 高并发场景下传统阻塞式或非阻塞式 I/O 模型的效率缺陷 而诞生的核心技术。阻塞式IO,无论是单线程阻塞式IO还是多线程阻塞式IO,在数据未就绪时线程会被挂起,直到数据就绪后才恢复执行。而且,多线程还涉及到线程切换,以及内存开销。线程切换那是需要时间的,频繁的切换会导致效率降低。至于非阻塞式的IO,轮询高频发生,浪费CPU资源。试想这样的场景:当你有一万个网络连接需要同时处理时,为每个连接开一个线程是否可行?答案显然是否定的,抛开线程上下文切换,单从内存上看,Linux默认一个线程大小是8MB,所以内存消耗80000MB / 1024 ≈ 78G。
二、select
多路复用(也叫多路转接)三剑客之一,单进程就可以监视多个文件描述符的可读、可写和异常状态。
1.函数介绍
select函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
1)nfds:监视的最大文件描述符+1。
2)readfds、writefds、 exceptfds分别表示可读、可写、异常文件描述符的集合。
3)timeout,用来设置等待时间,可避免一直阻塞。
4)返回值
● 返回值大于0,表示监视的文件描述符中已就绪的个数。
● 返回值等于0,表示并没有文件描述符就绪,超过timeout时间。
● 返回值小于0,表示发生错误。
5)timeval结构体,第一个成员变量单位是秒,第二个单位是微秒。
2.原理
select底层是用三张位图来存放对应的对应的文件描述符集合的,并且提供了一套对位图的操作方法。
void FD_CLR(int fd, fd_set *set); / /将指定文件描述符移出集合,对应位设为0
int FD_ISSET(int fd, fd_set *set); / /检查fd是否在集合中(对应位是否为1),返回非0表示存在
void FD_SET(int fd, fd_set *set); / /将指定文件描述符加入集合,对应位设为1
void FD_ZERO(fd_set *set); / /清空集合,将位图中所有位设为0
select底层是会把集合给修改的,例如设置进可读文件描述符集合的是0011 0000,当6号文件描述符可读然后函数返回后,可读文件描述符集合被修改成了0010 0000,没有事件发生的fd = 5被清空。如果我们下次调用还要关心fd = 5的话,需要再次设置进可读文件描述符集合中,需要频繁的重复设置,所以实践中干脆用一个辅助数组来存放这些下次还要关心的fd(避免丢失),每次在调用select之前都遍历一遍辅助数组来设置这些fd,在返回时亦是如此,需要遍历整个位图才知道哪些fd就绪了,这是导致效率比较低的一个原因。
3.样例代码
#include <iostream>
#include <sys/select.h>
#include <unistd.h>int main()
{fd_set read_fds;FD_ZERO(&read_fds);FD_SET(0, &read_fds);//检测标准输入while(true){struct timeval timeout;timeout.tv_sec = 2;//设置超时时间为2秒int ret = select(1, &read_fds, nullptr, nullptr, &timeout);if(ret < 0){perror("select");continue;}if(ret == 0){std::cout << "timeout" << std::endl;continue;}if(FD_ISSET(0, &read_fds)){char buffer[1024] = { 0 };int n = read(0, buffer, sizeof(buffer) - 1);if(n > 0){std::cout << buffer << std::endl;}}//内核会修改位图,需要重新设置进去FD_ZERO(&read_fds);FD_SET(0, &read_fds);}return 0;
}
4.优缺点总结
先说优点,select的跨平台兼容性比较好,几乎主流的操作系统都是兼容的,这相对于epoll来说是个优点(epoll只能在Linux上用)。
缺点有:
①文件描述符数量的上限低,通常是1024个,这对于动辄上万个连接的高并发场景来说是远远不够的。
②其次,select需要做不少的遍历工作,用户需要遍历返回后的位图,查找哪些是已经就绪的,然而有时候,遍历一千个文件描述符才有屈指可数的几个就绪也是有的。这就导致了很多无效的遍历。在内核层面,每次调用select函数时亦是如此,需要遍历所有的监听描述符判断是否就绪。这样,随着监听描述符的增多,效率就会不断下降。
③同时,在每一次调用select函数时,需要将fd集合从用户态拷贝到内核态,返回时内核在将结果拷贝回用户态。
④除了拷贝上的时间开销,还有内核在将处理结果拷贝到用户态是,会覆盖原有的数据,如果下次还想监听的描述符因此被覆盖的话,下次还要再重新设置。
三、poll
1.函数介绍
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
1)fds是poll函数监听的结构列表,每一个元素中包含三个成员变量:文件描述符、监听事件的集合、返回的事件集合。pollfd结构体如下:
// pollfd 结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
2)nfds表示fds的长度。
3)timeout表示超时时间,单位是毫秒。
4)返回值含义和select一样。
2.样例代码
#include <iostream>
#include <poll.h>
#include <unistd.h>int main()
{struct pollfd poll_fd;poll_fd.fd = 0; //监听标准输入poll_fd.events = POLLIN;//可读事件while(true){int ret = poll(&poll_fd, 1, 3000);if(ret < 0){perror("poll");continue;}if(ret == 0){std::cout << "timeout" << std::endl;continue;}if(poll_fd.revents == POLLIN)//判断返回的事件{char buffer[1024] = { 0 };int n = read(0, buffer, sizeof(buffer) - 1);if(n > 0){std::cout << buffer << std::endl;}}}return 0;
}
3.优缺点总结
先说优点,首先值得一提的是,poll突破了文件描述符数量的限制。使用结构体数组(struct polled[ ])而非位图(fd_set),可监听任意数量的文件描述符,仅受系统资源的限制。第二点,相对于select来说,poll做到了事件和返回结果的解耦(events和revents),不会再像select那样,从内核拷贝到用户时会覆盖原有数据,进而需要再次设置。
缺点是,调用poll时还是需要将数据拷贝到内核,返回时从内核拷贝到用户。而且也需要遍历整个struct polled[ ]数组,检查哪些fd已就绪。
四、epoll
epoll是 Linux 特有的高效 I/O 多路复用机制,专为解决select和poll的性能瓶颈而设计,尤其适合处理高并发问题。按照man手册的说法,epoll是为了处理大量句柄而做了改进的poll。所谓句柄,就是epoll_create函数返回的文件描述符。
1.函数介绍
int epoll_create(int size);
这个函数的作用就是创建一个epoll的句柄。size参数已被忽略,可任意传值。这个句柄(也就是文件描述符)用完以后,需要调用close函数关闭。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
● epoll的事件注册函数。
● 第一个参数是epoll的句柄,即epoll_create函数的返回值。
● 第二个参数表示动作,用三个宏来表示。
EPOLL_CTL_ADD:注册新的fd
EPOLL_CTL_MOD:修改已经注册fd的监听事件
EPOLL_CTL_DEL:删除fd
● 第三个参数是要监听的fd。
● 第四个参数是告诉内核要监听什么事件。
至此,可能比较迷的就是struct epoll_event是什么鬼?下面是它的结构:
struct epoll_event {uint32_t events; // 监控的事件类型(如 EPOLLIN、EPOLLOUT)epoll_data_t data; // 用户数据(常保存 fd 或回调函数指针)
};typedef union epoll_data {void *ptr;int fd; // 常用:与事件关联的 fduint32_t u32;uint64_t u64;
} epoll_data_t;
下面看看epoll_ctl函数是怎么用的。
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN;
int ret = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev);//epoll_fd为句柄
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
● 等待事件触发。
● 第一个参数是epoll句柄。
● 输出参数,存放就绪事件的结构体数组
● maxevents,events数组大小(单次返回的最多事件数)
● 超时时间(ms),-1 表示阻塞直到事件触发
● 返回值:>0:触发的事件数量 0:超时 -1:错误
函数使用样例:
epoll_event events[1000];
int nfds = epoll_wait(epoll_fd, events, sizeof(events) / sizeof(events[0], -1);
2.原理
epoll原理围绕红黑树、就绪列表、回调函数机制这三者展开。当调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这两个结构体中有两个关键的成员——红黑树和就绪列表。
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件。就绪列表*/
struct list_head rdlist;
....
};
通过epoll_ctl方法添加的事件,最终就会被挂到红黑树中。而所有添加到epoll中的事件都会与设备驱动程序建立回调关系,然后在监控的事件发生时调用这个回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把发生的事件添加到就绪列表rdlist中。然后,当调用epoll_wait方法时,只需要检查eventpoll中的就绪列表rdlist是否有元素即可。如果就绪列表不为空,那么就将发生的事件拷贝给用户,同时将事件的数量返回给用户。 与select和poll相比,epoll就不用去遍历所有的事件,导致一些无效的遍历和判断。下面继续深入到一些细节问题。
红黑树中的每个结点,都是一个epitem结构体。下面看看这个结构体长啥样。
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的 eventpoll 对象
struct epoll_event event; //期待发生的事件类型
}
总结一下:
红黑树的作用就是存储所有需要监控的文件描述符(fd)及其关联的事件(
epoll_event
) 。就绪列表的作用就是临时存储已触发的fd(就绪事件)。当事件触发时,由回调函数ep_poll_callback将对应的结点添加到就绪列表中。当调用epoll_wait方法时,将就绪事件拷贝给用户然后清空就绪列表。
重谈epoll_create、epoll_ctl、epoll_wait三个函数。
1)当调用epoll_create时,创建eventpoll结构体,然后初始化红黑树的根节点和就绪列表表头。
2)当调用epoll_ctl(epfd, EPOLL_CTL_ADD, fd, event)时,内核首先会检查红黑树中是否已经存在对应的fd结点。如果不存在,那就新 建epitem结点并入树。然后设置该fd对应的回调函数ep_poll_callback,绑定到其底层设备驱动。
3)当调用epoll_wait时,就去检查就绪列表,如果不为空,则拷贝到用户态,然后清空就绪列表。
3.LT和ET两种工作模式
LT(Level-Triggered,水平触发),只要文件描述符fd满足可读/可写的条件,epoll_wait会持续通知应用层,直到数据被完全处理。
当fd的接收缓冲区有数据时,会触发EPOLLIN事件,只要数据未读完,每次调用epoll_wait方法都会重复报告此fd的可读事件。同理,如果发送缓冲区未满(可写入),则持续触发EPOLLOUT。
优点:容错性强,未处理的事件不会被遗漏,LT模式下会持续通知上层应用。而且编程比较简单。
缺点:若应用层未及时处理完数据,频繁触发事件会增加epoll_wait的调用次数。一个高负载的fd可能会导致epoll_wait长期处于忙碌状态。
ET(Edge-Triggered,边缘触发),仅在fd状态变化时触发通知(比如从不可读变为可读),后续如果数据未读完也不在通知,直到下一次状态变化(比如数据由少变多)。
当接收缓冲区从空变为非空时,触发一次EPOLLIN,之后即使仍有数据未读完也不在重复通知。因此应用层必须在一次通知中处理完所有数据,否则可能永远无法收到下一个状态变化的通知,进而丢失数据。
优点:仅在状态变化时触发一次,减少了epoll_wait的调用频率。同时一次性把接收缓冲区中的数据全部读完,这样发送方可以收到更大的窗口大小通知,进而可以发送更多的数据,提高网络的吞吐量。
缺点:编程复杂,如果处理数据不当,可能丢失数据。
特性 | LT(水平触发) | ET(边缘触发) |
---|---|---|
触发条件 | 数据未处理完则持续触发 | 仅当状态变化时触发一次 |
系统调用次数 | 可能较多(重复触发) | 较少(严格依赖状态变化) |
编程复杂度 | 低 | 高(需非阻塞 IO + 循环处理) |
容错性 | 高(未处理完会再次触发) | 低(漏处理可能导致事件丢失) |
适用场景 | 简单场景、对性能不敏感的延迟容忍型 | 高并发、延迟敏感型(如高频交易系统) |
4.优缺点总结
优点:
1)epoll有着高效的事件通知机制(基于回调函数),epoll_wait直接返回给用户就绪事件列表,无需遍历所有的文件描述符,性能与活跃连接数无关,对比select和poll的O(n)遍历,epoll在高并发场景下性能卓越。
2)支持大并发连接,文件描述符数量仅受系统资源限制。相比与select,这是一个优点。
3)事件触发模式灵活,根据不同的场景,有LT(默认)和ET两种模式可选。
4)无重复数据拷贝。select和poll在每次调用时,都需要将文件描述符集从用户空间拷贝到内核空间,而epoll在添加对应文件描述符和它对应的事件后,再调用epoll_wait时,无需再传入监听的文件描述符集,进而没有重复拷贝。
缺点:仅限Linux平台,代码可移植性差。
五、核心机制对比:select vs poll vs epoll
指标 |
|
|
|
---|---|---|---|
最大并发连接数 | 1024 | 10k+(受内存限制) | 100k+(内存依赖) |
时间复杂度 | O(n) | O(n) | O(1) |
内存拷贝开销 | 每次调用传递全量 fd | 同 select | 注册后无需重复传递 |
触发模式 | 仅 LT | 仅 LT | LT 和 ET |
适用平台 | 跨平台 | 类 Unix | Linux |
六、高频面试题解析
epoll 为什么用红黑树不用哈希表?
1)红黑树性能稳定可靠。哈希表查找的平均时间复杂度为O(1),但是在有大量哈希冲突,可能退化到O(N)。内核需要保证稳定的响应时间,无法接受性能波动,这对于高并发场景非常关键。红黑树作为自平衡的二叉搜素树,插入、查找、删除操作均严格保证O(logN)的时间复杂度,性能可预测且稳定。
2)红黑树具有更高效的动态操作。哈希表可能需要扩容,扩容就需要重新计算哈希值并迁移数据,导致短暂的性能抖动。红黑树天然支持动态的增删结点,无需全局调整结构,可以平滑的支持扩展。
3)红黑树的内存利用率比较高而且不存在冲突。在稀疏连接的场景下,哈希表中可能存在不少空桶,导致浪费。红黑树是按需分配结点内存的,不存在额外的浪费。而且红黑树是以fd来作为键值的,天然就避免了冲突。
ET 模式必须配非阻塞 socket 吗?为什么?
在ET(边缘触发)模式下使用非阻塞socket是强烈建议且几乎必须的。
1)ET模式下,要求上层一次读完数据,为了确保一次读完数据,需要通过循环读取,比如循环的调用read。但是,在接收缓冲区中的数据读完以后,再次调用read会导致阻塞,进而线程被挂起,直到下一波数据的到达。线程一旦被挂起,就无法去处理其他事件,严重影响并发性能。
2)非阻塞可以维持事件循环的高效性。非阻塞socket在每次IO操作后立即返回,使得应用可以快速处理完当前事件后,继续通过epoll_wait监听其他就绪事件,保持事件循环的高吞吐。如果是阻塞式的socket,单次处理可能长时间占用线程,导致其他就绪时间延迟处理,降低系统整体的响应速度。
如果 fd 频繁增减,epoll 是否仍高效?
依然高效,理由如下:
1)epoll使用红黑树作为管理fd的底层结构,其增删查操作均严格保证O(logN)的时间复杂度(n是当前监听的fd数量)。即使fd频繁增删,单次操作的性能仍被控制在可控范围内。对比select和poll这种基于线性扫描的模型,性能随fd数量的增长线性下降。而epoll的 O(log n) 操作显著优于这种线性开销。
2)epoll事件通知机制的天然优势——无轮询开销。epoll通过事件驱动的方式通知就绪的fd(基于回调机制),而非遍历整个集合。fd的频繁增删不会直接关联事件通知的开销。epoll_wait只返回就绪的fd。
七、结语
多路转接在面试中还是比较常见的,还请诸位慎重。
路漫漫其修远兮,吾将上下而求索!如有错误,请不吝指出,感谢支持!
完结~