epoll标准工作流与伪代码架构:高并发IO多路复用实战指南

发布时间:2026/10/7 21:52:17
epoll标准工作流与伪代码架构:高并发IO多路复用实战指南 做Linux服务端开发“epoll”这三个字母基本绕不过去。不管是HTTP网关、消息推送服务、物联网长连接还是游戏后端一旦连接数上了万级多路IO复用的场景几乎都会落在epoll这条标准工作流上创建epoll实例、注册文件描述符、等待内核通知、分发处理事件。我从select时代一路用过来第一次重构到epoll时觉得整个世界都清爽了但说句实话真正把epoll用顺手靠的是踩坑和反复研究伪代码架构才做到的。这篇文章就把epoll的标准工作流和伪代码架构掰开揉碎讲一遍把每步操作背后的原理、参数选择的理由还有文档里一般不写、实测才能发现的坑一次性讲透。适合刚接触高并发编程的开发者也可以当老手的查缺补漏手册。1. 从阻塞IO到多路复用epoll 为什么能扛住百万连接1.1 select/poll 的瓶颈到底在哪多年以前我刚开始写服务器时用的是select。原理其实很简单把所有要监控的fd放进一个集合然后每次调用select让内核帮你看一遍谁有事件。问题是“看一遍”这个动作越来越贵。首先select有FD_SETSIZE限制默认1024虽然是编译期可以调但换个机器、换个环境就要重新编译这在生产环境根本没法玩。poll把数量限制解开了可以用动态数组每次调用时传fd数组进内核内核线性扫一遍找出就绪的fd再整体返回。这个“线性扫”就是核心瓶颈连接数到一万的时候每次poll都要扫一万个fd哪怕只有3个fd有事件也得把一万个fd全部判断一遍O(n)复杂度躲不掉。更别提每次调用poll都要把fd数组从用户态拷贝到内核态然后内核再把结果拷贝回用户态两次拷贝加一次全量扫描CPU和内存带宽都消耗在这上面。真实项目里用poll撑到几千连接就开始出现明显问题。具体数值我记得很清楚。我曾经在一个推送服务里用poll管理大约8000个连接平均每个连接每5秒有一包数据。按理说量不算大但poll每次都要扫全表机器负载一直在30%左右连接数往上加时CPU使用率几乎是线性上涨。这就是O(n)的宿命。1.2 epoll 的三个关键设计回调、红黑树、就绪链表epoll把复杂度降下来靠的是三个和select/poll完全不同的设计思路。第一内核维护了一份注册表。你用epoll_ctl把fd和关注的事件告诉内核之后后续wait的时候不需要再把整个fd集合重新拷贝进来这个注册表被内核一直保持着。select/poll是“每次把名单递给我看”epoll是“你先帮我登记好之后就等通知”。第二注册表底层用的是一棵红黑树。增删改fd的复杂度是O(log n)。连接数一百万操作复杂度也就是二十来次比较完全可以忽略。这意味着高连接数下动态增删连接不会成为瓶颈。第三也是最重要的一点内核在驱动层给每个注册的fd装了一个回调函数。当fd上有IO事件发生时驱动会直接调用这个回调把fd塞进一条“就绪链表”。epoll_wait返回的时候内核只需要把这条就绪链表里的fd拷贝给用户空间拷贝量和活跃连接数成正比而不是总连接数。这就是epoll本质上是O(事件数)而不是O(连接数)的原因。给个生活化理解餐厅里有几百桌客人select是服务员隔一会儿挨桌问“要加水吗”不管你要不要水都要问一遍。epoll是每桌装了一个按钮客人按铃服务员就过去。有事件才处理没有就不打扰。Linux 2.6.8之后epoll底层用到的就绪列表和红黑树已经足够稳定这也是它能扛百万连接的根本原因。1.3 水平触发与边缘触发同一次通知两种语义epoll支持两种触发模式LT水平触发Level Triggered和ET边缘触发Edge Triggered。很多人一开始只会用LT因为它是默认行为跟select/poll的语义一致只要fd上有数据没被读走epoll_wait就会持续通知你。ET则不一样它只在状态“从无到有”的那一刻通知一次。比如内核缓冲区从空变成有数据会通知你一次你这次没把数据读完下次它不会因为“缓冲区还有数据”再通知你只有等到新的数据进来缓冲区再次出现“从无到有”的变化时才会再次通知。这个差异在项目里非常关键。LT模式写起来简单适合大多数业务场景代价是内核可能多次唤醒你去处理同一个源事件唤醒开销偏高。ET模式唤醒次数少但要求你把事件处理干净尤其要配合非阻塞IO使用读到EAGAIN才算把数据取尽。选ET还是LT没有标准答案如果是代理类、低延迟、高吞吐的场景ET更合适如果业务对丢事件零容忍、图省心LT配合非阻塞IO也很好用。我个人的项目目前是LT打底在数据转发路径上局部使用ET。2. epoll 标准工作流拆解创建、注册、等待、处理的完整闭环2.1 创建实例epoll_create 还是 epoll_create1标准工作流的起点是调用epoll_create。早期epoll_create只有一个参数size传的是“这个实例预计挂牌多少fd”。内核发展到现在size参数已经没有实际用途了从2.6.8开始内核会根据实际注册量动态扩容红黑树传1和传100000没区别但必须大于0。我见过有人直接传0然后发现epoll_create返回EINVAL的这一点新手特别容易忽略。如果用的是glibc较新版本更推荐epoll_create1(EPOLL_CLOEXEC)。它比epoll_create多一个flags参数常用EPOLL_CLOEXEC。这个标志的用途是当程序调用exec执行其他程序时会自动关闭这个fd避免泄漏到子进程里。对于经常做daemon化、或者需要forkexec的服务器进程来说这能省掉很多安全事故。如果不确定要不要直接上EPOLL_CLOEXEC基本没错。返回的epoll_fd本身也是一个fd没有事件时它不会占用太多资源但注意一个进程可以创建多个epoll实例实例之间是独立的不要随意设计跨实例共享逻辑除非你特别清楚自己在做什么。另外如果编译时没开启_GNU_SOURCEepoll_create1可能根本声明不出来需要编译加-D_GNU_SOURCE或代码里先#define _GNU_SOURCE。2.2 注册与更新epoll_ctl 的 ADD、MOD、DEL创建完实例核心动作是epoll_ctl。它有四个参数操作目标epoll_fd、操作类型op、要监控的fd以及一个指向struct epoll_event的指针。op有三种EPOLL_CTL_ADD注册新fdEPOLL_CTL_MOD修改已有fd的监听事件EPOLL_CTL_DEL移除fd。struct epoll_event是这里的关键struct epoll_event { uint32_t events; // 事件集合按位或 epoll_data_t data; // 用户自定义数据 };events常用标志有EPOLLIN可读、EPOLLOUT可写、EPOLLERR错误、EPOLLHUP挂断、EPOLLRDHUP对端关闭连接、EPOLLET边缘触发、EPOLLONESHOT只通知一次。需要强调的是EPOLLERR和EPOLLHUP不需要你显式注册内核检测到错误或挂断时总会把这两个事件上报所以处理事件时分发逻辑里一定要带上对它们的判断否则连接出问题时你可能完全不知道。data是一个联合体可以选择存fd、存指针或者存一个64位整数。实际项目里最常用的做法是存指针指向一个连接上下文结构体。这样就可以把fd和它的业务状态一次打包起来事件到来时直接从data里取出上下文不需要再写一遍“fd到业务数据”的映射表。很多从select迁过来的人习惯把fd存在data里然后回查哈希表这能用但白瞎了epoll给的好设计。我们在后面伪代码架构里会展开讲data.ptr的用法。另一个最常犯的错误是EPOLL_CTL_MOD忘了传新的events值。MOD的含义是“覆盖式更新”不是“叠加式更新”。如果你先前注册的是EPOLLIN现在想再加EPOLLOUT必须在events里写EPOLLIN | EPOLLOUT而不是只写EPOLLOUT否则原来的EPOLLIN就被静默覆盖了可读事件再也收不到排查起来非常隐蔽。2.3 等待事件epoll_wait 的返回值与超时策略工作流中间的那段等待交给epoll_wait。签名比较长但核心参数就三个事件数组、数组长度上限、超时毫秒数。事件数组是用户态缓冲区内核把就绪事件拷贝进去maxevents告诉内核最多填多少个必须大于0否则返回EINVAL。maxevents选多少也有讲究一般取64到256之间就够了。取太小单次处理的事件太少循环次数变多取太大单次拷贝就绪事件的开销上去反而拖慢处理。超时参数timeout有三种语义-1表示一直阻塞直到有事件0表示立即返回相当于做一次非阻塞轮询正整数表示最多等多少毫秒。返回值有三类要看清楚。大于0表示有n个事件就绪你只需要处理events[0]到events[n-1]。等于0表示超时。等于-1表示出错但这不一定是致命错误如果errno是EINTR说明wait被信号打断了只需要continue重新调用即可。很多线上事故就是EINTR没处理导致整个主循环退出服务悄无声息挂了。标准工作流里epoll_wait被包裹在一个while true主循环里事件处理完了就立刻回到epoll_wait继续等待这个循环的结构就是event loop的雏形。timeout选多少也是学问。网络服务器大多数场景下用-1让内核当调度器有事件就处理没有就睡省CPU。只有当你需要在等待事件的过程中顺便跑点定时任务时才会用正值毫秒数作为周期。定时任务如果频率低建议用timerfd挂到同一个epoll实例里而不是靠timeout轮询。timerfd能把定时器变成fd和网络fd统一进epoll事件循环这种做法既干净又省电。3. 可落地的伪代码架构高并发服务器的骨架长什么样3.1 从 listen 到 event loop一份可直接改写的伪代码单讲API不给架构等于白讲。我用伪代码把epoll标准工作流串成一个完整的服务器骨架你直接照着改成各语言的真实代码都行。整体思路是初始化监听fd注册到epoll进入event loopepoll_wait拿事件按事件类型分发到不同处理函数处理函数执行完毕后回到循环。初始化部分listen_fd socket(AF_INET, SOCK_STREAM, 0) set_nonblock(listen_fd) // 用 fcntl(fd, F_SETFL, O_NONBLOCK) 即可 bind(listen_fd, address) listen(listen_fd, backlog) epoll_fd epoll_create1(EPOLL_CLOEXEC) ev.events EPOLLIN ev.data.ptr LISTEN_CTX // 监听 fd 的上下文标识 epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev)主循环while true: n epoll_wait(epoll_fd, events, MAX_EVENTS, -1) if n 0: if errno EINTR: continue break for i in 0 .. n-1: ev events[i] ctx ev.data.ptr if ctx LISTEN_CTX: accept_new_connections() continue if ev.events (EPOLLERR | EPOLLHUP): close_connection(ctx) continue if ev.events EPOLLIN: handle_read(ctx) if ev.events EPOLLOUT: handle_write(ctx)accept_new_connections()有一个容易忽视的动作在ET模式下一次EPOLLIN可能对应多个待处理连接所以要用循环accept接到EAGAIN为止。伪代码如下while true: conn_fd accept(listen_fd, ...) if conn_fd 0: if errno EAGAIN: break else: handle_accept_error() break set_nonblock(conn_fd) // 所有 fd 都要非阻塞ET 下是硬性要求 ctx create_context(conn_fd) // 内部把 conn_fd 也置为非阻塞 ev.events EPOLLIN | EPOLLRDHUP ev.data.ptr ctx epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, ev)这套骨架把标准工作流完整走了一遍。后面所有业务逻辑不管是WebSocket还是RPC都是在这个循环上面长出来的。3.2 事件分发与连接状态机把业务逻辑挂到 data.ptr 上伪代码里data.ptr指向的ctx是整条架构的核心。推荐的设计是让ctx包含四类信息fd本身、连接对端地址、收发缓冲区状态、当前协议状态。状态机也建议挂在这里。为什么状态机这么重要因为epoll事件是离散的一个请求可能分多次到达一次完整业务往往要跨多个事件才能完成。没有状态机你就得写一堆if嵌套判断“这包数据是不是续传的”状态一多必然乱。我常用的一种状态设计conn_state { STATE_READING: 等待请求数据 STATE_READING_BODY: 正在读取请求体 STATE_WRITING: 正在发送响应 STATE_CLOSING: 准备关闭 }事件分发时根据ctx当前状态走对应的处理函数比如STATE_READING收到EPOLLIN就继续读并解析解析出完整请求后置为STATE_WRITING并通过业务线程生成响应再写回。这套“事件驱动状态机”的组合是epoll架构里最值得抄下来的部分。它让单个连接的逻辑从“每次来事件都全量处理”变成“只做当前状态下该做的事”复杂度从O(全量逻辑)降到O(单步逻辑)排错也舒服很多。如果你把data只当fd存你每来一个事件都要去查一次ctx映射表等于把select时代用内存换时间的方法又带回来了。把data.ptr直接用起来回查就没了性能也上了一个台阶。3.3 LT 与 ET 在伪代码里的不同写法在伪代码层面LT和ET的主要差异集中在读事件处理函数。LT模式读伪代码handle_read(ctx): n read(ctx.fd, buffer, len) if n 0: process(buffer, n) // 如果没读完内核会再次通知不着急本轮读完 else if n 0: close_connection(ctx) else if errno EAGAIN: return // 暂时没数据等下次通知ET模式读伪代码handle_read(ctx): while true: n read(ctx.fd, buffer, len) if n 0: process(buffer, n) if n len: // 读到的比缓冲区小大概率已经读尽可以停了 break else if n 0: close_connection(ctx) return else if errno EAGAIN: break // 确认读尽退出循环 else: handle_read_error()差异核心就是ET必须在这次回调里尽可能把数据消费干净否则内核不会再给你“还有剩余数据”的通知。说得直白点ET是把“什么时候做、做多少”的责任从内核移交给了应用用更高的开发难度换更少的唤醒次数。如果是真实代码ET模式下还必须给业务标记非阻塞IO否则一个带阻塞读的fd配合ET数据没读完时再次read会卡死整个进程这个坑相当要命。4. 从伪代码到真实代码这几个转换细节决定性能上限4.1 上下文结构体怎么设计直接影响你会不会内存泄漏伪代码里的create_context细节不能省。我见过不少项目ctx结构体里只放了fd和state到后面加功能时就开始到处加字段结果变成一个大杂烩。比较顺手的设计是把它拆成两层管理层和协议层。管理层只放fd、对端地址、收发缓冲区指针、状态、引用计数协议层放业务解析需要的字段比如HTTP头解析缓冲、WebSocket掩码、RPC序列号。两层放在同一个ctx里作为嵌套结构体这样管理逻辑可以复用协议逻辑单独演进。ctx的释放是内存泄漏的高发区。epoll事件驱动的本质是“不知道下一个事件什么时候来也不知道连接什么时候被关”。所以释放逻辑要在三个地方统一处理read返回0、EPOLLHUP/EPOLLERR、业务主动关闭。复用同一个close_connection(ctx)内部先EPOLL_CTL_DEL把fd从epoll实例里摘掉再close(fd)再释放ctx。顺序不能反先摘除再关闭可以避免fd还在红黑树上时就关闭导致后续事件误判。如果你用某些框架还要注意tag机制防止一个已释放的ctx被新连接复用之后旧事件又拿它去做处理。建议给每个ctx加一个magic number或者递增序号事件到来时校验序号能大幅度降低悬垂指针带来的崩溃率。曾有人问过引用计数要不要加。我的建议是只要你的业务里出现过“这个连接要被业务线程发数据但它的事件处理线程同时把它关了”这种竞态就一定要加。每次事件处理开始先引用计数1处理完成或连接关闭时-1只有在引用计数归零后才真正free内存。虽然多几行代码但能避免大量偶发崩溃。4.2 读事件处理EAGAIN、半包粘包与读完的判断读事件处理是性能优化的主战场先说EAGAIN。非阻塞模式下read返回-1且errno是EAGAIN表示当前没有可读数据了这不是错是“读尽了”的信号直接返回就行。另一种errno是EWOULDBLOCK和EAGAIN在Linux上是同一个值不用再分情况。如果read返回0表示对端已经关闭连接EOF这时候要主动close并触发清理流程。注意如果是ET模式你在循环读的时候读到0要立刻退出而不是继续读否则会一直返回0造成死循环。再说半包和粘包。TCP是字节流协议没有消息边界。你一个业务包100字节网络层可能分3个TCP segment发过来你一次read可能只拿到30字节。反过来两个业务包也可能连着到达一次read拿到180字节。处理办法只有一条应用层自己定义消息边界要么定长、要么加长度前缀、要么用分隔符。收到数据先进缓冲区解析器只从缓冲区里取“完整的一条消息”多余的留在缓冲区等后续数据。epoll本身不帮你做这件事但它的事件通知机制方便你实现“持续累积、按边界取出”的缓冲区逻辑。值得注意的是缓冲区大小并不总是越大越好。我见过有人一上来就分配1MB读缓冲连接数一多内存立刻爆掉。合理做法是给每个连接分配4KB到32KB的读缓冲发现不够时用倍增法扩容上限设一个阀值超过阀值就断开或走文件旁路。没有明显trade-off之前小缓冲起步动态扩展更稳。类似的写缓冲区也要设上限防止一个缓慢的客户端拖住服务器内存写缓冲超过某个体积阈值就强制断开连接。4.3 写事件与 EPOLLOUT为什么总有人说 CPU 被打满写事件是epoll新手最容易翻车的点。先厘清一个事实fd大部分时间都是可写的因为发送缓冲区基本是空的。如果你一开始就把EPOLLOUT注册进去epoll_wait会几乎每一次都返回“可写”你会不停调用sendCPU直接跑满而真正干活的读事件却被挤占。问题就出在“不该关注可写时却关注了”。正确的写事件姿势是默认只关注EPOLLIN不关注EPOLLOUT。只有当调用send返回EAGAIN也就是内核发送缓冲区满了、数据没法立刻发出去时才把EPOLLOUT添加到该fd的事件集合里同时把未发完的数据暂存在ctx的写缓冲区。等内核发送缓冲区腾出空间epoll_wait返回EPOLLOUT再尝试把写缓冲区的数据flush出去。flush完毕要立刻调用epoll_ctl把EPOLLOUT拿掉只留EPOLLIN。整个“按需注册、用完即撤”的循环是避免busy loop的底线。伪代码表达send_data(ctx, data): n send(ctx.fd, data, len, MSG_NOSIGNAL) if n 0 n len: return OK else if n 0 errno EAGAIN: append_remaining_to_write_buf(ctx, data, len) set_events(ctx.fd, EPOLLIN | EPOLLOUT) // 等可写通知 else if n 0 n len: append_remaining_to_write_buf(ctx, data n, len - n) set_events(ctx.fd, EPOLLIN | EPOLLOUT) else: handle_send_error() handle_write(ctx): flush_write_buf(ctx) if write_buf_empty(ctx): set_events(ctx.fd, EPOLLIN) // 写完就撤去掉 EPOLLOUT else: // 一次 flush 不完事件会再次触发 pass发送时带MSG_NOSIGNAL可以避免在对端关闭后send触发SIGPIPE导致整个进程退掉。这个参数在Linux上非常实用建议每一条send都带上。5. 百万并发路上的坑epoll 常见问题与排查实录5.1 惊群问题多线程共同 wait 时怎么保证只唤醒一次多线程模型中如果多个线程都阻塞在同一个epoll_fd的wait上一个事件到达时内核可能把多个线程都唤醒只有一个线程能真正处理其他线程醒来发现没事可做重新睡回去。这就是惊群thundering herd。它浪费的是一次上下文切换和竞争开销连接数少的时候无所谓连接数大、事件频繁时影响还是挺明显的。解决办法大体有三条路。第一Linux 4.5之后可以在EPOLL_CTL_ADD时给事件增加EPOLLEXCLUSIVE标志内核保证一个事件最多唤醒一个线程这是最简单、最推荐的做法。第二使用SO_REUSEPORT让多个进程/线程各自bind同一个端口内核来做负载均衡每个进程有自己的listen_fd和epoll实例天然避免惊群。第三只让一个线程负责accept把新连接分发到其他线程各自的epoll实例上线程之间互不抢同一个epoll_fd。从我实测看第一和第三种方案最稳。第二种方案应用层要多做一层路由负载均衡粒度也取决于内核hash适合做多进程模型时再用。如果只是单进程多线程EPOLLEXCLUSIVE几行代码就能解决别自己去造锁。内核版本如果太老没有EPOLLEXCLUSIVE可以用线程间的eventfd来做事件转发让accept线程统一分发也不算复杂。5.2 收不到事件或事件静默六成是这三类原因线上经常遇到“连接建立成功但服务端半天不触发读事件”或者“数据明明发过来了就是没反应”。排查这么多年的经验六成以上落在三类原因。第一类是事件标志被覆盖。如上文所说EPOLL_CTL_MOD时没带上原有标志EPOLLIN被顶掉。这在你按需注册EPOLLOUT之后特别容易发生一旦EPOLLOUT拿掉时把EPOLLIN也一起拿掉连接直接变哑。建议每次MOD都通过一个保存了ev.events的变量来更新不要凭空构造。第二类是ET模式下没读完数据。事件通知一次后你只读到部分数据就返回了剩余数据残留在内核缓冲区新数据到达也不会再有状态变化内核按ET规则不通知。这种问题表现就是“偶发卡住重启服务恢复”。对策是ET回调里把读循环写完读不到EAGAIN不罢休。第三类是fd复用导致的老ctx干扰。fd被关闭后数值可能被新连接复用。如果你在EPOLL_CTL_DEL之后没有及时清理ctx新连接使用同一个fd时旧的事件记录/旧ctx可能被错误关联。强烈建议在fd关闭后立刻DEL释放并且在新连接create_context时生成一个递增序号出现异常时打日志能很快定位是不是复用了旧结构。排查时可以先用strace看epoll_wait的返回值如果fd已就绪但应用没有处理多半是业务分发逻辑问题如果fd没就绪说明根本没注册进去或者事件被屏蔽了再回头查epoll_ctl。5.3 EPOLLONESHOT 和 EPOLLRDHUP被低估的两个标志位EPOLLONESHOT的意思是这个fd上的事件通知一次之后内核自动把它从epoll的关注列表里摘除。适合单个fd的某个事件只能由一个线程处理的场景比如多线程共享一个epoll实例时连接被一个工作线程接管后不希望另一个线程再碰同一个fd。使用EPOLLONESHOT要注意事件处理完后必须通过EPOLL_CTL_MOD重新注册该fd下一次事件才会回来否则它就像死了一样不会有任何后续通知。很多第一次用ONESHOT的人连“重新注册”这步都忘掉白白丢事件。这里补一个更隐蔽的细节如果你同时使用了EPOLLET重新MOD的时候一定要把EPOLLET标志也带上否则内核会把该fd悄悄切回LT模式行为完全不一样。我就曾在这个细节上吃过亏忙了一下午才发现是MOD时丢了EPOLLET。EPOLLRDHUP是另一个容易被忽略的选项。它表示对端调用了shutdown(SHUT_WR)或者正常关闭了连接。TCP场景里对端正常关闭连接时本地会收到EPOLLIN因为EOF也算可读read返回0也能识别。但某些协议里对端只写一半就关闭比如HTTP keep-alive要求你根据头判断要不要继续收数据这时EPOLLRDHUP能提前告知“连接写侧关闭”方便你在读写状态机里优雅处理半关闭连接。对于直播推流、文件传输这类需要明确感知对端关流的应用把EPOLLRDHUP加上比等read返回0更及时。最后再分享一条实操中的体会。epoll本身不难难的是它生长的上下文连接的创建与销毁、缓冲区管理、协议解析、线程调度任何一环出问题都会表现为“epoll有问题”。所以我一直建议调试epoll服务时要先把日志体系搭好在每个连接的create_context、读事件、写事件、close_connection里都打一行带连接序号的日志。这样事件链路一目了然等排查问题的时刻到来你就能省下大量时间。希望这篇epoll标准工作流与伪代码架构的梳理能让你少踩几个坑。