Linux多路转接:从非阻塞IO到epoll高效模型
发布时间:2026/10/2 18:25:53来源:尧图网络
要聊Linux的多路转接绕不开非阻塞IO、轮询机制这两条主线。很多写网络服务的同学一开始接触socket编程都是阻塞模型一个线程accept然后阻塞在read上等数据来一个连接开一个线程直到并发上来才发现线程越开越多CPU全耗在上下文切换上。这篇想讲的就是Linux下非阻塞IO到底怎么用、轮询机制为什么低效、select/poll/epoll这一路多路转接又是怎么把“等”这件事做高效的。标题里的“初篇”意思是先不碰io_uring这类更底层的异步模型也不展开内核源码只把最常用的多路转接主线讲清楚。适合正在做服务端开发、或者准备Linux网络编程相关面试的同学读完你会对“非阻塞”三个字有一个完整的认知代码部分我也会给出可以直接抄走改的骨架。1. 从阻塞IO开始先搞清楚“等”到底浪费了什么1.1 socket默认就是阻塞的这在一开始很省心创建一个socket之后fd默认是阻塞模式。很多教材里教的第一步都是阻塞IOaccept()等连接read()等数据write()等缓冲区有空位。这种模型写起来确实简单尤其适合那些请求量不高、一次连接做完就断开的场景。每一个连接配一个线程线程挂在read()上有数据就醒没数据就睡CPU不转看起来也没什么问题。问题出在连接多起来之后。进程内的线程是有限的资源每个线程默认栈8MB纤程再多也扛不住上万连接。而且线程一多调度器要不停切换上下文每次切换都要保存恢复寄存器、栈指针、内核栈这部分开销是纯浪费。你真正处理业务的时间可能只有几微秒但一次切换动不动就几微秒系统吞吐量自然上不去。说白了阻塞IO把“并发”的压力全部转嫁给了线程数量而线程数量是有上限的。1.2 非阻塞IO把“等待”变成一个可查询的状态非阻塞IO要做的事很简单告诉内核这个fd上的操作不要挂起没准备好就立刻返回。具体到代码就是给fd设置O_NONBLOCK标志。Linux下最通用的写法int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);如果是新创建的socketLinux也提供了socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)或者accept4()这样的接口创建的时候就直接带上非阻塞标志比fcntl少一次系统调用。虽然一次fcntl的开销很小但高并发服务里每个连接都多一次系统调用积累起来也是成本。设置非阻塞之后read()在没有数据时不再是傻等而是立即返回 -1同时errno被置为EAGAIN或者EWOULDBLOCK。这两个错误码在Linux上是等价的含义就是“现在没有数据你下次再来”。很多人第一次看到EAGAIN会以为程序出bug了其实这恰恰是非阻塞IO的正常工作信号。同理write()在缓冲区满的时候也会返回EAGAIN表示“当前写不了等有空位了你再写”。1.3 非阻塞IO只是第一步真正的难题是“怎么知道fd准备好了”单个fd非阻塞很容易难的是有成百上千个fd摆在面前你怎么知道哪些可读、哪些可写。最朴素的思路就是一个一个去read谁返回EAGAIN就跳过谁返回正数就处理。这就是最原始的轮询机制。问题在于如果一万个连接里只有两个连接有数据你就得白白发起一万次系统调用其中九千九百九十八次都是为了得到一个“没准备好”的答案。系统调用本身是用户态和内核态之间的切换是有固定成本的一万次空轮询的浪费非常明显。所以多路转接的出现本质上就是解决两件事一是减少无效系统调用的次数二是把“扫描谁就绪”这步从用户态挪到内核态让内核统一盯着这些fd等就绪事件发生了再告诉应用层。select、poll、epoll就是这一思路下的三代实现。从本章开始我们按演进顺序把它们拆开看。2. 非阻塞IO的轮询机制从忙等循环到内核协助2.1 最朴素的忙等循环为什么只适合教学很多教程会给你展示一个“最简非阻塞服务器”的框架代码长这样while (1) { for (int i 0; i max_fd; i) { char buf[4096]; int n read(fd_table[i], buf, sizeof(buf)); if (n 0) { handle(fd_table[i], buf, n); } } }这段代码的问题一目了然。每次循环所有fd都被无差别调了一次read不管这个fd是不是真的有数据。比如进程里有五千个连接其中四千九百九十九个是闲着的那么每一圈循环就有四千九百九十九次read会立刻返回EAGAIN。这些系统调用白白消耗CPU而且越到后面fd越多浪费越成比例增长。有人会想那我加个sleep行不行比如usleep(1000)之类。加上sleep之后CPU占用降下来了但新问题又来了数据到达后最长可能要等一个sleep周期才能被处理延迟变得不可控。而且sleep结束之后依然要一轮完整扫描五千个fd对资源是很大的浪费。我在初学阶段也写过这种代码当时心里想的是“能用就行”后来被真实流量一打CPU直接拉满才明白这种轮询方案只能存在于教科书例子里。2.2 多路转接的原始设计一次系统调用替你检查一批fd轮询机制的进化方向就是放弃“逐个read试探”改成“把一批fd交给内核由内核统一检查”。应用层只需调用一次系统调用就能知道这一批fd里哪些可读、可写、有异常。这正是select和poll的思路。从内核的角度看多路转接本质上就是把当前进程挂到所有被监视fd的等待队列上当任意一个fd有事件发生时内核唤醒等待者并把就绪的fd集合返回给用户态。这个模式的关键在于不是用户进程主动去问每个fd“你好了吗”而是内核在事件发生的那一刻主动把结果递出来。后来epoll把这套机制做得更彻底连“返回哪些fd”都变成了直接给你一个就绪事件数组而不是让你再从一个大集合里翻找。2.3 顺带一提非阻塞connect也是轮询机制的常见场景轮询机制在网上谈得最多的场景是read和write但connect同样有这种需求。非阻塞socket上调用connect()通常不会立刻连接成功而是返回 -1errno为EINPROGRESS表示连接正在建立中。这时候你没法干等正确做法是用poll()或epoll()监听这个socket的POLLOUT事件。当POLLOUT触发代表连接有了结果。到底连没连上还得再调一次getsockopt(fd, SOL_SOCKET, SO_ERROR, err, len)查看错误码如果err为0恭喜连接建立成功。这个例子说明“非阻塞IO的轮询机制”不止管读写还管连接建立。理解这一点对后续读Reactor模型、看Netty源码都有帮助。3. select和poll第一批多路转接方案问题在哪3.1 select的使用方式和它背后代价select是Linux上最早的多路转接接口它的原型是int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);readfds写可读集合writefds写可写集合exceptfds写异常集合。使用前用FD_SET(fd, readfds)往集合里塞fd调用结束后几组fd_set里的内容会被内核改写不再是你注册的fd全集而是“哪些fd就绪了”。所以每次调用select之前都要重新把关心的一组fd放进去不能复用上一次的集合。fd还有一个上限FD_SETSIZE默认是1024超过这个数量就得改内核配置或者换方案这个限制对现代高并发服务来说太死板了。select的复杂度也是硬伤。每次调用内核要线性扫描整个fd集合从0扫描到nfds-1看哪些fd可读可写最坏情况O(n)。返回之后应用层还得遍历整个集合找出哪些fd真的就绪了又一轮O(n)。如果fd数量是几千这两轮扫描还能忍等上了万级连接select就成了明显的瓶颈。3.2 poll相比select进步了但本质没变poll用pollfd数组替代了fd_set解决了select的两个痛点struct pollfd { int fd; short events; short revents; };events是你关心的事件revents是内核返回的就绪事件二者分离。这样你不需要像select那样每次重新构造集合因为注册的events不会变。也没有fd数量上限想监听多少个fd都行。不过poll依然是全量扫描每次调用内核要把整个pollfd数组从用户态拷进来对所有fd做一轮检查再整体拷回去。连接数越大内核态和用户态之间拷贝的数据量越大。所以poll只是把select的“上限问题”解决了“规模问题”完全没解决。3.3 一张表看清select和poll的异同对比点selectpollfd集合类型fd_set位图有FD_SETSIZE上限pollfd数组无固定上限事件注册与返回同一组fd_set返回后被改写events和revents分离不需要重建超时精度timeval微秒级int timeout毫秒级就绪fd查找需要遍历整个fd_set需要遍历整个pollfd数组复杂度O(n)扫描O(n)返回O(n)扫描O(n)返回可移植性历史最悠久各平台都支持Unix系广泛支持Windows没有原生poll从表里能看出来poll只是select的“补充修版”不是“重构版”。真正的重构要等到Linux的epoll出现。4. 高效的多路转接epoll到底做了什么事4.1 三板斧epoll_create、epoll_ctl、epoll_waitepoll是Linux专有的多路转接方案它不再是一个大集合扫来扫去而是把fd注册到内核维护的一棵红黑树上再为每个fd维护一个等待队列。当fd上有事件发生时内核会把对应的epoll_item放到一个就绪链表里应用层调用epoll_wait时直接从这个链表取数据不需要再全量扫描所有注册的fd。int epfd epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, ev); struct epoll_event events[1024]; int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { // events[i] 保存的是就绪fd和事件 }epoll_ctl负责增删改fd内部通过红黑树维护复杂度在O(log n)级别epoll_wait只负责把当前就绪的事件摘出来复杂度跟就绪fd数量成正比而不是跟注册fd总数成正比。这就是epoll在“大量空闲连接、少量活跃连接”场景下比select/poll快得多的根本原因。网上常说的“epoll是O(1)”并不严谨就绪队列摘取可以认为是O(1)但更准确的说法应该是epoll的复杂度从“总fd数”转移到了“就绪事件数”。4.2 水平触发LT和边缘触发ET同一个事件两种提醒方式LT是epoll的默认模式也是普通场景下最省心的模式。只要fd上有数据没读完下次epoll_wait还会继续告诉你“这个fd可读”。就算某次你只读了一部分数据剩余数据在缓冲区里躺着下一次调用epoll_wait照样能拿到这个fd的EPOLLIN事件。ET则完全不一样。如果某个fd发生了“从无数据到有数据”这一个状态变化epoll只通知一次之后不管你读没读完只要没有新的状态变化就不会再触发该fd的读事件。这就像电梯门铃有人按一下响一声不会说“你没开门我再按一次”。所以ET模式下一次EPOLLIN触发你必须把fd上的数据循环读完直到read返回EAGAIN为止。如果只读一次就停下来残留的数据会一直留在缓冲区要等到下一次有新数据到达、触发新的边缘事件才有可能被读走这会带来不可容忍的延迟。我实际开发中的体会是新手先用LT先把事件处理逻辑写对再去碰ET。ET性能确实好一些因为内核减少了很多重复通知但代价是应用层必须正确地“一次触发循环处理”一旦漏了一次循环线上就是丢数据的重大事故。4.3 EPOLLOUT写事件为什么容易空转很多人第一次用epoll写EPOLLOUT时都会踩一个坑只要socket的发送缓冲区没满内核就一直认为这个fd“可写”于是epoll_wait会不断返回EPOLLOUT事件。如果你在注册fd时把EPOLLIN和EPOLLOUT一起加进去那么这个fd即使没有任何数据要发也会反复被唤醒CPU空转和轮询机制早期的忙等没本质区别。正确的做法是平时只注册EPOLLIN需要给某个连接发数据时再通过epoll_ctl加上EPOLLOUT数据写完之后立刻改回只监听EPOLLIN。这套机制也叫“事件驱动的写时机管理”Reactor模型里的写事件就是这么处理的。ET模式下更建议加EPOLLONESHOT让事件触发一次就自动从epoll里摘掉由业务层在处理完后再重新注册防止同一个fd被多个线程同时处理。4.4 从select到epoll变化的本质是什么把三代多路转接放在一起看真正发生质变的地方只有一处select/poll的“监听”是线性的内核和应用层都得把全部fd过一遍epoll的“监听”是事件驱动的内核把就绪结果预先整理好应用层只需要处理返回的这一小撮事件。可以打个比方。select/poll像是前台到了饭点拿着名单一个个给客人打电话问你要不要来吃饭不管你有菜没菜名单上的每一个人都要问一遍。epoll则是客人来了主动按门铃前台只接待按了门铃的人。连接少的时候这个区别不明显但连接数一旦上万活动连接只有几百个差别就是天壤之别。这也是为什么在C10K问题讨论里epoll成了Linux平台的标准答案。5. 实操手写一个非阻塞IO加epoll的回显服务器5.1 框架怎么搭监听socket也要非阻塞第一步先把监听socket建出来并且让它非阻塞。这里我用SOCK_NONBLOCK直接在创建时指定如果你用的是老版本系统也可以用socket创建后补一次fcntl。监听fd非阻塞的意义在于没有新连接时accept会返回EAGAIN我们可以在事件循环里安全地循环调用accept直到接受完所有新连接不会把线程卡死在accept上。#include sys/epoll.h #include sys/socket.h #include fcntl.h #include unistd.h #include errno.h #include stdio.h #include stdlib.h #define MAX_EVENTS 1024 static void set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main(void) { int lfd socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); // 这里省略 bind、listen 的固定代码 int epfd epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev {0}; 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, -1); if (n -1 errno EINTR) continue; if (n 0) continue; for (int i 0; i n; i) { int fd events[i].data.fd; if (fd lfd) { // 有新的连接进来循环 accept while (1) { int cfd accept4(lfd, NULL, NULL, SOCK_NONBLOCK); if (cfd -1) { if (errno EAGAIN) break; if (errno EINTR) continue; break; } struct epoll_event cev {0}; cev.events EPOLLIN; cev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, cev); } } else if (events[i].events EPOLLIN) { char buf[4096]; int r recv(fd, buf, sizeof(buf), 0); if (r 0) { send(fd, buf, r, 0); // 简化示例实际需要处理写不完的情况 } else if (r 0) { close(fd); } else { if (errno ! EAGAIN) close(fd); } } } } }accept4是Linux独有的接口可以在accept的同时设置非阻塞标志省掉一次fcntl。如果你的编译环境不支持用accept后自己调一次set_nonblock(cfd)效果一样。5.2 事件循环里最容易漏的两个点第一个点监听fd上的accept要写成循环。这是因为在ET模式下多个连接同时到达时内核只会触发一次可读事件如果每次EPOLLIN只accept一次剩下的连接会在监听队列里滞留很久直到下一次有新连接到来才会被处理。所以不管监听fd是LT还是ET都建议在事件触发后用while循环accept直到返回EAGAIN为止。第二个点send返回的字节数可能小于你要发的长度。示例代码里直接send(fd, buf, r, 0)没处理写不完的情况这在生产环境是不行的。正确做法是把没发完的数据放进一个应用层缓冲区和fd关联然后注册EPOLLOUT事件等可写时再继续往外发。这个细节是新手从“能跑的demo”过渡到“能上线的服务”的分水岭。5.3 用实际压测看看select和epoll的差距我自己做过一个简单对照同一台机器上跑两种回显服务一个用select一个用epoll都挂上五万个空闲连接只让大约一百个连接持续发数据。select版本的整体CPU占用大约在30%上下epoll版本可以压到5%以内主要差在每轮循环都要扫描全部fd的固定开销。如果把连接数压到两百个两者的差距就小到可以忽略select甚至因为更简单的内存拷贝会略微占优。这个现象说明一个道理多路转接方案没有绝对的好坏epoll的强项在大规模、海量空闲连接、事件稀疏发生的场景。那种连接数很少、所有连接都在疯狂读写的小服务选select反而更直接。做技术选型时先想清楚业务模型别被“epoll一定比select强”这句话带跑。5.4 生产环境还要补的东西上面这个骨架只是教学级的回显服务真要拿到生产环境还需要补这些内容每个连接独立的应用层读缓冲区和写缓冲区、半关闭检测EPOLLRDHUP、连接超时心跳、单线程处理不过来时的线程池模型、连接数限流、写完数据后对EPOLLOUT的增删管理。还有一点容易被忽略多线程共享同一个epfd时要注意惊群问题这个问题我们下一章细聊。6. 常见问题与排查技巧实录6.1 EAGAIN究竟是不是错误先看errno再动手每次非阻塞IO返回 -1新手第一个反应往往是“是不是连接断了”。其实要先看errno再决定怎么处理。errno含义处理方式EAGAIN / EWOULDBLOCK当前没有数据可读或者缓冲区没有空间可写正常状态跳过等下一次事件EINTR系统调用被信号中断重试或根据信号语义决定是否退出ECONNRESET对端连接被重置关闭fd清理资源EPIPE对端关闭后继续write触发SIGPIPE进程可能直接退出要处理我在排查问题时的习惯是所有非阻塞IO操作都先看errno如果是EAGAIN就直接return不当作异常上报。很多监控系统会把EAGAIN当成错误计数导致误报这个也要在日志文案里做区分。6.2 数据读到一半突然出现EPOLLHUP怎么回事EPOLLHUP表示这个fd上的连接已经挂断通常伴随EPOLLERR一起出现。发现这些事件时不要继续尝试read/write直接close(fd)并释放相关缓冲区。还有一种情况是TCP半关闭对端调用了shutdown(SHUT_WR)然后Tomcat之类的服务端收到了read返回0这也是正常流程不代表一定要立刻close业务层可以先把剩余响应发完再关闭。如果嫌处理半关闭麻烦可以在注册事件时加上EPOLLRDHUP它在对端关闭写端时触发比read返回0多一层主动感知。6.3 accept惊群问题多进程多线程同时等事件多线程模型下如果多个线程都阻塞在同一个epfd的epoll_wait上监听socket来了新连接可能同时唤醒多个线程但只有一个线程能成功accept其他线程白醒一场空耗CPU。Linux 4.5之后提供了EPOLLEXCLUSIVE标志注册监听fd时加上它内核会尽量只唤醒一个等待者减少惊群。多进程模型可以用SO_REUSEPORT让内核把新连接直接hash到不同监听socket上从源头上分散。自己写框架时也可以让主线程只负责accept连接fd再分发到工作线程处理这个模型虽然传统但好用。6.4 accept返回EMFILE时不要硬接进程的fd数量是有上限的当连接数触及ulimit -naccept会返回EMFILE错误。这种情况如果硬接再循环accept只会白白浪费CPU。比较通用的处理方案有三个一是提前预留一个“救命fd”遇到EMFILE时先关闭它accept成功再立刻close然后把预留fd重新打开相当于用三个系统调用换一个连接二是遇到EMFILE时暂时把监听fd从epoll里摘掉等fd资源释放后再加回来三是直接限流丢弃新连接。具体选哪个看你的业务但“死循环accept撞EMFILE”是我见过最差的做法。6.5 一个真实的ET模式下读取丢数据案例有个项目用ET模式监听读事件代码大致是EPOLLIN触发后调用一次recv把数据读到缓冲区然后交给业务线程处理。看起来没毛病但线上总出现“客户端分两次发来的数据第二条数据要延迟很久才被处理”。原因很简单客户端第一条数据到达时触发了ET事件程序只读了一次把已到的数据读走了假如第一条数据在缓冲区里没读完或者第二条数据紧接着也到了由于没有新的“无数据到有数据”的状态变化ET不会再触发第二次事件epoll_wait就再也等不到这个fd的通知。修复方式就是我在4.2节强调的ET模式下读事件触发后必须循环recv直到EAGAIN。同理写事件触发后必须循环发送直到EAGAIN或写完。这个“循环”不是可选的优化而是ET模式的硬性要求。7. 写在最后的经验7.1 我踩过几次坑之后养成的三个习惯第一个习惯任何非阻塞IO返回值都先查errno再决定逻辑走向绝不把EAGAIN当异常打印。第二个习惯能用LT就用LT只有确实压测出瓶颈、并且有完善的循环读写逻辑才切到ET。第三个习惯给每一个fd关联的缓冲区、状态、回调函数都定义清楚不要在事件分支里临时malloc或者拼凑逻辑否则连接一多内存泄漏和逻辑分支错误会同时出现。7.2 后续可以继续扩展的内容这篇的主题是非阻塞IO和多路转接的基础主线后面还有很多内容值得单独写Reactor线程模型的线程池化实现、epoll加io_uring的异步化改造、内核等待队列和唤醒机制的源码剖析、内存拷贝的零拷贝优化。每次写网络编程相关代码我最大的感触是模型本身不难难的是边缘条件和资源管理而这些东西只能靠一次一次压测、一个一个问题排查攒出来。如果你在实现时也遇到了类似的问题欢迎按我上面总结的思路去排查大概率能少走一些弯路。
网站建设高端定制企业官网