1. 从一次线上抖动说起:为什么 select 撑不住高并发
很多做 Linux 服务端的朋友都经历过这样的场景:单机连接数刚过千,CPU 的 sys 占用就飙到 30% 以上,strace一看全是select在反复扫描 fd 集合。这不是代码写得差,而是 select 的模型天生如此——每次调用都要把整个 fd 集合从用户态拷进内核态,内核再线性遍历一遍,返回时再拷回来。连接数越多,这份"无效功"越大。
epoll 就是为解决这个问题而生的。它是 Linux 下 IO 多路复用的第三代接口,核心能力是:让一个线程同时盯住几十万个 socket,只在真正有数据可读可写时才被唤醒。适合谁?写网关、IM 长连接服务、Redis/Nginx 这类中间件、以及任何需要单机扛住万级以上并发连接的场景。
这篇文章我会先把 select/poll/epoll 的机制差异讲透,重点拆开 epoll 的"红黑树 + 就绪链表"设计,然后给出一份能直接跑的 epoll 服务端骨架,最后结合 TaoToken 的统一 Key/API 通道,演示怎么把 AI 编码工具接进来,让写这类底层代码时少踩坑。全程可复制、可验证。
2. select、poll、epoll 的机制差异到底在哪
2.1 select 和 poll 的共同瓶颈
select 用fd_set位图表示关注的 fd,上限被FD_SETSIZE卡死在 1024(默认)。poll 换成了pollfd数组,去掉了数量上限,但两者有个致命共性:无状态。
所谓无状态,是指内核不记得你上次关注了哪些 fd。每次调用select/poll,你都得把完整列表重新传一遍,内核重新遍历一遍。复杂度是 O(n),n 是监听的 fd 总数,而不是活跃 fd 数。一个 10 万连接、同一时刻只有 100 个活跃的服务,select 每次都要扫 10 万个,99.9% 是白干。
2.2 epoll 的三个系统调用
epoll 把这套流程拆成了三步,关键是把"注册"和"等待"分离:
int epoll_create1(int flags); // 创建 epoll 实例,返回一个 fd int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 增删改关注的事件 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待就绪事件epoll_ctl的 op 有三个宏:EPOLL_CTL_ADD注册新 fd、EPOLL_CTL_MOD修改监听事件、EPOLL_CTL_DEL移除 fd。epoll_event结构里events是关注的事件掩码,data是用户数据(常用data.fd存 fd,或data.ptr存自定义结构指针)。
常用事件掩码对照:
| 掩码 | 含义 |
|---|---|
| EPOLLIN | 可读(含对端正常关闭) |
| EPOLLOUT | 可写 |
| EPOLLERR | 发生错误 |
| EPOLLHUP | 对端挂断 |
| EPOLLET | 边缘触发模式 |
| EPOLLONESHOT | 只通知一次,需重新注册 |
2.3 红黑树 + 就绪链表:epoll 高效的本质
调用epoll_create时,内核创建一个eventpoll结构体,里面有两个关键成员:一棵红黑树rbr和一条就绪链表rdllist。
红黑树存的是所有被监控的 fd(封装成epitem)。用红黑树而不是哈希或数组,是因为它增删查都是 O(log n),且能高效去重——同一个 fd 重复 ADD 会被识别出来。就绪链表存的是"已经发生事件、等着被取走"的 fd。
真正的魔法在回调机制:当你epoll_ctl(ADD)一个 socket 时,内核会在这个 socket 的等待队列上注册一个回调。当网卡收到数据、中断处理把数据拷进内核缓冲区后,回调被触发,把这个 fd 对应的epitem挂到就绪链表上。于是epoll_wait被调用时,只需要看就绪链表空不空,非空就把链表里的节点拷到用户态数组返回,空就 sleep 到超时。整个过程和监听的 fd 总数无关,只和活跃 fd 数相关。
这就是为什么 epoll 在"大量连接、少量活跃"的 WAN 场景下碾压 select/poll;而在"所有连接都活跃"的高速 LAN 场景,优势并不明显,甚至因为epoll_ctl的额外开销略慢。
3. LT 与 ET:两种触发模式的行为差异
LT(水平触发)是默认模式。只要缓冲区里还有数据没读完,下次epoll_wait还会通知你。编程简单,不容易漏事件,相当于"更快的 poll"。
ET(边缘触发)只在状态变化的那一刻通知一次。如果一次没把数据读干净,剩下的数据不会再有新通知,除非对端又发了新数据。所以 ET 必须配合非阻塞 socket,并且用循环读到read返回EAGAIN为止。
// ET 模式下的标准读法 while (1) { ssize_t count = read(fd, buf, sizeof(buf)); if (count == -1) { if (errno != EAGAIN) { perror("read"); /* 真错误 */ } break; // EAGAIN 表示读干净了 } else if (count == 0) { break; // 对端关闭 } // 处理 buf 中的 count 字节 }Nginx 默认用 ET,追求极致性能;业务代码如果对正确性要求高、团队经验一般,LT 更稳妥。我试过在压测里把同一份逻辑从 LT 改成 ET,QPS 提升有限,但漏读导致的 bug 排查成本高得多,选型要权衡。
4. TaoToken 前置:统一 Key 与 API 通道
写底层网络代码时,我经常让 AI 工具帮忙补全 epoll 骨架、审 ET 循环有没有漏EAGAIN。但多个工具各配各的 Key、各记各的 endpoint,管理起来很乱。TaoToken 的作用就是提供一个统一的 Key 和 API 通道,把模型对话、编码补全这些能力收敛到一个入口。
官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
API 基址(不带 UTM):https://taotoken.net/api
你需要先拿到一个 Key,再去配置工具。Key 在控制台的 API Keys 页面生成:
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
拿到 Key 后,下面给两份可直接复制的配置骨架。
5. 可复制配置:settings.json 与 config.toml 骨架
5.1 Claude Code 风格 settings.json
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "sk-你的TaoToken密钥", "ANTHROPIC_MODEL": "claude-sonnet-4-20250514" }, "permissions": { "allow": ["Read", "Edit", "Bash(gcc:*)", "Bash(make:*)"] } }把ANTHROPIC_BASE_URL指向 TaoToken 的 API 基址,ANTHROPIC_AUTH_TOKEN填你的 Key。这样工具的所有请求都走统一通道,换模型只改ANTHROPIC_MODEL一行。
5.2 通用 config.toml 骨架
[provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-你的TaoToken密钥" [model] default = "claude-sonnet-4-20250514" max_tokens = 8192 temperature = 0.2 [request] timeout_seconds = 60 retry = 2temperature调低到 0.2,是因为写 epoll 这类底层代码时,我更希望模型给确定性的、符合 man 手册的答案,而不是发挥创意。
6. 验证请求:确认通道与 epoll 行为都正确
6.1 验证 TaoToken 通道
配置好后,先用一条最小请求确认通道通:
curl -s https://taotoken.net/api/v1/messages \ -H "x-api-key: sk-你的TaoToken密钥" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "max_tokens": 64, "messages": [{"role":"user","content":"用一句话说明 epoll 的 ET 模式为什么要非阻塞"}] }'返回里有正常的content文本,说明 Key 和通道都没问题。想直接在网页里对话验证模型,可以走模型对话入口:
https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite
6.2 验证 epoll 的 ET 行为
写一个最小服务端,监听端口,用 ET 模式,然后开两个终端:一个跑服务,一个用nc连上去发两段数据,观察是否只收到一次通知。
#include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <string.h> #include <errno.h> #define MAXEVENTS 64 static int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main(void) { int lfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(9000); bind(lfd, (struct sockaddr *)&addr, sizeof(addr)); set_nonblocking(lfd); listen(lfd, SOMAXCONN); int efd = epoll_create1(0); struct epoll_event ev, events[MAXEVENTS]; ev.events = EPOLLIN | EPOLLET; ev.data.fd = lfd; epoll_ctl(efd, EPOLL_CTL_ADD, lfd, &ev); for (;;) { int n = epoll_wait(efd, events, MAXEVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == lfd) { int cfd = accept(lfd, NULL, NULL); set_nonblocking(cfd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = cfd; epoll_ctl(efd, EPOLL_CTL_ADD, cfd, &ev); printf("accepted fd=%d\n", cfd); } else { char buf[512]; while (1) { ssize_t c = read(events[i].data.fd, buf, sizeof(buf)); if (c == -1) { if (errno != EAGAIN) perror("read"); break; } else if (c == 0) { close(events[i].data.fd); break; } printf("read %zd bytes\n", c); } } } } }编译运行:
gcc -O2 -o epoll_demo epoll_demo.c ./epoll_demo另一个终端:
printf 'hello' | nc 127.0.0.1 9000你会看到accepted fd=...和read 5 bytes。如果一次nc发两段(比如printf 'aa'; sleep 1; printf 'bb'),ET 模式下会看到两次 read 通知,因为对端触发了两次状态变化。这就是 ET 的行为验证。
7. 本篇常见错排查
报错一:epoll_wait返回后 read 一直 EAGAIN,但数据明明在。多半是 fd 没设非阻塞,ET 模式下阻塞读会卡死整个循环。检查fcntl(fd, F_SETFL, flags | O_NONBLOCK)有没有漏。
报错二:连接数一多就Too many open files。epoll 实例本身占一个 fd,每个连接占一个。检查ulimit -n,必要时在服务启动前ulimit -n 65535,或改/etc/security/limits.conf。
报错三:ET 模式下偶发丢数据。典型是 read 循环没读到EAGAIN就 break 了。ET 必须读到返回EAGAIN或0才算处理完,中途 break 会漏掉缓冲区剩余数据。
报错四:TaoToken 请求返回 401。检查ANTHROPIC_AUTH_TOKEN或x-api-key是否填了完整 Key,有没有多余空格。Key 在 API Keys 页面重新生成即可。
报错五:epoll_ctl返回EEXIST。同一个 fd 重复 ADD。要么先 DEL 再 ADD,要么改用EPOLL_CTL_MOD。
报错六:epoll_wait的 maxevents 传得比实际数组大。会导致内核往数组外写,栈溢出。maxevents 必须 ≤ 数组实际长度。
8. 工具侧落地:把 AI 编码接进 epoll 开发流
写这类底层代码,我习惯让 AI 帮忙做三件事:审 ET 循环、生成 epoll_ctl 的边界处理、解释 man 手册里含糊的返回值。要让这些能力稳定可用,工具侧的配置得先落地。
如果你主要做长期编码和 Agent 类任务,建议走 Coding Plan,把额度集中管理:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
接入细节和参数说明看文档:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
Claude Code 用户可以直接参考 Anthropic 兼容配置:
https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite
配置好之后,回到 epoll 本身:把上面那份骨架跑通,用nc验证 ET 行为,再逐步加上写事件处理、EPOLLONESHOT、超时管理。红黑树和就绪链表的设计理解透了,后面调 Nginx、调 Redis 的网络层,很多"为什么这么写"的疑问会自然消解。