从 select 到 epoll:拆解 I/O 多路复用的演进与实战
目录一、引言为什么需要 I/O 多路复用二、select1.函数介绍2.原理3.样例代码4.优缺点总结三、poll1.函数介绍2.样例代码3.优缺点总结四、epoll1.函数介绍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(intnfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);1nfds监视的最大文件描述符1。2readfds、writefds、exceptfds分别表示可读、可写、异常文件描述符的集合。3timeout用来设置等待时间可避免一直阻塞。4返回值● 返回值大于0表示监视的文件描述符中已就绪的个数。● 返回值等于0表示并没有文件描述符就绪超过timeout时间。● 返回值小于0表示发生错误。5timeval结构体第一个成员变量单位是秒第二个单位是微秒。2.原理select底层是用三张位图来存放对应的对应的文件描述符集合的并且提供了一套对位图的操作方法。void FD_CLR(int fd, fd_set *set); / /将指定文件描述符移出集合对应位设为0int FD_ISSET(int fd, fd_set *set); / /检查fd是否在集合中对应位是否为1返回非0表示存在void FD_SET(int fd, fd_set *set); / /将指定文件描述符加入集合对应位设为1void FD_ZERO(fd_set *set); / /清空集合将位图中所有位设为0select底层是会把集合给修改的例如设置进可读文件描述符集合的是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集合从用户态拷贝到内核态返回时内核在将结果拷贝回用户态。④除了拷贝上的时间开销还有内核在将处理结果拷贝到用户态是会覆盖原有的数据如果下次还想监听的描述符因此被覆盖的话下次还要再重新设置。三、poll1.函数介绍int poll(struct pollfd *fds, nfds_t nfds, int timeout);1fds是poll函数监听的结构列表每一个元素中包含三个成员变量文件描述符、监听事件的集合、返回的事件集合。pollfd结构体如下// pollfd 结构struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */};2nfds表示fds的长度。3timeout表示超时时间单位是毫秒。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已就绪。四、epollepoll是 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注册新的fdEPOLL_CTL_MOD修改已经注册fd的监听事件EPOLL_CTL_DEL删除fd● 第三个参数是要监听的fd。● 第四个参数是告诉内核要监听什么事件。● 返回值成功返回0失败返回-1。至此可能比较迷的就是struct epoll_event是什么鬼下面是它的结构struct epoll_event { uint32_t events; // 监控的事件类型如 EPOLLIN、EPOLLOUT epoll_data_t data; // 用户数据常保存 fd 或回调函数指针 }; typedef union epoll_data { void *ptr; int fd; // 常用与事件关联的 fd uint32_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句柄。● 输出参数存放就绪事件的结构体数组●maxeventsevents数组大小单次返回的最多事件数● 超时时间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两种工作模式LTLevel-Triggered水平触发只要文件描述符fd满足可读/可写的条件epoll_wait会持续通知应用层直到数据被完全处理。当fd的接收缓冲区有数据时会触发EPOLLIN事件只要数据未读完每次调用epoll_wait方法都会重复报告此fd的可读事件。同理如果发送缓冲区未满可写入则持续触发EPOLLOUT。优点容错性强未处理的事件不会被遗漏LT模式下会持续通知上层应用。而且编程比较简单。缺点若应用层未及时处理完数据频繁触发事件会增加epoll_wait的调用次数。一个高负载的fd可能会导致epoll_wait长期处于忙碌状态。ETEdge-Triggered边缘触发仅在fd状态变化时触发通知比如从不可读变为可读后续如果数据未读完也不在通知直到下一次状态变化比如数据由少变多。当接收缓冲区从空变为非空时触发一次EPOLLIN之后即使仍有数据未读完也不在重复通知。因此应用层必须在一次通知中处理完所有数据否则可能永远无法收到下一个状态变化的通知进而丢失数据。优点仅在状态变化时触发一次减少了epoll_wait的调用频率。同时一次性把接收缓冲区中的数据全部读完这样发送方可以收到更大的窗口大小通知进而可以发送更多的数据提高网络的吞吐量。缺点编程复杂如果处理数据不当可能丢失数据。特性LT水平触发ET边缘触发触发条件数据未处理完则持续触发仅当状态变化时触发一次系统调用次数可能较多重复触发较少严格依赖状态变化编程复杂度低高需非阻塞 IO 循环处理容错性高未处理完会再次触发低漏处理可能导致事件丢失适用场景简单场景、对性能不敏感的延迟容忍型高并发、延迟敏感型如高频交易系统4.优缺点总结优点1epoll有着高效的事件通知机制基于回调函数epoll_wait直接返回给用户就绪事件列表无需遍历所有的文件描述符性能与活跃连接数无关对比select和poll的O(n)遍历epoll在高并发场景下性能卓越。2支持大并发连接文件描述符数量仅受系统资源限制。相比与select这是一个优点。3事件触发模式灵活根据不同的场景有LT默认和ET两种模式可选。4无重复数据拷贝。select和poll在每次调用时都需要将文件描述符集从用户空间拷贝到内核空间而epoll在添加对应文件描述符和它对应的事件后再调用epoll_wait时无需再传入监听的文件描述符集进而没有重复拷贝。缺点仅限Linux平台代码可移植性差。五、核心机制对比select vs poll vs epoll指标selectpollepoll最大并发连接数102410k受内存限制100k内存依赖时间复杂度O(n)O(n)O(1)内存拷贝开销每次调用传递全量 fd同 select注册后无需重复传递触发模式仅 LT仅 LTLT 和 ET适用平台跨平台类 UnixLinux六、高频面试题解析epoll 为什么用红黑树不用哈希表1红黑树性能稳定可靠。哈希表查找的平均时间复杂度为O(1)但是在有大量哈希冲突可能退化到O(N)。内核需要保证稳定的响应时间无法接受性能波动这对于高并发场景非常关键。红黑树作为自平衡的二叉搜素树插入、查找、删除操作均严格保证O(logN的时间复杂度性能可预测且稳定。2红黑树具有更高效的动态操作。哈希表可能需要扩容扩容就需要重新计算哈希值并迁移数据导致短暂的性能抖动。红黑树天然支持动态的增删结点无需全局调整结构可以平滑的支持扩展。3红黑树的内存利用率比较高而且不存在冲突。在稀疏连接的场景下哈希表中可能存在不少空桶导致浪费。红黑树是按需分配结点内存的不存在额外的浪费。而且红黑树是以fd来作为键值的天然就避免了冲突。ET 模式必须配非阻塞 socket 吗为什么在ET边缘触发模式下使用非阻塞socket是强烈建议且几乎必须的。1ET模式下要求上层一次读完数据为了确保一次读完数据需要通过循环读取比如循环的调用read。但是在接收缓冲区中的数据读完以后再次调用read会导致阻塞进而线程被挂起直到下一波数据的到达。线程一旦被挂起就无法去处理其他事件严重影响并发性能。2非阻塞可以维持事件循环的高效性。非阻塞socket在每次IO操作后立即返回使得应用可以快速处理完当前事件后继续通过epoll_wait监听其他就绪事件保持事件循环的高吞吐。如果是阻塞式的socket单次处理可能长时间占用线程导致其他就绪事件延迟处理降低系统整体的响应速度。如果 fd 频繁增减epoll 是否仍高效依然高效理由如下1epoll使用红黑树作为管理fd的底层结构其增删查操作均严格保证O(logN的时间复杂度n是当前监听的fd数量。即使fd频繁增删单次操作的性能仍被控制在可控范围内。对比select和poll这种基于线性扫描的模型性能随fd数量的增长线性下降。而epoll的 O(log n) 操作显著优于这种线性开销。2epoll事件通知机制的天然优势——无轮询开销。epoll通过事件驱动的方式通知就绪的fd基于回调机制而非遍历整个集合。fd的频繁增删不会直接关联事件通知的开销。epoll_wait只返回就绪的fd。七、结语多路转接在面试中还是比较常见的还请诸位慎重。路漫漫其修远兮吾将上下而求索如有错误请不吝指出感谢支持完结~