首页 / 资讯中心 / 文章详情

IO多路复用详解:从select、poll到epoll的高并发基石

IO多路复用详解:从select、poll到epoll的高并发基石 ★ FEATURED ARTICLE
不知道你有没有算过这样一笔账一台普通的服务器上Nginx 可以轻松扛住几万个并发连接而如果用早期的“每连接一个线程”模型几千个连接就能把 CPU 拖到 100%。差距为什么这么大关键就在 IO 多路复用模型。这个模型的名字听起来很学术但说白了就是一句话让一个线程同时盯着成千上万个 socket哪个有数据来了就去处理哪个没数据就安静等着。它是高并发网络编程的地基也是 Nginx、Redis、Netty 这些高性能组件的共同底层逻辑。这篇文章我会从为什么需要它到 select、poll、epoll 的底层原理再给一份能直接跑的 epoll 服务器示例代码最后聊聊我在实际项目中踩过的坑。1. 从阻塞 IO 到多路复用问题到底出在哪1.1 阻塞 IO 的困境先回到最朴素的网络编程场景。如果你写一个普通的 TCP 服务端代码大概长这样int client_fd accept(listen_fd, ...); // 然后读取客户端请求 read(client_fd, buf, sizeof(buf));这个read调用是阻塞的。所谓阻塞就是如果没有客户端发数据过来这条线程就会一直卡在这个函数里CPU 被白白挂起什么都不干。在单线程模型下一次只能服务一个连接第一个客户端连上来不发数据后面的客户端全部排队等着。这种模式在局域网里调调小 Demo 没问题但放到公网环境一个客户端可能连接后五分钟才发第一条消息你服务器就瘫痪了。1.2 多线程方案为什么不够既然一个线程只能服务一个连接那很自然的想法就是一个连接来了就给它单独开一条线程去处理。多线程阻塞 IO 模型因而成了教科书上的标准解法。while (1) { int client_fd accept(listen_fd, ...); pthread_create(tid, NULL, handle_client, client_fd); }听起来很合理但真正做到生产环境就有问题了。线程不是免费的每个线程默认栈空间可能是 8MB虚拟内存创建和销毁又要消耗系统调用。我曾经在一台 2C4G 的云服务器上跑过一个多线程服务连接数到 3000 左右线程切换的开销就把 CPU 吃满了。大部分线程都在阻塞等待线程调度器却得不停为这些“僵尸等待者”做上下文切换资源全浪费在调度本身。更致命的是连接数量暴涨时线程池的积压任务会越来越多客户端超时、连接重置这些问题接踵而至。我当时的结论很明确线程不是用来和连接数对等的线程应该尽量少但每个线程都得高效。1.3 多路复用的核心思路IO 多路复用的思路完全不一样它把“等待事件”这件事从应用层收拢到内核让一个线程同时向内核注册多个 socket然后阻塞等待。只要这些 socket 中有任意一个可读、可写或发生异常内核就通知线程。打个比方多线程模型相当于每个厨师盯一口锅锅里没动静厨师也干等着多路复用相当于一个厨师同时盯着几十口锅哪口锅冒气了就炒哪口。厨师数量不用多锅再多也能应付。用术语说就是传统的阻塞 IO 是“进程等待文件描述符”多路复用是“让一个进程同时等待多个文件描述符”通过select、poll、epoll这类系统调用实现。这么做的直接好处是线程数量不再与连接数量成正比几千几万个连接只用少量线程就能扛住CPU 主要花在处理实际有数据的连接上。2. 三大系统调用的原理详解2.1 select 模型容量限制和轮询代价select是最老的多路复用系统调用上世纪 80 年代就出现在 BSD 系统里。它一次调用可以监听多个文件描述符但有几个硬伤。先看它的签名简化版int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);调用时你需要把关心的 fd 加入一个fd_set位图然后交给内核。内核会遍历这些 fd看哪些有事件再把结果返回。整个过程有两个瓶颈第一fd_set的容量固定。在 Linux 上FD_SETSIZE通常默认是 1024。你可以改宏重新编译内核或程序但本质上代码可移植性会很差。第二每次调用select都要把整个 fd 集合从用户态拷贝到内核态事件判断结束后再整体拷回来。连接数一多这个拷贝本身就是不小的开销。最关键的是内核返回时并不会告诉你具体是哪个 fd 有事件它只是把有事件的 fd 在位图里标记出来。你需要在应用层重新遍历所有 fd挨个用FD_ISSET判断。假设监听了 10000 个连接实际上只有 5 个活跃你也得遍历 10000 次。这就是我常说的“一切都要你自己扫一遍”的模型。2.2 poll 模型摆脱 FD_SETSIZE 限制poll是 select 的改良版它用链表式的pollfd数组替代了位图理论上可以监听任意数量的 fd总算不用再抠那 1024 的限制。struct pollfd { int fd; short events; short revents; };每次调用poll传入一个struct pollfd数组内核遍历数组检查每个 fd 的事件状态改写revents。相比 select 的优势在于没有数量上限接口也更清晰但两个核心问题还在第一每次调用仍然需要把整个数组从用户态拷贝到内核态而且这个数组大小随着 fd 数量线性增长第二内核返回后应用层依然需要遍历整个数组才能找出活跃连接。所以很多人说 poll 只是“没有数量限制的 select”并不夸张。它在 fd 数量少、连接固定不变的场景下能用但到了高并发大连接场景同样力不从心。2.3 epoll 模型事件驱动与大并发基础epoll 是 Linux 特有的高性能多路复用实现也是目前 Linux 服务端的主流选择。它的设计思路和 select/poll 有本质区别它不要求你每次调用都把全部 fd 拷进内核而是通过三个函数维护一份“注册表”。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);epoll 之所以能承载大规模并发核心在于三点一是事件注册和监听分离。epoll_ctl负责向内核注册 fd 和感兴趣的事件epoll_wait只负责等待事件发生。fd 集合在内核里维护不需要每次都拷贝。二是就绪列表机制。内核会维护一个“就绪队列”当某个 fd 上发生你关心的事件时内核会把对应的 epitem 挂到就绪队列里。epoll_wait只需要从就绪队列里取事件而不用扫描全部 fd。这个机制把复杂度从“遍历所有连接”降到了“只处理活跃连接”。三是支持水平触发和边缘触发两种模式。边缘触发模式下内核只在状态变化的那一刻通知一次适合配合非阻塞 IO 实现极高性能水平触发更保险有事件就反复通知。用大白话说select/poll 就像每天到整个仓库翻一遍货物看看哪些要发货epoll 相当于装了传感器货架上哪件货动了才通知你。连接越多这种优势越明显。2.4 对比总结表特性selectpollepoll底层结构fd_set 位图pollfd 数组红黑树 就绪链表数量上限FD_SETSIZE通常 1024无明确上限无明确上限受内存限制用户态到内核态拷贝每次调用全量拷贝每次调用全量拷贝注册时拷贝用 epoll_ctl 单独维护查找就绪 fd 方式全部遍历全部遍历只取就绪链表触发模式水平触发水平触发水平触发、边缘触发平台支持Linux、macOS、Windows 等Linux、macOS 等仅 Linux如果你的项目明确跑在 Linux 上不用犹豫直接上 epoll。如果要做跨平台网络库通常会封装一层比如在 Windows 上用 IOCPmacOS/BSD 上用 kqueueLinux 上用 epoll。3. 从理论到代码一个简单的 epoll 服务器示例3.1 初始化监听 socket下面是核心代码片段在 Linux 环境下用 gcc 编译跑起来后用telnet或者nc就能测试。第一步是老规矩创建一个非阻塞的监听 socket设置 SO_REUSEADDR 避免端口重用问题。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #include errno.h int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9000); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, 128) 0) { perror(listen); exit(1); } set_nonblocking(listen_fd); // 后续代码继续...这里注意我将监听 socket 也设成了非阻塞。这样在 accept 时不会因为某个连接的意外情况导致线程卡住。3.2 事件循环关键细节接下来创建 epoll 实例把监听 socket 添加进去然后进入事件循环int epfd epoll_create(1); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[128]; char buf[4096]; while (1) { int n epoll_wait(epfd, events, 128, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // 有新连接 while (1) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; else { perror(accept); break; } } set_nonblocking(conn_fd); ev.events EPOLLIN | EPOLLRDHUP; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); } } else { // 处理数据 int fd events[i].data.fd; if (events[i].events (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { close(fd); continue; } ssize_t len read(fd, buf, sizeof(buf) - 1); if (len 0) { if (len 0 || errno ! EAGAIN) { close(fd); } continue; } buf[len] \0; printf(recv %zd bytes: %s\n, len, buf); write(fd, ok\n, 3); } } } close(epfd); close(listen_fd); return 0; }有几个细节值得说道说道。accept 循环里用了一个内层 while不断 accept 直到返回EAGAIN这样可以一次性把当前半连接队列里的连接全收干净避免“惊群”和延迟。读事件的处理上我没有用边缘触发所以是水平触发模式只要有数据可读就会持续触发逻辑上更安全。3.3 水平触发和边缘触发怎么选这段代码用的是默认的水平触发也就是EPOLLIN没有加EPOLLET。水平触发下只要内核缓冲区里有数据epoll_wait就会一直返回可读事件。好处是开发简单不会漏掉数据坏处是如果一次没读完下次还会触发对写代码要求不高。边缘触发则像“上下沿触发”只有缓冲区的状态从空变为非空那一刻内核才通知一次。数据来了你不读完内核也不会再次通知你直到有新的数据进来。这要求你必须在一次事件里把数据循环读到EAGAIN否则就可能丢数据服务端还得把 fd 设成非阻塞防止最后几次 read 阻塞住线程。我的建议分情况写业务原型、连接数不极端的用水平触发省心。追求吞吐量、连接数几十万量级的用边缘触发配合非阻塞 IO读事件时用 while 循环 read 到 EAGAIN。实际操作中我踩过一次坑边缘触发模式下某个 fd 收到一个很大的包我只 read 了一次就退出来了后续内核没有新数据进来不再触发事件这个 fd 的剩余数据就永远滞留在缓冲区里客户端干等。后来加了循环读到 EAGAIN 才解决。3.4 性能调参心得再分享几个实际项目的调参经验。第一epoll_wait的maxevents参数不是越大越好。这个值决定了每次能够返回的最大事件数如果设成 128而你同时有 1000 个 fd 就绪内核会把剩下的放到下次。设太大没有意义反而要多拷内存一般设为 128 或 256 就够用根据业务实际调整。第二timeout参数建议设成 -1无限等待还是定时轮询要看场景。如果线程里除了网络事件还要处理定时任务就设个 50ms 或者 100ms相当于既做了事件等待又做了定时 tick。如果纯粹是网络代理转发-1 减少空转。第三注意处理EPOLLRDHUP。这是连接对端关闭连接的信号比EPOLLHUP更常用。很多新手只判断EPOLLIN结果连接关闭后一直收不到事件fd 泄漏。加上EPOLLRDHUP后RFC 里说的优雅关闭也能及时感知。4. 常见问题与排查经验4.1 大量空闲连接导致 CPU 飙升有人以为 epoll 是万能的连接再多只要不活跃CPU 都是 0。事实没那么简单。epoll_wait 确实只返回活跃连接但如果你的业务里有很多定时扫描、心跳检查或者每次事件处理完都做了一些全局 O(N) 操作空闲连接同样会拖垮 CPU。我有一次排查一个服务连接数只有 5000CPU 却常年 30%。用perf top一看热点全在链表遍历上。原因是我们对每个连接都会定期做超时检查用到了一个全局链表每次查超时都是 O(N)N 大了自然卡。后来改成时间轮或者小顶堆以后CPU 直接降到 3%。所以排查性能问题时别急着怪多路复用模型先看看有没有“伪活跃”逻辑。4.2 惊群问题如何解决多线程 epoll 场景下如果多个线程同时epoll_wait在同一个 epfd 上当一个 fd 有事件时可能唤醒多个线程但只有一个线程能真正处理其他线程空跑一圈这就是惊群。Linux 新内核已经在 epoll 层面做了部分优化但业务层还是建议使用 SO_REUSEPORT 结合多进程监听或者用 epoll 的EPOLLEXCLUSIVE标志减少唤醒。我在一个 UDP 网关项目里试过4 个线程共享一个 epoll fd压测时发现线程切换暴增。后来让每个线程单独创建自己的 epoll fd再配合 SO_REUSEPORT 把不同连接分发到不同线程问题就解决了。4.3 边缘触发下读不完数据怎么办前面提到过边缘触发的读写必须把事情做完否则内核不会再叫醒你。但“做完”没有明确标准你不可能知道对方到底会发多少数据。常规做法是// 边缘触发 非阻塞模式下的读循环 while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 处理数据 } else if (n 0) { close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 当前数据读完了 } else { // 真实错误关闭连接 close(fd); break; } } }同理写事件也要在一个事件里把待发送数据全部 write 完直到返回 EAGAIN然后停止关注 EPOLLOUT避免每次都触发写事件造成 busy loop。这些细节写起来不难但调试起来很隐蔽。4.4 Reactor/Proactor 模式关系IO 多路复用通常和 Reactor 模式搭配出现epoll_wait 是事件分发器拿到事件后回调对应处理函数这就是经典的 Reactor 结构。Netty 的 boss thread / worker thread 分开监听和处理就是典型的多 Reactor 多线程模型。Proactor 模式则是把“读数据”的过程也交给内核异步完成数据读好后回调用户代码Linux 上的异步 IOio_uring正在往这个方向发展。不过目前生产环境里大厂的高性能中间件多数还是基于 epoll 的 Reactor 模型因为原理简单、可控性强、出问题好定位。写在最后我最早接触 IO 多路复用的时候以为 epoll 比 select 快是因为“内核做了什么魔法”。后来真正读过内核源码、做过压测对比才意识到核心思路转变从“每次调用都要全量检查所有人”到“平时只维护关系事件发生了只通知当事者”。这个思想不仅用在网络编程里在 Java NIO、Node.js 的事件循环、甚至操作系统的中断处理里都能看到影子。哪怕你以后不写 C理解了这个模型再去看 Redis 单线程为啥这么快、Nginx 为什么能顶住百万连接都会豁然开朗。如果只是做业务开发不一定要手写 epoll但至少要能读懂这段代码知道EPOLLIN和EPOLLOUT分别代表什么知道水平触发和边缘触发的差异。真到了线上连接打满、CPU 100% 的那一天你会感谢自己曾经多看了这一眼。
阅读完成 · 觉得有帮助?
咨询建站