有一段时间我在排查一台网关服务器CPU占用高得离谱top 里一个进程吃满了一个核。strace 挂上去之后发现这个进程绝大部分时间都在反复做同一件事循环调用 read 系统调用读一大把没有数据到达的 socket撞上 EAGAIN 就跳到下一个转一圈回来再读一遍。这不是业务逻辑在忙这是典型的非阻塞 IO 空转轮询。这也是我特别想写一篇 Linux IO 模型文章的原因。标题里那几个词——非阻塞 IO、轮询机制、多路转接看着基础但真要把它们之间的关系、各自的实现代价、到底怎么配合使用讲透其实很多人是含糊的。它也是 Linux 网络编程面试里的高频题更是写网关、代理、推送服务、游戏服务器这些高并发程序时绕不开的地基。这篇文章我尽量不写成教科书而是把原理背景和实操经验一起讲希望你看完能直接在代码里用起来。初篇先聚焦在非阻塞 IO 和轮询机制上多路转接做为核心解法详细拆开。1. 先把 IO 模型的坐标系画清楚1.1 阻塞、非阻塞、同步、异步怎么区分很多人一开始就把这四个词搞混。我习惯用一个更朴素的划分阻塞和非阻塞说的是“发起 IO 请求后这个调用会不会立刻返回”。如果数据没准备好阻塞 IO 会让当前线程挂起等待直到数据就绪或者出错非阻塞 IO 则会立刻返回一个状态告诉你“现在没有数据你过会儿再来”。同步和异步是另一条轴上的问题关注的是“数据从内核拷贝到用户缓冲区这件事由谁来做”。同步 IO 需要应用自己调用 read/write 去完成数据拷贝异步 IO 则是你发起一个请求后内核在数据准备并拷贝完成后才通知你你不需要亲自参与拷贝过程。如果打个比方去奶茶店点单就是一个不错的理解场景。阻塞式就是你站在柜台前干等店员把奶茶做好递给你你拿到手才走。非阻塞式就是你付完钱去旁边坐着隔一会儿跑过去问一句“好了没”没做好就继续回来刷手机。多路转接则是店里那个叫号系统你留个号哪一杯做好了广播叫到你你再过去取。这个类比不严谨但对于上手理解已经够了。我们真正日常最常打交道的是同步机制下的两条路线阻塞 IO 和非阻塞 IO 多路转接。异步 IO 在 Linux 下的原生实现如 io_uring是另一个话题而且它在一些极端高性能场景下才有明显优势初篇先不展开。1.2 阻塞 IO 的朴素代价为什么我们需要换思路阻塞 IO 写起来确实最简单。一个经典的 TCP 服务端accept 一个连接然后 recv 等着收数据数据到了就处理。单线程写这种代码逻辑清楚排查问题也方便。问题在于单线程阻塞在主循环里服务一个连接时其他连接全部排队等着。客户端数量一旦上来这种“一次只伺候一个”的模式立刻崩溃。于是大家自然想到多线程或多进程每个连接分一个线程去阻塞读。这里有个很真实的工程代价。Linux 下默认线程栈是 8MB一万个连接开一万个线程光栈空间就要 80GB 内存这还不算用户态线程的切换开销。线程切换时CPU 需要保存和恢复上下文连接越多、切换越频繁CPU 的有效利用率反而越低。C10K 问题的根源之一就在这里不是机器性能不够而是这种“每连接一线程”的模型本身就撑不住。所以业界的核心思路逐渐收敛到一句很朴素的话能不能让一个进程同时盯着很多连接哪个连接有数据我就处理哪个非阻塞 IO 提供了基础——发起读取不会被卡住而多路转接则把“同时等一堆 fd”的效率和体验拉了上来。这两个词放在一起就是本篇文章的核心逻辑链。2. 非阻塞 IO让调用立刻返回但别高兴太早2.1 怎么把一个 fd 变成非阻塞在 Linux 下把一个文件描述符设置为非阻塞核心手段是 fcntl 系统调用给 fd 加上 O_NONBLOCK 标志。常见写法如下int flags fcntl(fd, F_GETFL, 0); if (flags -1) { // 处理错误 return -1; } if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) -1) { // 处理错误 return -1; }习惯上要先 F_GETFL 拿到原来的标志位再把 O_NONBLOCK 加进去。如果直接 F_SETFL 一个写死的值很容易把 fd 原本的其他属性搞丢比如 O_APPEND 这类标志。如果你通过 open 创建文件可以在 open 的参数里直接带 O_NONBLOCK省一步。但 socket 编程里socket() 创建出来的监听 fd 不带这个标志accept() 返回的连接 fd 更是需要单独设置。这是一个经常被漏掉的细节。经验是accept 之后立刻把连接 fd 设为非阻塞别等用到再设因为很可能在“用到”之前read 阻塞一次就把整个进程卡住了。2.2 read/write 在非阻塞模式下的返回值语义非阻塞模式下read 的行为会变得很“君子”有数据就返回读到的字节数没有数据就返回 -1并设置 errno 为 EAGAIN 或者 EWOULDBLOCK。在 Linux 上这两个 errno 值是一样的所以你在代码里判断这两个随便哪个都行但业界习惯统一判断 EAGAIN。我见过不少新手把这些返回值当错误处理然后疯狂打日志。实际上 EAGAIN 不是错误它是系统在跟你打暗号数据没到但这个 fd 是好的你过会儿再来。正确的处理是当作一种正常分支。write 在非阻塞模式下也需要注意。发送缓冲区有空间write 会返回实际写入的字节数这个数值可能比你请求的字节数少发送缓冲区满了就返回 -1同样设置 EAGAIN。这意味着你的应用层发送逻辑要有能力处理“只写了一半”的情况不能就假设一次 write 就把所有数据发出去了。阻塞模式下咱们习惯了 write/read 要么全返回、要么等到数据足够但非阻塞模式下“短读”“短写”都是日常。所以应用层数据缓冲区的设计就成了刚需读到的数据先存起来待处理发不出去的数据也要先存起来等可写事件出现再继续发。2.3 纯轮询机制的代价轮询改良的路径在哪设置完非阻塞如果不配合多路转接那就落到“自己轮询”这条路上。最初级的写法是这样的伪代码while (1) { for (i 0; i n; i) { n read(fd[i], buf, sizeof(buf)); if (n 0) { // 处理数据 } else if (n 0) { // 对端关闭 } // EAGAIN? continue } }这个写法能跑但代价很实在。每次 read 陷入一次系统调用系统调用的成本不只是那几个 CPU 周期还包括用户态和内核态的切换、参数验证、fd 关联信息查找等。假设你有 1000 个空闲连接每秒去轮询 100 次那就是每秒 10 万次系统调用。每次哪怕只有 2 微秒的空耗10 万次也是 0.2 秒的 CPU 时间。换句话说一个核的 20% 就这么白白烧掉了而且这些连接一个数据都没来。更讽刺的是这个消耗会跟着连接数线性增长。连接数翻倍轮询消耗也翻倍但吞吐量一点没涨。这就不是高性能程序的解法。有人会想那我加个 sleep 不就行了比如每次轮询后 sleep 1 秒CPU 是降下来了但数据的实时性崩了。一条消息到达后最长可能要等接近 1 秒才被发现这对大多数业务都是不能接受的。就算 usleep 到 1 毫秒也意味着每次事件平均有 0.5 毫秒的额外延迟同时 CPU 空耗并没有本质改善因为你要么在做无意义的系统调用要么在睡觉。我早年在写一个小型内网监控采集器时就是低估了这个轮询消耗。当时没接 epoll自己写了个非阻塞 sleep轮询的逻辑以为把睡眠时间控制到几百微秒就够精准了。结果联调时一压数据进程的 CPU 直接飙到 70% 多而真实吞吐只有几十 QPS。那次之后我彻底接受了一个事实在 Linux 上非阻塞 IO 的正确打开方式必须配一个“谁来通知我”的机制而不是自己盲等。而这个机制就是多路转接。3. 多路转接select/poll/epoll 的高效实现3.1 “转接”到底转接的是什么多路转接这个名字有点抽象但其实很直白把“同时等一堆 fd”这个任务集中转交给内核去做。用户进程不再自己一个个轮询而是把 fd 清单递给内核说“你帮我盯着这些哪个有事了告诉我”。这跟餐厅叫号一个逻辑与其你每隔几秒跑过去问服务员“我的好了没”不如把你点的单fd挂到系统上好了系统叫你。你该休息休息该干别的干别的有事再过来处理。这个机制在 Linux 下的演进经历三代select、poll、epoll。它们解决的问题层层递进理解起来会有一个特别清晰的脉络。3.2 select结构简朴但限制一眼可见select 的原型是int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);用的时候把想监听可读的 fd 放进 readfds可写的放进 writefds异常放进 exceptfds调用完系统调用后内核会把就绪的 fd 保留在位图里其余的清除掉。返回值是就绪 fd 的数量timeout 是超时时间。几个细节很关键。第一nfds 参数要传“最大 fd 编号 1”因为内核是线性从 0 到 nfds-1 遍历这个位图检查的。你要是传小了高位 fd 的事件将永远不被检查到。很多诡异 bug 就是这么来的。第二fd_set 本质是位图在 64 位系统上FD_SETSIZE 默认是 1024也就是最多同时监听 1024 个连接。想突破就得重新编译内核或者换方案这就很不灵活。第三内核会修改 fd_set只保留就绪 fd所以你在循环调用 select 之前必须把所有要监听的 fd 重新加入 fd_set。这一步漏了程序的行为会非常离谱因为位图里越来越脏。select 的复杂度是 O(n)不管有几个 fd 就绪内核都要把所有传入的 fd 扫一遍。fd 数量少的时候完全没毛病数量一旦过千遍历开销和 fd_set 重置开销就很显眼了。3.3 poll去掉数量上限但仍是全量扫描poll 的接口是struct pollfd { int fd; short events; short revents; }; int poll(struct pollfd *fds, nfds_t nfds, int timeout);它把 fd、感兴趣的事件、返回的事件分开放在 struct pollfd 里。events 是用户填写的监听事件revents 由内核回填实际事件。相比 select 有个优势不需要每次重新往 events 里写监听事件内核只动 revents这个设计干净很多。数量上限确实没了你传多少个 pollfd 都行实际受系统 fd 数量和内存限制。但内核依然要线性遍历你传入的数组检查每个 fd 的状态。所以 poll 解决的是“数量限制”问题没有解决“全量扫描”的性能瓶颈。当连接数到几千甚至上万时每次调用 poll 要把整个 pollfd 数组从用户态拷贝到内核态内核再全部查一遍这个成本仍然很可观。3.4 epoll事件驱动从“扫描”变成“通知”epoll 是 Linux 2.6 引入的思路完全换了一档。它有三个接口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_create 返回一个 epoll 实例epoll_ctl 用来添加、修改、删除 fd 的监听关系epoll_wait 阻塞等待事件返回。epoll 的内部实现有两个关键结构一个红黑树和一个就绪链表。添加 fd 时fd 会被插入红黑树同时内核会为这个 fd 注册一个回调函数当 fd 上有数据到达、状态改变时驱动层会触发这个回调把该 fd 的就绪事件挂进就绪链表。epoll_wait 做的事情就很简单了看看就绪链表里有没有东西有就拷贝出来返回没有就根据 timeout 睡眠等待。这带来的复杂度变化是添加/删除/修改 fd 都是 O(log n)获取就绪事件是 O(就绪数量)。哪怕 fd 数量达到十万只要就绪的只有 3 个epoll_wait 返回时就只处理这 3 个不用再回头看另外 99997 个。这才叫真正的“事件驱动”。epoll 还有一个重要特性EPOLLLT水平触发和 EPOLLET边缘触发。水平触发模式下只要 fd 上还有数据没读完每次 epoll_wait 都会报告这个 fd 可读边缘触发模式下只有状态从“无数据”变到“有数据”的那一刻才通知一次之后你如果把数据留在缓冲区里没读完内核不会再提醒你。ET 模式的本意是减少重复通知让用户一次把数据尽量读完降低系统调用的次数。但它对编程要求也高你必须配合非阻塞 IO并且读到 EAGAIN 为止因为在 ET 模式下你无法知道缓冲区到底还有没有数据只能“读直到返回 EAGAIN”。如果你在 ET 模式下用了阻塞 read很可能在某个瞬间被卡住而内核又没有新事件通知程序就莫名僵住了。这是我看到的 ET 模式最常见的翻车点。3.5 三兄弟怎么选机制上限扫描方式就绪处理数据拷贝适用场景selectFD_SETSIZE一般1024全量遍历 O(n)位图修改每次重新传入fd_set少量fd、跨平台简单场景poll无硬性上限受fd上限和内存限制全量遍历 O(n)revents回填每次传入pollfd数组fd数量中等平台兼容性要求高epoll无硬性上限哈希/红黑树就绪链表O(log n) O(就绪数)就绪链表直接返回只拷贝就绪事件Linux下高并发、长连接是这个时代的主流选择建议如果你在 Linux 上写新代码直接上 epoll没什么好犹豫的。select 和 poll 如今更多出现在一些兼容性要求极高的老系统里或者作为理解原理的教材存在。我自己写工具时如果不是刻意做兼容绝不回头选 select理由不只是性能上限还有 fd_set 重置那类细节太容易埋雷。4. 落地非阻塞 IO 多路转接的实例骨架4.1 一个最小的 epoll 服务器骨架概念都聊通了给一段最基础但可运行的骨架复制下来改造就能用。#include sys/epoll.h #include fcntl.h #include unistd.h #include errno.h #include stdio.h #include stdlib.h #include string.h #include netinet/in.h #include arpa/inet.h #define MAX_EVENTS 1024 // 把 fd 设为非阻塞 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int lfd socket(AF_INET, SOCK_STREAM, 0); // 设置端口复用方便调试重启 int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8080); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(lfd, (struct sockaddr *)addr, sizeof(addr)); listen(lfd, 128); int epfd epoll_create(1); set_nonblocking(lfd); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); struct epoll_event events[MAX_EVENTS]; while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, 100); for (int i 0; i n; i) { if (events[i].data.fd lfd) { // 有新连接进来 int cfd accept(lfd, NULL, NULL); if (cfd -1) continue; set_nonblocking(cfd); ev.events EPOLLIN | EPOLLRDHUP; // EPOLLRDHUP可以感知对端关闭 ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); } else { int cfd events[i].data.fd; // 对端关闭读到0或收到EPOLLRDHUP if (events[i].events (EPOLLRDHUP | EPOLLERR | EPOLLHUP)) { close(cfd); continue; } char buf[4096]; while (1) { ssize_t n read(cfd, buf, sizeof(buf)); if (n 0) { // 业务处理这里只做回显 write(cfd, buf, n); } else if (n 0) { close(cfd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 数据读完了 } // 真正的错误 close(cfd); break; } } } } } }这段代码里有两个点我要专门解释一下。一是 accept 出来的连接 fd 为什么必须 set_nonblocking。如果你不设置在 epoll_wait 通知这个 fd 可读后你调用 read 通常会立刻返回数据但并不能保证一定不会阻塞。在高负载下数据可能被其他排队中的事件延迟read 就可能卡住而 epoll 的线程只有一个时卡住就是全盘卡住。所以从第一刻起就让它非阻塞是一劳永逸的防坑办法。二是读事件的 while 循环内层。在水平触发模式下即使你只读一次epoll 也会因为缓冲区还有数据而再次通知你所以不读干净也行。问题是这会造成内核和应用层之间的反复来回三次数据就可能触发三次 read 调用。而在边缘触发模式下你必须一次循环读完否则剩下的数据再也不会被通知。我这份骨架用的是 LT 模式所以内层读到 EAGAIN 就 break但没带 ET 的强制语义这个写法两种模式都能兼容读者可以根据需求调整注册的事件和判定逻辑。4.2 写事件EPOLLOUT 的使用技巧读事件是绝大多数人的第一反应但写事件其实才是最容易出问题的地方。当你调用 send/write但系统发送缓冲区满时非阻塞模式下会返回 EAGAIN。此时你有数据还没发出去不能扔应该把它暂存在应用层的发送缓冲区里然后通过 epoll_ctl 给该 fd 注册 EPOLLOUT 事件。等内核发送缓冲区有空闲时epoll_wait 会返回这个 fd 的可写事件你再继续把剩余数据发出去。关键在于写完剩余数据后要立刻通过 epoll_ctl 把 EPOLLOUT 从该 fd 上移除或者改用 EPOLL_CTL_MOD 把事件改回 EPOLLIN。原因很简单只要内核发送缓冲区没满EPOLLOUT 就一直是就绪状态epoll_wait 会持续返回可写事件这就变成一个高频空转的 busy loop白白烧 CPU。这类问题的经典场景就是程序本来负载不高但把连接 fd 的 EPOLLOUT 注册上之后没删CPU 立刻爆表。你 strace 一看几乎全在写事件上打转。经验法则EPOLLOUT 是“一次性”事件只在你有数据想发但发不出去时才临时注册数据发完立刻摘掉。常规长连接服务绝大多数时间只需要监听读事件。4.3 超时管理和空闲连接处理的一个小套路epoll_wait 的 timeout 参数也值得单独说。设成 0 等于非阻塞轮询CPU 会空转设成 -1 则无限阻塞只要没有任何 fd 就绪进程就一直睡着。实际项目里更多是设一个几十毫秒的值一方面兜底处理一些不能依赖 epoll 通知的逻辑比如定时任务另一方面给信号处理、调试留反应窗口。我通常设 50 到 100 毫秒。空闲连接检查不归 epoll 管。epoll 只告诉你谁有数据来从不告诉你谁很长时间没动静。想要踢掉空闲连接你需要自己维护一个按最后活跃时间排序的结构最小堆、时间轮、或者简单链表都可以每次收发包时更新对应连接的时间定期扫描超时的连接并主动 close。网上有些实现是把超时检查和 epoll_wait 的 timeout 放一起做每轮 wait 后顺手检查一遍堆顶的到期时间这个方案实现起来也不复杂比较实用。5. 常见问题与排查技巧实录5.1 EAGAIN 被当错误处理进程 CPU 100%一个常见现象是进程满负荷运转但业务 QPS 却很低。用 perf 或 strace 看系统调用里 read/recv 占大头而且频繁返回 EAGAIN。原因一般不是你代码逻辑太重而是你在读事件到达后没有用非阻塞方式读到 EAGAIN而是反复读、反复 read。用了阻塞 read 也会这样某个 fd 的事件通知了一批数据喂饱了业务处理后你希望再等下一批但阻塞在 read 上可别的 fd 又触发不了事件线程卡死表现类似。排查方法strace -p PID -f -e traceread,recvfrom 观察系统调用如果发现大量 EAGAIN 且出现在非预期的地方检查是否该 break 没 break。记住非阻塞 IO 的核心语义收到就绪通知只是“有数据可读”的提示不代表“能读到充足的数据”。你需要在循环里读直到 EAGAIN 出现才确定这一轮数据读尽了。5.2 ET 模式配阻塞读程序莫名卡死这个我在前面已经重点提醒过。ET 模式下内核只在状态变化时通知一次如果你没有把缓冲区数据读完剩下的数据就“永远”不再通知你。最典型的卡死现场程序收到事件read 一次拿到了 100 字节但缓冲区里还有 900 字节随后业务逻辑处理这 100 字节此时新的 900 字节数据依然在缓冲区里却没有事件再触发而你的 read 又不会主动发起。程序看起来就像完全没收到数据一样。解决除了前面说的必须非阻塞读到 EAGAIN还有一个建议如果团队里有人对 epoll 的 ET 语义不够熟初期先用 LT 模式。LT 模式虽然多几次系统调用但不会因为“漏读”造成事件丢失线上稳定性优先级高于微小的性能收益。5.3 select 的 fd_set 忘记重置监听集合越来越脏这个问题我给别人排过很多次。select 返回后内核会修改传入的 fd_set清掉没有就绪的 fd。如果你的代码是循环 select但没有在每次循环开始时重新填充 readfds/writefds第二次循环开始后位图里的状态就跟你想监听的状态完全不对了。表现经常是明明客户端断开服务端却不知道或者新连接的 fd 无法触发事件。教训很简单要么严格在每次循环前重新用 FD_SET 构建集合要么直接用 poll/epoll 不用和位图纠缠。如果你维护的是老系统还离不开 select至少把构建 fd_set 的代码封装成一个函数每次循环前调用不要裸写。5.4 EPOLLOUT 注册后忘记摘除CPU 空转这个坑前面 4.2 讲过了我这里再说一个具体症状你用 epoll_wait 的返回值数量除以时间作指标发现即便没有业务流量每次 wait 也立刻返回且返回的事件里全是写事件。排查时先打印一下返回事件的 events 字段如果 EPOLLOUT 占了绝大多数且这些 fd 并没有真正的待发送数据查一下是不是哪里注册了 EPOLLOUT 后忘了 EPOLL_CTL_MOD 改回来。这类问题用 gdb 调试反而费劲最快的办法是用 strace 抓 epoll_ctl 的调用历史看看谁在哪个 fd 上注册了 EPOLLOUT。注意 strace 只能看到系统调用看不到应用层缓冲区但足以定位是哪个 fd 被反复触发。5.5 fd 泄漏和连接数到达上限服务跑几天后突然不能再接受新连接可能是 fd 不够了。检查方式很简单lsof -p PID | wc -l cat /proc/PID/fd | wc -l cat /proc/sys/fs/file-max # 系统总限制 ulimit -n # 进程限制如果 fd 数已经顶到 ulimit -n 的上限比如 1024 或 65535那么 accept 会返回 ENFILE 或者 EMFILE。泄漏的常见来源一是 EPOLLHUP 或 EPOLLERR 事件被忽略fd 没 close二是业务处理分支里提前 return 忘了 close三是被对端关闭的连接没有被及时检测到。解决的关键是在处理事件的代码里把“关闭连接”作为一个统一的出口来管理比如用一个 goto cleanup 或者统一函数收尾而不是每个分支各自裸写 close。我在代码里习惯用 EPOLLRDHUP 事件去提前感知对端关闭TCP 半关闭场景下也能处理这比等 read 返回 0 要省一轮事件。写在最后这篇文章写到这里核心的知识链已经完整串起来了非阻塞 IO 提供“不被卡住”的基石多路转接提供“高效等待”的机制两者配合才构成 Linux 下高并发 IO 的常用底座。初篇没有涉及更上层的线程模型、Reactor 模式、io_uring 等内容那些更适合放到后续篇章里展开。我个人实际操作中最大的体会是多路转接解决的是“等待”的效率但真正决定服务稳定性的往往是这些细节——EAGAIN 的分支处理是否清晰、EPOLLOUT 是否及时摘除、fd 是否统一管理。这些看起来都不起眼却是我排查线上问题排查得最多的地方。如果你也在写这类服务建议一开始就把这些细节做成代码规范而不是等到出问题了再补。