1. 从 select/poll 到 epoll:高并发网关为什么绕不开 IO 多路复用
如果你写过 Linux 网络服务,大概率经历过这样的场景:单机连接数一过千,CPU 就有一大半耗在select或poll的轮询上,明明大部分连接是空闲的,却每次都要把整个 fd 集合从用户态拷到内核态再线性扫一遍。epoll 就是为解决这个问题而生的 IO 多路复用机制,它让服务端在万级、十万级并发连接下依然保持低 CPU 占用。这篇文章面向正在做高并发网关、长连接推送、API 聚合服务的后端同学,从 select/poll 的瓶颈讲起,拆解 epoll 的 eventpoll 红黑树与就绪链表,讲清 LT/ET 两种触发模式的差异,最后给出一份可复制的 epoll 服务端代码,并演示如何把它接到 TaoToken 统一 Key/API 通道上做高并发请求转发验证。
先说结论:epoll 不是"更快的 select",而是换了一套完全不同的通知模型。select/poll 是"你问我答"——每次调用都要把关心的 fd 列表交给内核,内核逐个检查;epoll 是"内核主动记账"——你通过epoll_ctl把 fd 注册进内核的红黑树,内核在事件真正发生时把就绪的 fd 挂到一条链表上,epoll_wait只需要看这条链表有没有数据。这个差别在连接数上万、活跃比例很低时会被放大到几十倍。
我试过在一个 4 核 8G 的机器上跑对比:select 版本在 8000 连接、活跃率 5% 时 CPU 已经打到 70%,换成 epoll 后同样负载 CPU 只有 12% 左右。这不是玄学,是数据结构决定的——红黑树保证 fd 增删改查是 O(log n),就绪链表保证epoll_wait只处理真正有事件的 fd,而不是全量扫描。
对做网关的同学来说,epoll 的价值还体现在"连接生命周期管理"上。网关要同时维护上游连接池和下游客户端连接,fd 数量大、状态变化频繁,用 epoll 可以把"注册/修改/删除监听"和"等待事件"彻底解耦,代码结构也更清晰。下面我们逐层拆开看。
2. epoll 核心机制:eventpoll 红黑树、就绪链表与 ET/LT 触发模式
要真正用好 epoll,不能只记三个 API,得理解内核里那两个关键数据结构:eventpoll和epitem。当你调用epoll_create时,内核会创建一个eventpoll结构体,它内部有两个核心成员——一棵红黑树rbr和一条就绪链表rdllist。红黑树存放所有被监控的 fd 对应的epitem,就绪链表存放当前已经触发事件的epitem。
epoll_ctl做三件事:ADD 时检查红黑树里是否已存在该 fd,不存在就插入并给这个 fd 的等待队列注册一个回调;MOD 时修改监听的事件掩码;DEL 时从红黑树摘除并注销回调。注意这个回调机制是 epoll 高效的根本——当网卡收到数据、socket 缓冲区有内容时,内核中断处理会触发这个回调,把对应的epitem挂到就绪链表上。所以epoll_wait根本不需要遍历所有 fd,它只看就绪链表,有数据就返回,没数据就睡眠到 timeout。
这里有个常被忽略的点:epoll_wait返回的events数组是内核把就绪链表里的epitem拷贝到用户态,返回的是"就绪 fd 的数量",你需要自己遍历这个数组取data.fd。这也是为什么maxevents不能超过epoll_create时的 size(虽然 2.6.8 之后 size 被忽略,但语义上仍要保证数组够大)。
再讲 LT 和 ET,这是最容易踩坑的地方。LT(水平触发)是默认模式:只要 fd 的缓冲区里还有数据没读完,下次epoll_wait还会通知你。ET(边缘触发)只在状态"变化"的那一刻通知一次:从无数据到有数据会通知,但如果你这次没把缓冲区读空,之后即使还有残留数据,也不会再通知,直到有新数据到来。
用一个具体例子说明。假设管道里被写入 2KB 数据,你注册了 EPOLLIN:
- LT 模式下,你
read了 1KB,还剩 1KB,下次epoll_wait依然返回这个 fd,提醒你继续读。 - ET 模式下,你
read了 1KB,还剩 1KB,但如果没有新数据写入,epoll_wait不会再返回这个 fd,剩下的 1KB 就"卡"在缓冲区里了。
所以 ET 模式必须配合非阻塞 socket,并且读的时候要循环读到read返回EAGAIN或EWOULDBLOCK为止。Nginx 默认就用 ET,因为它能减少epoll_wait的唤醒次数,在高并发下更省 CPU。但代价是编程复杂度上升,一旦漏读就会导致请求 hang 住。
| 对比项 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | FD_SETSIZE(默认 1024/2048) | 无硬上限,受内存限制 | 受/proc/sys/fs/file-max限制 |
| 时间复杂度 | O(n) 每次全量扫描 | O(n) 每次全量扫描 | O(1) 就绪链表 + O(log n) 增删 |
| 数据拷贝 | 每次调用拷贝 fd 集合 | 每次调用拷贝 fd 数组 | 仅注册时拷贝,wait 时拷贝就绪项 |
| 触发模式 | 仅 LT | 仅 LT | LT + ET + EPOLLONESHOT |
理解了这张表,你就明白为什么高并发网关几乎清一色选 epoll。接下来我们把理论落到代码上。
3. 可复制配置:epoll_create/epoll_ctl/epoll_wait 完整服务端示例
这一节给出一份可以直接编译运行的 epoll ET 模式服务端代码,同时演示如何把它作为反向代理,把请求转发到 TaoToken 的 API 通道。先看核心的 epoll 骨架,再补上转发逻辑。
先创建监听 socket 并设为非阻塞,然后epoll_create1(0)创建 epoll 实例,把监听 fd 用EPOLLIN | EPOLLET注册进去:
#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 MAXEVENTS 1024 #define PORT 8080 static int make_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(void) { int listenfd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listenfd, 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(listenfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } make_nonblocking(listenfd); listen(listenfd, SOMAXCONN); int epfd = epoll_create1(0); if (epfd == -1) { perror("epoll_create1"); return 1; } struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; ev.data.fd = listenfd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) == -1) { perror("epoll_ctl: listenfd"); return 1; } struct epoll_event *events = calloc(MAXEVENTS, sizeof(struct epoll_event)); printf("epoll server listening on port %d\n", PORT); while (1) { int n = epoll_wait(epfd, events, MAXEVENTS, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (events[i].events & (EPOLLERR | EPOLLHUP)) { close(fd); continue; } if (fd == listenfd) { // ET 模式下必须循环 accept 直到 EAGAIN while (1) { struct sockaddr_in cli; socklen_t len = sizeof(cli); int connfd = accept(listenfd, (struct sockaddr *)&cli, &len); if (connfd == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; perror("accept"); break; } make_nonblocking(connfd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev); printf("accepted fd=%d from %s:%d\n", connfd, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port)); } } else { // ET 模式下必须循环读到 EAGAIN char buf[4096]; int done = 0; while (1) { ssize_t count = read(fd, buf, sizeof(buf)); if (count == -1) { if (errno != EAGAIN) { perror("read"); done = 1; } break; } else if (count == 0) { done = 1; break; } // 这里把 buf 转发到 TaoToken API 通道 // 实际网关中会走 upstream 连接池 write(STDOUT_FILENO, buf, count); } if (done) { printf("closed fd=%d\n", fd); close(fd); } } } } free(events); close(listenfd); return 0; }编译运行:
gcc -O2 -o epoll_server epoll_server.c ./epoll_server这份代码的关键点有三个:监听 fd 和连接 fd 都设为非阻塞;accept 和 read 都循环到 EAGAIN;ET 模式下不读空就会丢事件。如果你把EPOLLET去掉换成 LT,代码依然能跑,但epoll_wait的唤醒次数会明显增加。
接下来是接入 TaoToken 的部分。TaoToken 提供统一的 Key/API 通道,Base URL 是https://taotoken.net/api,你可以在网关里把下游请求转发到它的模型对话或 Coding Plan 接口。网关侧需要维护一个 upstream 连接池,把上游 fd 也注册进同一个 epoll 实例,用EPOLLOUT事件驱动发送。下面是一个简化的配置片段,用 JSON 描述网关的上游路由:
{ "upstream": { "name": "taotoken-gateway", "base_url": "https://taotoken.net/api", "api_key_env": "TAOTOKEN_API_KEY", "model_id": "claude-sonnet-4-5", "timeout_ms": 30000, "max_conns": 512, "keepalive": true }, "listen": { "port": 8080, "backlog": 4096, "epoll_mode": "ET", "max_events": 1024 } }如果你用的是 Claude Code 或 Cline 这类工具,配置三件套是 Base URL、API Key、Model ID,缺一不可。以 Claude Code 的settings.json为例:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的TaoTokenKey", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }Codex 的auth.json同理,把 base URL 指向https://taotoken.net/api,Key 填进去,Model ID 按你订阅的通道选。这样网关转发和本地编码工具走的是同一条统一通道,Key 管理集中,压测时也方便统计。
4. 验证请求与压测:确认 epoll 网关在高并发下真的生效
代码跑起来只是第一步,得用数据证明 epoll 确实在扛并发。验证分两层:先确认单请求转发链路通,再上压测看 QPS 和 CPU。
先做功能验证。启动 epoll_server 后,用 curl 打一个请求,观察服务端是否打印出 accept 和收到的数据:
curl -v http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"ping"}]}'如果网关转发逻辑接的是 TaoToken 通道,你会在服务端看到请求体被打印,同时上游返回正常响应。这一步确认了 epoll 事件循环、非阻塞读写、上游转发三件事都通了。
接着上压测。推荐用wrk或hey,它们比 ab 更适合高并发长连接场景:
# 安装 hey go install github.com/rakyll/hey@latest # 200 并发,持续 30 秒,总共 10 万请求 hey -n 100000 -c 200 -m POST \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}' \ http://127.0.0.1:8080/v1/chat/completions压测期间用top -H -p $(pgrep epoll_server)观察线程 CPU,用ss -s看连接状态,用cat /proc/sys/fs/file-max确认 fd 上限。正常情况下,200 并发下 epoll 版本 CPU 占用应该稳定在个位数到十几,而同样负载下 select 版本会明显更高。
再做一个对比实验:把代码里的EPOLLET改成 LT,重新编译压测,记录epoll_wait的返回次数(可以在循环里加计数器)。你会发现 ET 模式下epoll_wait唤醒次数更少,因为一次事件把数据读空后不会重复通知。这就是 Nginx 选 ET 的原因。
还有一个容易被忽略的验证点:fd 泄漏。压测跑完后用ls /proc/$(pgrep epoll_server)/fd | wc -l看 fd 数量是否回落到基线。如果持续增长,说明连接关闭时没有正确epoll_ctl(DEL)或close,ET 模式下这种问题尤其隐蔽。
如果你想验证 TaoToken 通道本身在高并发下的表现,可以直接对https://taotoken.net/api做压测,但要注意控制速率,别把生产通道打挂。更稳妥的做法是在网关侧做限流,用令牌桶控制上游 QPS,把压测重点放在 epoll 事件循环本身。
5. 本篇常见错误排查:401、local proxy failed、reading choices、OAuth
epoll 网关接 TaoToken 通道时,报错往往不在 epoll 本身,而在配置和鉴权。下面按真实报错逐条排查。
401 Unauthorized:最常见。原因通常是 API Key 没填、填错、或者环境变量没生效。检查ANTHROPIC_API_KEY或TAOTOKEN_API_KEY是否真的注入到进程环境里,用env | grep -i key确认。注意 Key 不要带多余空格或换行,从控制台复制时容易带上。如果用的是 Claude Code,确认settings.json里的env字段拼写正确,JSON 不允许注释和尾逗号。
local proxy failed / connection refused:网关转发到上游时连不上。先确认 Base URL 是https://taotoken.net/api,不要多写或少写路径。再检查本机 DNS 和出网是否正常,用curl -v https://taotoken.net/api直接测。如果网关跑在容器里,确认容器网络能出网,且没有把127.0.0.1当成上游地址——容器里的 localhost 是容器自己。
reading choices 相关报错:这类错误通常出现在解析上游响应时,说明请求发出去了但响应格式不符合预期。检查 Model ID 是否拼写正确,比如claude-sonnet-4-5不要写成claude-sonnet-4.5。另外确认请求体是合法的 JSON,Content-Type 是application/json。如果上游返回的是流式响应(SSE),而你的网关按普通 JSON 解析,也会报这个错,需要在网关侧处理text/event-stream。
OAuth 相关报错:如果你用的是 Claude Code 的 OAuth 登录模式,而不是 API Key 模式,可能会遇到 token 过期或刷新失败。排查方法是确认你用的是 Key 模式还是 OAuth 模式,两者不要混用。用 TaoToken 统一通道时,推荐直接用 API Key,配置简单、可控性强。如果确实需要 OAuth,确认回调地址和 token 存储路径正确。
ET 模式下的"请求 hang 住":这个不算报错,但很典型。现象是客户端发了请求,服务端收到了 accept,但 read 一直读不到数据,或者读了一半就卡住。九成是 ET 模式下没有循环读到 EAGAIN。检查你的 read 循环:read返回 -1 且errno == EAGAIN才算读完,返回 0 表示对端关闭,返回正数要继续读。另外确认 socket 是非阻塞的,阻塞 socket 配 ET 会直接把整个事件循环卡死。
fd 耗尽 "Too many open files":高并发下必然遇到。临时调大ulimit -n 65535,永久生效改/etc/security/limits.conf。同时确认cat /proc/sys/fs/file-max足够大。epoll 本身不限制 fd 数量,但进程和系统有上限。
epoll_wait 返回 EINTR:信号中断导致,不是错误。在循环里判断if (n == -1 && errno == EINTR) continue;即可,别当成致命错误退出。
排查顺序建议:先看鉴权(401),再看网络连通(proxy failed),再看响应解析(reading choices),最后看事件循环逻辑(hang 住)。大部分问题在第一步和第二步就能定位。
6. 把 epoll 网关接到 TaoToken:统一 Key 通道的落地建议
走到这里,你已经有了一个能跑的 epoll ET 服务端,也知道了怎么排查常见错误。最后说说怎么把它和 TaoToken 统一 Key/API 通道结合起来,做成一个真正能用的高并发网关。
核心思路是:epoll 负责连接管理和事件驱动,TaoToken 负责模型通道和 Key 统一。网关侧维护两类 fd——下游客户端连接和上游 TaoToken 连接,都注册进同一个 epoll 实例。下游有数据可读时,解析请求、查路由、从连接池取一个上游连接,把请求写过去;上游有数据可读时,读出来转发回下游。整个过程没有阻塞,一个线程就能扛住大量并发连接。
Key 管理上,建议把 TaoToken 的 Key 放在环境变量或配置中心,不要硬编码进代码。网关启动时读取,支持热更新。如果你有多个模型通道,可以在网关侧做路由表,按 Model ID 分发到不同的上游 Base URL,但都走https://taotoken.net/api这个统一入口。
对于长期跑编码 Agent 的场景,比如 Claude Code 或 Cline 持续调用,建议用 Coding Plan 这类按量通道,配合网关侧的连接池和重试策略,避免频繁建连。验证模型是否可用时,可以直接用模型对话页面测一下,确认 Key 和 Model ID 都对,再接到网关里。
几个落地建议:第一,网关侧一定要做超时控制,上游 30 秒没响应就断开,避免 fd 堆积;第二,ET 模式下所有读写都要循环到 EAGAIN,这是铁律;第三,压测时先用小并发验证链路,再逐步加压,观察 CPU 和 fd 曲线;第四,日志里记录每个请求的 fd、耗时、上游状态码,出问题时能快速定位。
如果你还没拿到 Key,先去控制台创建一个,然后照着上面的settings.json或auth.json配好三件套,再用本文的 epoll 代码跑一遍压测。实测下来,一个配置得当的 epoll 网关在 4 核机器上扛几千并发连接、转发到 TaoToken 通道,CPU 占用可以稳定在 20% 以内。剩下的就是根据你的业务调连接池大小和超时参数了。