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_wait
epoll 的 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 | 注册新 fd | accept 新连接后加入监听 |
| 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 且 EPOLLIN:LT 模式下数据没读完,内核会持续通知。检查 read 是否读到了EAGAIN,或者缓冲区是否太小导致每次只读一部分。
ET 模式下事件只触发一次就没了:这是 ET 的典型坑。必须用非阻塞 fd,并且循环 read 直到返回EAGAIN。如果只读一次,剩余数据会一直留在缓冲区,但不会再有新事件通知。
accept 返回 EMFILE:fd 耗尽。检查是否有连接关闭后没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 会更顺手。工具是辅助,真正的理解还是来自你亲手把这份代码改坏再修好的过程。