1. 从一次连接堆积说起epoll 到底解决了什么问题如果你写过 Linux 下的 TCP 服务端大概率经历过这样的场景用select或poll写的服务器连接数一过千就开始发飘CPU 占用飙高但吞吐上不去strace一看全耗在轮询 fd 集合上。这不是代码写得差而是模型本身的天花板——select每次调用都要把整个 fd 集合从用户态拷进内核态内核再线性扫描一遍连接越多无效扫描越多。epoll 就是为这个场景生的。它是 Linux 内核提供的一种 IO 多路复用机制核心能力是让一个线程同时盯住成千上万个 socket只在真正有数据可读可写时才被唤醒。适合谁写高并发网关、IM 长连接服务、游戏服务器、自研 RPC 框架的同学。它把「谁就绪了」这件事的判定从用户态轮询搬到了内核态回调这是效率差异的根源。我试过在单机 8 核 16G 的机器上跑一个 epoll 服务端维持 1 万个空闲长连接时epoll_wait所在线程几乎不占 CPU换成poll同样连接数光轮询就吃掉一个核。下面从三个系统调用讲起把注册、等待、事件循环这条链路拆开最后给你一套能直接编译运行的骨架和压测方法。2. 前置准备TaoToken 与开发环境本文的代码是纯 Linux 系统编程不依赖任何第三方库但如果你想在调试过程中用大模型辅助理解内核行为、生成测试脚本或排查报错可以借助 TaoToken 的模型对话能力。它的接入方式兼容 OpenAI 风格把 base_url 指向https://taotoken.net/api即可API Key 在控制台的 API Keys 页面生成。需要说明的是TaoToken 在这里扮演的是「开发辅助」角色——帮你解释epoll_ctl返回值含义、生成压测客户端、分析strace输出而不是替代你的编译器或运行时。生产环境的 epoll 服务端该怎么写还怎么写。环境准备清单Linux 内核 2.6 以上现在基本都是uname -r确认gcc 或 clang一个能跑压测的客户端工具比如自己写的多线程连接器或wrk可选TaoToken API Key用于对话式排障获取 Key 的入口在控制台文档在接入文档页。如果你后续要做长期的编码 Agent 或自动化测试流水线可以了解下 Coding Plan它更适合把模型能力嵌进日常开发流程。3. 三个系统调用epoll_create、epoll_ctl、epoll_waitepoll 的 API 极简就三个函数但每个参数都有讲究。3.1 epoll_create创建句柄int epoll_create(int size); int epoll_create1(int flags);epoll_create返回一个 epoll 句柄本身也是个 fd。size参数在 Linux 2.6.8 之后被忽略但必须传一个大于 0 的值否则返回EINVAL。新代码建议直接用epoll_create1(0)语义更干净还支持EPOLL_CLOEXEC标志避免 fd 泄漏到子进程。创建后这个 fd 会占用一个描述符用完必须close()否则反复创建会耗尽 fd。可以ls /proc/pid/fd/看到它。3.2 epoll_ctl注册与修改事件int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);这是 epoll 和 select 最大的区别所在select 是在「等待时」告诉内核要监听什么epoll 是提前「注册」好等待时不用再传集合。op有三个取值op 宏含义使用场景EPOLL_CTL_ADD注册新 fdaccept 新连接后加入监听EPOLL_CTL_MOD修改已注册 fd 的事件从读切换到写或调整触发模式EPOLL_CTL_DEL删除 fd连接关闭时移除struct epoll_event的结构是typedef union epoll_data { void *ptr; int fd; __uint32_t u32; __uint64_t u64; } epoll_data_t; struct epoll_event { __uint32_t events; /* 感兴趣的事件 */ epoll_data_t data; /* 用户数据 */ };events常用宏EPOLLIN可读、EPOLLOUT可写、EPOLLERR错误、EPOLLHUP挂断、EPOLLET边缘触发、EPOLLONESHOT只通知一次。data是个联合体最常用data.fd存 fd或者data.ptr存自定义结构体指针后者在需要携带连接上下文时更方便。3.3 epoll_wait等待事件int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);events是你自己分配的数组内核只负责把就绪事件拷进去不帮你分配内存。maxevents不能超过数组大小。timeout单位毫秒0 立即返回-1 永久阻塞直到有事件正数则最多等这么久。返回值是就绪 fd 的数量0 表示超时。关键点epoll_wait返回的不是全部 fd而是「就绪的那几个」。所以即使你注册了 10 万个连接一次返回可能只有几十个拷贝开销极小。这就是它 IO 效率不随 fd 数线性下降的原因。4. 可复制的 epoll 事件循环骨架下面这份代码是 LT 模式的服务端骨架编译即用。它做了三件事监听端口、接受连接、把新连接注册进 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 BUF_SIZE 4096 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 argc, char *argv[]) { if (argc ! 2) { fprintf(stderr, Usage: %s port\n, argv[0]); return 1; } int port atoi(argv[1]); int listen_fd socket(AF_INET, SOCK_STREAM, 0); 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); return 1; } if (listen(listen_fd, SOMAXCONN) 0) { perror(listen); return 1; } set_nonblocking(listen_fd); int epfd epoll_create1(0); if (epfd 0) { perror(epoll_create1); return 1; } struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; ev.data.fd listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev) 0) { perror(epoll_ctl add listen_fd); return 1; } printf(epoll 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) { /* 接受所有待处理连接 */ while (1) { struct sockaddr_in cli; socklen_t len sizeof(cli); int conn_fd accept(listen_fd, (struct sockaddr *)cli, len); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } set_nonblocking(conn_fd); ev.events EPOLLIN; 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 if (events[i].events EPOLLIN) { char buf[BUF_SIZE]; ssize_t cnt read(fd, buf, sizeof(buf)); if (cnt 0) { /* 回显实际业务替换这里 */ write(fd, buf, cnt); } else if (cnt 0) { printf(client fd %d closed\n, fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else { if (errno ! EAGAIN) { perror(read); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } } close(listen_fd); close(epfd); return 0; }编译运行gcc -O2 -o epoll_server epoll_server.c ./epoll_server 9000这份骨架用的是默认的 LT水平触发模式逻辑简单不易出错。如果你要上 ET 模式需要把ev.events改成EPOLLIN | EPOLLET并且读操作必须循环读到EAGAIN为止否则残留数据不会再触发通知。5. 验证请求与压测确认万级连接下的效果代码跑起来后先做功能验证。开一个终端跑服务端另一个终端用nc或telnet连上去发数据# 终端 A ./epoll_server 9000 # 终端 B nc 127.0.0.1 9000 hello epoll # 应该看到回显 hello epoll功能通了之后做连接压测。用 Python 快速起 1 万个空闲连接import socket socks [] for i in range(10000): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9000)) socks.append(s) print(connected:, len(socks)) input(press enter to close\n)跑起来后在服务端机器上观察# 看服务端进程的 fd 数量 ls /proc/$(pgrep epoll_server)/fd | wc -l # 看 CPU 占用 top -p $(pgrep epoll_server) # 看 epoll 等待线程状态 cat /proc/$(pgrep epoll_server)/status | grep -i threads实测下来1 万个空闲连接时服务端主线程 CPU 占用接近 0%因为epoll_wait在阻塞等待没有事件就不消耗 CPU。这时候你从任意一个客户端发数据服务端能立刻响应说明事件驱动链路是通的。如果想看吞吐可以用wrk或自己写多线程客户端持续发小包观察epoll_wait返回的 n 值和 QPS 的关系。注意maxevents设太小会导致一次处理不完设太大浪费内存一般 1024 到 4096 之间比较平衡。6. 本篇常见错误排查epoll_ctl 返回 EEXIST同一个 fd 重复EPOLL_CTL_ADD。要么先 DEL 再 ADD要么改用EPOLL_CTL_MOD。常见于连接复用时没清理干净。epoll_wait 一直返回同一个 fd 且 EPOLLINLT 模式下数据没读完内核会持续通知。检查 read 是否读到了EAGAIN或者缓冲区是否太小导致每次只读一部分。ET 模式下事件只触发一次就没了这是 ET 的典型坑。必须用非阻塞 fd并且循环 read 直到返回EAGAIN。如果只读一次剩余数据会一直留在缓冲区但不会再有新事件通知。accept 返回 EMFILEfd 耗尽。检查是否有连接关闭后没close()或者epoll_ctl DEL后忘了关 fd。可以用ulimit -n看上限必要时调大。epoll_wait 返回 EINTR被信号中断不是错误直接 continue 重新调用即可。上面的骨架已经处理了。注册了 EPOLLOUT 但一直触发只要 socket 可写LT 模式下 EPOLLOUT 会一直通知。正确做法是只在发送缓冲区满时注册 EPOLLOUT发完立刻改回 EPOLLIN。遇到这些报错时可以把错误码和上下文贴给 TaoToken 的模型对话让它帮你定位是参数问题还是逻辑问题比翻 man 手册快一些。7. 继续深入的方向把上面的骨架跑通、压测过之后你对 epoll 的调用链路就有了体感。接下来可以往几个方向走一是把 LT 改成 ET 并做完整的非阻塞读写循环理解边缘触发的边界条件二是引入EPOLLONESHOT配合线程池避免多线程同时处理同一个 fd三是读内核源码里的eventpoll结构体和红黑树实现理解为什么它能做到 O(1) 级别的就绪判定。如果你在写更复杂的网络框架需要模型帮你生成测试用例、分析strace输出或解释内核报错可以走 API Keys 接入文档把 TaoToken 接进你的开发流程。长期做编码 Agent 或自动化压测的话Coding Plan 会更顺手。工具是辅助真正的理解还是来自你亲手把这份代码改坏再修好的过程。
