1. 为什么是 epoll1.1 从一次“十万连接”说起先讲个我早期的经历。刚接触 Linux 网络编程那会儿我以为高并发就是把线程开得足够多一台机器开上几千个线程总该能扛住压力了吧结果一压测机器直接卡死CPU 全耗在线程切换上真正干活的没几个。后来才明白传统的select和poll模型在连接数上来之后会迅速遇到瓶颈不是它们不努力而是设计模型决定了它们撑不住。select的问题在于每次调用都要把全部文件描述符集合从用户态拷贝到内核态内核帮你扫一遍再把结果拷回来。连接数一多这股拷贝和遍历的开销就非常扎眼。poll虽然解决了select的 1024 个 fd 上限问题但本质上还是“每次全量扫描”。而epoll给出的思路完全不同它把感兴趣的文件描述符提前交给内核“托管”内核在事件发生时才主动通知你只取就绪的那一小撮 fd 去处理就行。这个差异就是十万连接和一万连接的分水岭。如果你正在做高并发 TCP 服务、网关、消息推送、或者任何需要同时管理成千上万个 socket 的场景epoll几乎是你避不开的选择。这篇文章我把epoll的接口、原理和实战一起讲透适合刚学完 socket 编程、正准备往高并发方向走的读者哪怕你已经在用epoll了我后面写的 LT/ET 细节和排查经历也值得扫一眼。1.2 epoll 解决了什么高并发服务器面对的核心矛盾是大量连接大部分时间空闲但你又必须时刻知道谁“突然”有数据要读、谁可以写了。用阻塞 IO 加上多线程线程数跟着连接数走连接一多就爆炸。用非阻塞 IO 加上反复轮询CPU 又全耗在无意义的检查上。epoll本质上是一种“事件驱动”的解决思路你告诉内核关心哪些 fd 的哪些事件内核在对应事件发生的时候通知你。你不需要主动去问“你好你有数据了吗”而是坐在那里等内核把好消息送上门。这带来的好处很直接。第一可扩展性强epoll 管理的 fd 数量上限由系统内存决定轻松支持数万甚至数十万连接。第二效率高处理就绪 fd 的时间只和“有多少事件发生”相关而不是和“总共管理了多少连接”相关。第三编程模型清晰配合非阻塞 IO你可以用单线程处理海量连接避免多线程带来的锁竞争和上下文切换开销。不过epoll也不是万能的它适用于 IO 密集型场景如果你要做的是 CPU 密集型计算那该用线程池还是用线程池。把它理解成“IO 多路复用工具”里的最优解之一不要神化它。2. 三个接口把流程串起来epoll的使用没有想象中复杂核心就三个接口epoll_create、epoll_ctl、epoll_wait。很多初学者一看 man page 就头大其实把它们串成一个流程就清晰了先建一个“事件总管”然后把 fd 注册进去告诉它“你帮我盯着这个 fd 的读事件”最后在原地等它给你通知。2.1 epoll_create创建 epoll 实例#include sys/epoll.h int epoll_create(int size);size参数在老内核里用来提示内核初始分配多大空间从 Linux 2.6.8 开始这个参数被忽略但传一个大于 0 的数依然是约定俗成的写法。更推荐用epoll_create1它多一个flags参数可以传EPOLL_CLOEXEC防止子进程继承这个 fd。int epfd epoll_create(1); if (epfd 0) { perror(epoll_create); return -1; }拿到epfd之后它就是所有后续操作的操作柄。这个 fd 本身会占用一个文件描述符用完后记得close(epfd)释放。实际项目里我喜欢在程序初始化阶段就创建 epoll 实例不要每次处理连接时反复创建销毁那样反而浪费。2.2 epoll_ctl注册、修改、删除关注事件链接的本质是“告诉内核你要关心什么”原型很直白int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);op有三个可选操作EPOLL_CTL_ADD注册新 fd、EPOLL_CTL_MOD修改已注册 fd 的关注事件、EPOLL_CTL_DEL删除一个 fd。event是核心结构体长这样struct epoll_event { uint32_t events; // 关注的事件掩码 epoll_data_t data; // 用户数据联合体 }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;data这个字段很关键它不参与内核匹配只是内核在事件发生时原样返回给你。最常用的写法是把data.fd设为对应的 socket fd这样epoll_wait返回时你就知道是哪个 fd 就绪了。如果你维护了自定义连接对象也可以把data.ptr指向你的连接上下文一次拿到完整业务数据。事件掩码常用的有这么几个EPOLLIN可读事件包括对端正常关闭连接的情况这一点经常有人踩坑后面细说EPOLLOUT可写事件一般在你需要主动向对方写大量数据、且 socket 发送缓冲区已满时才注册EPOLLERR错误事件内核协议栈出错时触发通常建议始终关注EPOLLHUP挂起事件对端异常断开时触发同样建议始终关注EPOLLET边缘触发模式这个参数的影响很大后面用专门的小节讲EPOLLONESHOT一次性通知事件触发一次后就自动从 epoll 中移除需要重新EPOLL_CTL_MOD才能再次关注配合多线程处理时很好用举个例子把一个监听 socketlisten_fd注册到 epoll同时关心可读和挂起事件struct epoll_event ev; ev.events EPOLLIN | EPOLLERR | EPOLLHUP; ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) 0) { perror(epoll_ctl add listen_fd); }这里要特别强调一点对于监听 socket你关心的就是EPOLLIN因为新连接到来时accept可以立即执行而不阻塞。真正要accept的客户端连接 fd在用accept拿到后再把它注册进同一个 epoll 实例。2.3 epoll_wait等待事件就绪int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);events是一个由调用方提供的数组内核会把就绪的事件填进来maxevents表示这个数组最多装多少个事件timeout是阻塞等待的毫秒数传-1表示永久等待传0表示立即返回。返回值是本次就绪的事件个数为 0 表示超时为 -1 表示出错。出错之后要检查errno如果是EINTR被信号中断正确的做法是继续循环调用而不是当成致命错误退出。这套接口设计的巧妙之处在于epoll_wait返回时你拿到的全是“已经就绪”的事件不需要再遍历全部 fd 去挨个检查状态。每次循环只需要处理events[0]到events[n-1]复杂度是 O(就绪数)而不是 O(总连接数)。3. 红黑树 就绪链表epoll 内部是怎么转的3.1 内核里的两套数据结构总有人问我epoll凭什么比select快除了“减少用户态内核态拷贝”这个层面真正拉开差距的是它在内核里的数据结构设计。epoll在内核里针对每个 epoll 实例维护了两套核心数据结构。第一套是红黑树用来存放所有你注册过的 fd。红黑树的优势在于插入、删除、查找都是 O(log n)不管你有 10 个还是 10 万个 fd注册和修改操作的成本都很稳定。epoll_ctl的三个操作本质上就是在红黑树上做节点操作。第二套是就绪链表也叫 ready list专门存放已经触发事件的 fd。当某个 fd 的状态发生变化、满足你关注的事件条件时内核会把对应的事件节点链到这个链表上。epoll_wait做的事情其实就是在等待这个链表非空一旦非空把这些就绪节点拷贝到用户传入的events数组里然后清空该次的通知记录。简单理解红黑树是“关注名单”就绪链表是“有事要汇报的名单”。“关注”和“就绪”是两码事这也是 epoll 处理海量连接时不至于卡顿的根本原因。3.2 事件就绪的“回调”机制真正让 epoll 高效运转的是回调机制。在你通过epoll_ctl注册 fd 时内核会给这个 fd 关联一个回调函数。当 fd 上发生 IO 事件比如 socket 接收缓冲区来了数据时内核协议栈会主动调用这个回调回调做的事情就是把这个 fd 对应的epoll_item加入到就绪链表。这个过程完全是被动的、事件驱动的内核不需要为了“找就绪事件”去遍历所有 fd。这就像你在食堂订餐后厨一旦做好某个人的菜会主动喊你取餐不需要你自己每隔几秒跑去窗口问一次“好了没”。这里有一个很容易被忽略的细节水平触发 LT 模式下如果 fd 就绪但你这次没处理完数据后续epoll_wait会继续返回这个 fd因为就绪链表里的节点会因缓冲区还有数据而保留。而在边缘触发 ET 模式下内核只通知一次如果你没把数据全部读完它是不会再次通知你的这既是坑也是效率来源。3.3 LT 与 ET 模式的区别select和poll本质上都是水平触发epoll默认也是 LT但额外支持 ET。两者的区别用一句话概括LT 模式只要资源可读/可写就会一直通知ET 模式只在状态从不可用变为可用时通知一次。拿读事件举例。假设 socket 上来了 10KB 数据你一次性只读了 2KBLT 模式下下次epoll_wait还是会返回这个 fd因为还有 8KB 没读走ET 模式下内核已经把这次“可读”的通知发出去了你读不完剩下的数据它不会再通知你直到又有新数据到达所以 ET 模式要求你每次必须循环读到EAGAIN把数据尽量取干净否则会丢数据。EAGAIN 表示当前没有更多数据可读需要等下次事件通知。这个“读到 EAGAIN”的习惯是 ET 编程里最重要的纪律。那么问题来了ET 更快吗理论上是因为它减少了内核重复通知的次数在高负载场景下可以降低系统调用次数。但代价是编程复杂度上升。新手不建议一上来就全用 ETLT 模式下你可以先做对业务再考虑优化。很多公司的线上服务用 ET是因为已经把每个 fd 的读写逻辑打磨得很稳了那是优化过后的选择不是一开始就是。4. 实操用 epoll 写一个高并发回显服务器4.1 基础框架说了这么多理论还是上手写个东西最踏实。我带你写一个基于 epoll 的单线程高并发回显服务器功能非常简单客户端连接上来发送任意内容服务端原样返回。但这个框架能作为你后续做聊天室、网关、消息推送的基础。整体思路分五步创建监听 socket设为非阻塞创建 epoll 实例把监听 socket 注册进去关心EPOLLIN进入事件循环epoll_wait阻塞等待就绪事件如果是监听 socket就accept新连接把新 fd 设为非阻塞并注册进 epoll就绪事件如果是普通连接就循环读取数据再原样写回这里有几个设计决策值得解释一下。所有 socket 都设为非阻塞是为了配合 epollepoll 只是告诉你“大概率可读可写”但极端情况下依然可能因为资源竞争导致操作阻塞非阻塞保证你的单线程循环不会被一个 fd 卡死。而单线程处理所有连接就避免了多线程下的锁竞争模型极简也最容易排查问题。4.2 完整代码#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/socket.h #include sys/epoll.h #include netinet/in.h #include arpa/inet.h #define MAX_EVENTS 1024 #define BUF_SIZE 4096 // 把 fd 设为非阻塞 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 create_listen_fd(int port) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return -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(port); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(listen_fd); return -1; } if (listen(listen_fd, 128) 0) { perror(listen); close(listen_fd); return -1; } set_nonblock(listen_fd); return listen_fd; } int main(int argc, char *argv[]) { int port 9000; if (argc 1) { port atoi(argv[1]); } int listen_fd create_listen_fd(port); if (listen_fd 0) { return -1; } int epfd epoll_create(1); if (epfd 0) { perror(epoll_create); close(listen_fd); return -1; } struct epoll_event ev; ev.events EPOLLIN | EPOLLERR | EPOLLHUP; ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) 0) { perror(epoll_ctl add listen_fd); close(listen_fd); close(epfd); return -1; } struct epoll_event events[MAX_EVENTS]; char buf[BUF_SIZE]; printf(echo server listening on port %d\n, port); 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) { int fd events[i].data.fd; if (fd listen_fd) { // 有新连接到来 struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, len); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { // 没有更多新连接了属于正常情况 continue; } perror(accept); continue; } set_nonblock(conn_fd); ev.events EPOLLIN | EPOLLERR | EPOLLHUP; ev.data.fd conn_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev) 0) { perror(epoll_ctl add conn_fd); close(conn_fd); } else { char ip[32]; inet_ntop(AF_INET, client_addr.sin_addr, ip, sizeof(ip)); printf(accept new client: %s:%d, fd%d\n, ip, ntohs(client_addr.sin_port), conn_fd); } } else { if (events[i].events EPOLLIN) { // 循环读直到读完当前缓冲区的数据 while (1) { ssize_t rn read(fd, buf, sizeof(buf)); if (rn 0) { // 原样写回 ssize_t wn write(fd, buf, rn); if (wn 0) { if (errno ! EAGAIN errno ! EWOULDBLOCK) { // 写失败且不是缓冲区满关闭连接 close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); break; } } } else if (rn 0) { // 对端关闭 printf(client closed, fd%d\n, fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 当前数据读完了退出循环等下次通知 break; } // 真正出错 perror(read); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); break; } } } if (events[i].events (EPOLLERR | EPOLLHUP)) { // 出错或挂起直接关掉 printf(error or hangup, fd%d\n, fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } } } } close(listen_fd); close(epfd); return 0; }这段代码在 Linux 上可以直接编译gcc -o epoll_echo epoll_echo.c ./epoll_echo 9000然后用nc或者telnet测试echo hello epoll | nc 127.0.0.1 9000你会看到服务端原样返回hello epoll。这时再开多个终端同时连接你会发现一个进程就撑住了所有连接这就是 epoll 的威力。4.3 使用 ET 模式的注意事项如果你想试试 ET 模式只需要把两个epoll_ctl注册处的events加上EPOLLET即可。但运行起来之后你大概率会踩到一个坑连接闲置的时候对端发来数据服务端可能不响应或者响应不完整。原因就是前面说的ET 模式只在状态跃迁时通知一次。如果注册时 fd 上已经积压了数据比如刚 accept 完、对方立刻发数据某些内核版本下你可能抢不到这次“状态跃迁”的通知导致这个 fd 的数据一直不被处理。解决方案是注册完 fd 后主动模拟一次可读事件也就是手动触发一次EPOLLIN处理逻辑把已经到达的数据先处理掉。实现上可以把 fd 的处理逻辑封装成一个函数在accept之后显式调用一次。另外ET 模式读取数据时千万不能只调一次read必须循环读到EAGAIN。很多人写 ET 服务出 bug十有八九是这里没读干净。这也是我为什么在新手阶段不建议直接用 ET 的原因逻辑没错但工程细节太多。5. 常见问题与排查技巧实录5.1 问题速查表我把实际调试 epoll 服务时经常碰到的问题整理成了表格你在排查时可以直接对号入座。现象可能原因排查方向与解决方案连接建立后服务端收不到数据fd 没注册进 epoll或者注册后又被意外删除检查epoll_ctl返回值在accept后打印 fd 编号确认连接是否成功注册有新连接但accept返回 -1accept 队列为空或 EAGAIN这是正常现象监听 socket 收到 SYN 后由内核完成握手accept不一定立刻有连接可取忽略 EAGAIN 即可客户端断开后服务端循环卡死没把对端关闭read 0当成连接结束处理一定要显式处理rn 0的情况关闭 fd 并从 epoll 中删除否则 fd 泄漏会越来越大EINTR导致epoll_wait返回 -1进程收到信号比如 SIGCHLD、SIGPIPE判断errno EINTR后continue不要退出循环高并发下服务端 fd 数量持续上涨连接关闭时没有close和EPOLL_CTL_DEL每次close之前确认已从 epoll 删除也可以定期用ss或lsof检查 fd 总数write返回 -1 且 errno 为 EAGAINsocket 发送缓冲区已满这种情况要注册EPOLLOUT等可写事件通知之后再继续写不要让业务逻辑阻塞在单次 write 上5.2 几个容易踩的坑第一个坑是EPOLL_CTL_DEL和close的顺序问题。**先删再关还是先关再删**正确的顺序是先epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)再close(fd)。虽然新内核会自动清理 close 掉的文件描述符但显式删除可以避免某些边界条件下 epoll 内部残留事件导致的异常通知。我在调试阶段就遇到过一次close之后没删事件循环里又拿到旧 fd 的EPOLLHUP通知fd 号被复用后这个挂起事件就作用到新连接上了排查了很久才发现根源。第二个坑是EPOLLIN不只是“有数据可读”对端关闭连接时也会触发EPOLLIN然后read返回 0。所以处理连接关闭不能只在EPOLLHUP或EPOLLERR里关必须在EPOLLIN的读取分支里判断read 0。这几乎是所有 epoll 新手都会漏掉的一点。第三个坑是关于“每次 accept 只调一次到底行不行”。在高并发下监听 socket 可能同时收到多个新连接。如果在一次EPOLLIN通知里只accept一次剩下的连接会留在内核队列里LT 模式下下次epoll_wait还会继续通知程序没问题但吞吐量会受影响。更稳的写法是while (1)循环accept直到返回EAGAIN再跳出把一轮内核通知里能处理的事件尽量处理完。5.3 性能调优参考当你把基本逻辑跑通之后可以关注几个调优方向。第一个方向是epoll_wait的maxevents参数建议设置成和你的事件数组大小一致不要设太小否则一次内核通知里装不下所有就绪事件频繁返回会增加系统调用开销。我平时习惯设为 1024 或 2048再根据连接量调整。第二个方向是分配连接内存池。每个连接都维护一个读写缓冲区如果每次 accept 都malloc高并发下分配和释放的锁竞争、缓存失效问题会拖慢性能。常见的做法是预先分配固定大小的连接池空闲连接挂在一个空闲链表上。这个优化属于进阶玩法初学者先把 epoll 逻辑写好再考虑内存分配模型的优化。第三个方向是注意SIGPIPE信号。当对端关闭连接后你还继续write进程会收到SIGPIPE信号默认行为是终止进程。这个坑很隐蔽线上服务突然挂掉日志里什么都没有查半天发现是SIGPIPE害的。处理方式很简单在程序启动时signal(SIGPIPE, SIG_IGN)忽略掉write返回错误时自行处理即可。第四个方向是考虑是否使用EPOLLONESHOT。如果服务端是用多线程处理业务的某个 fd 的数据可能同时被多个线程抢着处理造成逻辑混乱。用EPOLLONESHOT确保一个 fd 在一次事件通知后立即从 epoll 中摘除处理完后再重新注册就能天然避免并发读写同一个 fd 的问题。这个模式适合从单线程 epoll 往多线程 epoll 过渡的阶段。我自己在踩过几次坑之后总结下来的习惯是凡是 epoll 服务的退出路径一定要统一封装成一个函数比如close_conn(epfd, fd)在里面依次做“删除事件关注、关闭 fd、释放连接资源”任何分支走到连接结束都调用它避免出现一条路径忘删、一条路径忘关的疏漏。这个习惯看起来笨但在高并发下救了我很多次排查 fd 泄漏时从来不用一行一行追代码。最后再分享一个小技巧调试 epoll 服务时别一上来就压测先用strace观察系统调用或者用ss -tn看连接状态。很多时候“服务没响应”的原因根本不是 epoll 写错了而是连接压根没建立成功或者对端根本没发数据。定位问题先看网络层再看应用层会省很多冤枉时间。