资讯详情

资讯详情

深入理解Linux IO多路转接:select、poll与epoll核心原理与实战

上一篇文章里我写到了非阻塞式 socket 在单线程里的应用评论区就有兄弟问非阻塞加轮询连接一多不照样把 CPU 烧穿吗每次 read 都返回 EAGAIN循环里全是空转这跟阻塞模型岂不是半斤八两这个问题问到点子上了它正好引出 Linux 网络编程里绕不过去的一块内容IO 多路转接也就是 select、poll、epoll 这一族 API。今天这一篇我打算把这三兄弟从头到尾讲清楚从“为什么需要它”到底层数据结构再放一个能直接编译运行的 epoll 回显服务代码最后把我这些年踩过的坑一并列出来。搞懂这一篇你对 Linux 下高并发网络服务的理解会完整一大截。这篇适合正在学 socket 编程的学生、准备 Linux 面试的求职者还有工作中要写 TCP 服务但不想直接套框架、想自己掌控事件循环的后端开发。全程不依赖第三方库纯 Linux C 接口装好 gcc 和一个终端就能跑。1. 先搞清楚一件事IO多路转接到底要解决什么问题很多人在一开始写网络服务时都是从最简单的阻塞模型开始了。单线程 accept 一个连接然后 read 数据处理完再 read。这个模型只有一条路走到黑一旦第二个客户端发起连接服务端就彻底傻眼了因为线程还没有从第一个连接的 read 调用里回来。阻塞 IO 的本质问题是把“等待数据”和“读取数据”两件事绑死在了一起而操作系统在数据到位之前会把当前线程直接休眠。你手上握着几十个客户端却只能在同一时间照顾一个这就是单线程阻塞模型的死穴。也许你马上想到那多开几个线程总行了吧。小规模确实可以几十个连接就开几十个线程编译运行都很顺利。但连接数一旦往上抬问题就变味了。每个线程默认有 8MB 左右的虚拟内存栈空间线程切换要保存恢复上下文线程之间一旦共享状态就得加锁而加了锁又会有竞争和优先级反转。更讽刺的是如果你的连接大部分时间都在“空闲等待”那么这些线程基本都在阻塞休眠真正干活的没几个。我见过不少线上服务连接数到五万以后线程模型直接崩盘不是这里超时就是那里锁等待。所以多线程不是不能写而是扛不住大规模连接条件下的扩展性。那 IO 多路转接到底做了件什么事多路指的是同时监控大量 socket转接指的是把“谁来告诉我数据到了”这件事从用户态转接给内核去完成。进程不需要反复去每个 socket 上试探而是把所有关心的 fd 一次性交给内核内核发现有数据、可写、出错等情况后再返回给用户。用日常的事来类比就是你等病房里五个病人不需要隔几分钟挨个推门问“好没好”护士台有叫号系统病人可接待了会主动广播。select、poll、epoll 就是那个护士台socket 就是病房事件就是叫号声。2. select 与 poll由内核代查的旧时代方案2.1 用 select 写出第一个多路转接 Demoselect 是最早进入系统调用层面的多路转接方案。它的核心思路是用三个 fd_set 作为参数分别表示“可读”“可写”“异常”。fd_set 本质上不是数组而是一个位图每一个 bit 代表一个文件描述符Linux 下 FD_SETSIZE 默认 1024这意味着默认最多只能监听 1024 个 fd。这是第一个需要刻在脑子里的认知。先看一段 select 的最关键片段fd_set readfds; struct timeval tv {5, 0}; while (1) { FD_ZERO(readfds); FD_SET(listenfd, readfds); // 把每个需要关注的客户端 fd 用 FD_SET 加进去 int ret select(maxfd 1, readfds, NULL, NULL, tv); if (ret 0) { perror(select); break; } if (ret 0) { printf(5秒超时没有任何fd就绪\n); continue; } if (FD_ISSET(listenfd, readfds)) { int connfd accept(listenfd, NULL, NULL); printf(new connection, fd%d\n, connfd); } // 再遍历其他已连接fd用FD_ISSET判断谁可读 }注意几个细节。select 的第一个参数 nfds 不是 fd 总个数而是“最大 fd 编号 1”因为内核是线性遍历 0 到 nfds 之间所有位图的传错了就会漏掉高编号 fd。select 会修改传入的 fd_set下次调用前必须重新初始化并重新添加所有 fdtimeval 也会被内核修改成剩余时间循环里必须重新赋值。这些都是新手最容易踩的雷。2.2 select 的三大硬伤select 的实际工程价值在小 demo 里能体现出来但一上规模就难受了。第一1024 的上限在你服务端要承接几万连接的时候这根本不够看。第二每次调用都要把整个 fd_set 从用户态拷贝到内核态然后内核再从 0 到 maxfd 全部扫描一遍这个 O(N) 的时间不会因为活跃连接少而减少。第三select 返回后你只知道“有几个就绪”却不知道“哪几个就绪”必须自己重新遍历一遍 fd_set 才能确定到底是哪个 socket 有数据。也就是说用户态还要再做一次 O(N) 的扫描。结合后面的 epoll 再看select 的问题不只是性能问题而是它把所有代价都花在了与并发规模成正比的地方完全没有可扩展性。2.3 poll 的改进与它没有根治的问题poll 是 select 的修正版它用 pollfd 数组替代位图彻底去掉了 1024 的上限而且 events 和 revents 分离你只需要设置 events内核返回时填 revents同一个 pollfd 结构可以在循环里反复复用不需要像 fd_set 那样每次全部重建。struct pollfd fds[MAX_CONN]; fds[0].fd listenfd; fds[0].events POLLIN; while (1) { int ret poll(fds, nfds, -1); if (ret 0) { perror(poll); break; } if (fds[0].revents POLLIN) { int connfd accept(listenfd, NULL, NULL); fds[nfds].fd connfd; fds[nfds - 1].events POLLIN; } for (int i 1; i nfds; i) { if (fds[i].revents POLLIN) { // 处理该连接的数据 } } }poll 看起来比 select 优雅但它没有根治性能问题。每次调用仍然要把整个 pollfd 数组从用户态拷贝到内核态内核还是线性遍历所有 fd返回后用户态也还是要把整个数组过一遍。复杂度没有量级上的变化只是常数和上限变好了。所以 poll 适合连接数量中等、活跃连接占比不低的场景一旦连接数量升到很高它和 select 一样会变得非常吃力。3. epoll性能与可扩展性的关键转折3.1 epoll 的三个系统调用怎么配合epoll 之所以能成为 Linux 下高并发的事实标准是因为它从 API 设计上就跟 select/poll 不一样。select 把“要监控的东西”放在参数里每次传epoll 则把“要监控的东西”维护在内核里用户通过三个函数来增删改查。epoll_create1(0)在内核创建一块事件表返回一个文件描述符。旧版epoll_create(1024)那个参数在 Linux 2.6.8 之后已经只是历史遗留提示传多大内核都不再拿它当容量上限。epoll_ctl(epfd, op, fd, event)操作事件表op 可取 EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL分别表示注册、修改、删除某个 fd 的监听事件。epoll_wait(epfd, events, maxevents, timeout)阻塞等待事件返回就绪事件个数events 数组里存的是就绪的 fd 和事件类型。你不需要自己遍历“全部 fd”只需要处理返回的这几个。一个基础流程是先创建监听 socket加进 epoll客户端连接到来时accept 拿到新 fd再把这个 fd 加进 epoll之后每次 epoll_wait 返回就知道哪些 socket 上有数据到了逐个处理即可。struct epoll_event ev; int epfd epoll_create1(0); ev.events EPOLLIN; ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev); while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { // 处理 events[i].data.fd 上的事件 } }3.2 就绪回调与红黑树epoll 快的根本原因epoll 快的秘密藏在两个内核数据结构里一张红黑树一条就绪链表。红黑树用于维护当前注册的所有 fd因此增删查 fd 的复杂度控制在 O(logN)这个开销只发生在调用epoll_ctl的时候。更关键的是当你通过epoll_ctl把 fd 挂进事件表时内核会给该 fd 对应的文件对象注册一个回调函数。一旦这个 fd 上有事件发生比如网卡收到数据、socket 变为可写内核就直接调用这个回调把该 fd 塞进就绪链表。这样一来epoll_wait返回前只做一件事从就绪链表里摘出已经就绪的节点复制到用户态传入的 events 数组。内核不需要从头到尾扫描全部 fd事件通知的复杂度只跟“就绪的连接数”成正比而不是跟“连接总数”成正比。理解这一点你就明白为什么 epoll 能轻松扛住几十万连接大部分空闲连接既不占用就绪链表也不需要被反复扫描。这就好比叫号系统并不是把整栋楼的病人全扫一遍才告诉你几号能就诊而是每个病人完成检查时主动挂号通知。3.3 ET 与 LT边沿触发和水平触发怎么选epoll 在支持 EPOLLIN、EPOLLOUT 这些事件类型之外还有一个影响行为的关键标志边沿触发Edge TriggeredET和水平触发Level TriggeredLT。默认是 LT只要 fd 上还有数据没读干净每次 epoll_wait 都会持续返回该事件ET 则完全不同事件只在状态变化的那一刻通知一次如果你没有把数据读完内核不会再提醒直到这个 fd 上再次发生状态变化比如又有新的数据到达。拿门铃类比会很直观LT 是只要门口有人你每次开门它都会响ET 是门铃只响一次你必须趁这一下把门口的东西全部搬进去否则就错过了。ET 的高效之处在于它显著减少了 epoll_wait 的重复返回但代价是编程难度变高。使用 ET 模式时必须把 socket 设置为非阻塞数据到达后要用循环 read 一直读到返回 EAGAIN才能保证数据清空否则残留数据会导致事件漏报连接服务进入“死等”状态。我在实际项目里对业务逻辑复杂、不可控读时长的场景更倾向于用 LT对纯转发、收发逻辑简单清晰且性能要求高的场景用 ET。新手入门建议先跑通 LT再挑战 ET不要一上来就追求极端性能。4. 实操一个完整的 epoll 回显服务与测试记录4.1 完整服务端代码与核心注释我把这些年常用来验证环境的最小可运行模型贴出来这是一个 ET 模式的回显服务客户端发什么它就回什么。代码不长但把 epoll 的三个接口、非阻塞设置、循环读数据这几件事全串起来了。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/epoll.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 static int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket); return 1; } int reuse 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); 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(8888); if (bind(listenfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(listenfd, 128) 0) { perror(listen); return 1; } int epfd epoll_create1(0); if (epfd 0) { perror(epoll_create1); return 1; } struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, ev); struct epoll_event events[MAX_EVENTS]; while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i n; i) { if (events[i].data.fd listenfd) { struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); int connfd accept(listenfd, (struct sockaddr *)client_addr, len); if (connfd 0) { perror(accept); continue; } printf(new connection from %s:%d, fd%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), connfd); set_nonblock(connfd); ev.events EPOLLIN | EPOLLET; ev.data.fd connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, ev); } else if (events[i].events EPOLLIN) { int fd events[i].data.fd; char buf[BUFFER_SIZE]; int len; while ((len read(fd, buf, sizeof(buf))) 0) { if (write(fd, buf, len) 0) { break; } } if (len 0) { printf(connection closed, fd%d\n, fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } else if (len 0) { if (errno ! EAGAIN errno ! EWOULDBLOCK) { perror(read); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } } } } } close(epfd); close(listenfd); return 0; }这份代码里有几个位置必须解释一下。第一为什么要非阻塞因为这里的连接 fd 注册的是 EPOLLET边沿触发下一次不再重复通知必须一次性把缓冲区读干净如果 socket 是阻塞的最后一次 read 会卡死整个事件循环。第二为什么 read 要用循环其实严格说当 EPOLLET 触发时只要还有数据read 返回就是正数直到缓冲区为空才返回 EAGAIN所以用 while 循环才能榨干缓冲区的全部内容。第三len 0 表示对端正常关闭要在条件判断里最先处理因为这是判断连接断开最可靠的方式。4.2 编译、运行与多客户端测试编译和运行只需要三条命令gcc -o echo_epoll echo_epoll.c ./echo_epoll程序启动后监听 8888 端口。客户端我建议直接开几个终端分别跑 nc最省事nc 127.0.0.1 8888一个终端输入 hello另一个终端输入 world你会在各自终端上看到回显内容同时服务端终端会打印出新连接的来源地址和 fd 编号。如果你像我一样喜欢较真可以用脚本模拟大量连接比如写一个循环发起 1000 个 TCP 连接并保持住再观察服务端日志和 CPU 占用率。实测下来连接数从几百涨到几万整个事件循环的稳定性和响应速度都远不是非阻塞轮询能比的。我这边跑起来后服务端输出大概是这个样子new connection from 127.0.0.1:51234, fd6 new connection from 127.0.0.1:51236, fd7 new connection from 127.0.0.1:51238, fd8同时可以另开一个终端用 lsof 看进程的 fd 数量增长情况lsof -p $(pgrep echo_epoll) | wc -l随着客户端连接增加这个数字会逐步上升但 CPU 占用率始终保持低位。这正是 IO 多路转接的核心价值它把系统的关注范围从“连接数量”缩小到了“实际活跃的连接数量”。4.3 从运行观察看 epoll 的工作过程如果你手边有 strace还可以把 epoll_wait 的系统调用过程拉出来看一眼strace -p $(pgrep echo_epoll) -e traceepoll_wait,accept,read,write观察点在于当没有任何客户端发数据时epoll_wait 会一直阻塞在调用里一旦有数据到达read 返回正数但 epoll_wait 本身并不会频繁被触发因为 ET 模式下事件通知一次就没了只要你把数据读干净了内核不会再打扰你。这个打印结果能很直观地体现 LT 与 ET 的区别也能帮你确认自己有没有漏读数据。我自己第一次用 ET 时就因为 read 只调一次没循环结果大量数据卡在缓冲区里服务端完全不再触发事件排查了半天才反应过来。5. 实战中踩过的坑与排查速查清单5.1 EAGAIN 和 EPOLLET 的配合ET 模式下最典型的翻车现场是事件只触发一次程序却只 read 了一次就退出了。数据量大一点的 TCP 报文一个包根本塞不下剩下的数据全部滞留在内核接收缓冲区里而内核又不会第二次通知你于是客户端明明发了消息服务端却像死了一样毫无反应。解决方式是循环 read 直到返回 EAGAIN 或 EWOULDBLOCK这两个 errno 值与 EAGAIN 完全等价作用就是告诉你“缓冲区已清空当前没有更多数据了”。注意不要对 EAGAIN 做 perror 打印或者当作错误处理它是 ET 模式的正常退出条件一旦打印日志刷屏性能就毁了。另外这里有一个容易混淆的点ET 模式和阻塞 fd 连用是不可靠的。因为当你循环 read 到缓冲区为空时阻塞 fd 的下一次 read 会一直挂起等待而不是立刻返回 EAGAIN于是你的事件循环彻底卡死在读调用上。这也是我代码里特意用 fcntl 把每个新连接设置为非阻塞的根本原因。5.2 连接关闭事件怎么判最可靠的关闭判断永远是用 read 的返回值。read 返回 0说明对端发送了 FIN连接进入半关闭状态这时候你该做的第一件事是清理自己这边的 fd 并调用 epoll_ctl 的 EPOLL_CTL_DEL。有人会依赖 EPOLLRDHUP 这个事件来提前感知对端关闭但它只在特定内核版本和协议栈实现下稳定而且一旦配合 TLS、代理等中间层行为会变得不易掌控。还有 EPOLLHUP 和 EPOLLERR它们在异常断开时会被返回但你的事件循环不能只盯着 EPOLLIN 看EPOLLOUT 和 EPOLLERR 位也要检查否则 RST 之类的异常会被忽略连接 fd 一直泄漏在 epoll 里。有一年我在线上排查过一个问题服务进程的 fd 数量一直涨时不时出现“Too many open files”。最后定位到原因是事件循环里只判断了 EPOLLIN没有充分处理 EPOLLERR 和 EPOLLHUP客户端异常断电后那个 socket 在 epoll 表里还挂着一个不活动的节点既不会触发 EPOLLIN也不会被主动清理。从那以后我的事件循环判断都会优先检查 events[i].events (EPOLLHUP | EPOLLERR)再做常规数据处理。5.3 惊群与 EPOLLEXCLUSIVE如果你的服务是用多线程或者多进程同时对一个 epoll fd 调 epoll_wait当新连接到来时所有等待线程都会被唤醒但最终只有一个人能 accept 成功其余线程白白空转一场这就是惊群。早期 Nginx 也踩过这个坑后来通过锁机制解决。Linux 4.5 之后引入了 EPOLLEXCLUSIVE 标志可以在注册事件时让内核只唤醒一个等待者显著缓解惊群。另外在 listener 层面使用 SO_REUSEPORT 让多个进程各自绑定同一端口也是常见的分散压力方案。但这两个都是优化手段不是新手阶段必须掌握的东西。你要记得的是单线程事件循环天然没有惊群问题能用单线程解决的场景不要为了“高并发”的幻觉硬上多线程。5.4 常见问题速查表现象原因解决办法epoll_wait 被信号打断返回 -1进程收到信号errno 为 EINTR判断 errno EINTR 后 continue不要 break 退出ET 模式只有第一次收到数据read 没有循环读到 EAGAIN数据残留在缓冲区循环 read直到返回 EAGAIN/EWOULDBLOCKET 模式进程卡死CPU 无输出socket 没有设为非阻塞缓冲空时 read 阻塞注册前用 fcntl 设置 O_NONBLOCK新连接一直不触发 acceptlistenfd 没有加入 epoll或者事件类型没设 EPOLLIN检查 epoll_ctl 的注册对象和 events 设置epoll_ctl ADD 返回 EEXIST同一个 fd 被重复加入改用 EPOLL_CTL_MOD 更新事件或先 DEL 再 ADD大量空闲连接导致 CPU 高事件循环里每个事件之间忙轮询epoll_wait 的 timeout 设 -1依靠事件驱动不要自己加 sleep 兜底对端断开后 fd 泄漏只处理 EPOLLIN 不处理 HUP/ERR在事件判断中同时检查 EPOLLHUP、EPOLLERRread 返回 0 立刻清理6. 最后分享几个我个人的编码习惯我在实际写这一类程序时有个很固执的习惯主事件循环里只做收发数据绝不直接处理业务逻辑。收到完整报文后把数据丢进队列由其他线程专门去消费或者至少把耗时操作移出 epoll_wait 的返回循环。原因很简单epoll_wait 返回后如果你在一个连接上卡了 10 秒做计算所有其他连接的读写都会被拖死多路转接的优势瞬间归零。这个思路跟 Reactor 模式是一致的先把这一层想明白后续学 libevent、libuv 甚至 Nginx 的事件模型都会顺畅得多。还有一个小技巧新同学写 epoll 服务时建议第一版先用 LT 模式把事件循环的基本逻辑跑通之后再改成 ET 并测试数据完整性。不要一上来就把两个难点绑在一起调试那样出了问题你根本分不清是模式选择错了还是代码写错了。IO 多路转接只是解决“同时等很多 socket”这个问题的工具它不负责业务架构也不负责数据协议把这些边界分清楚你的代码才不会越写越复杂。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →