尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux epoll详解:从select/poll到epoll的高性能IO演进与TaoToken实践

Linux epoll详解:从select/poll到epoll的高性能IO演进与TaoToken实践 1. 从 select/poll 到 epoll高并发网络编程到底卡在哪如果你写过 Linux 网络服务大概率经历过这样的场景用 select 写的服务连接数一过千CPU 就飙到 100%但实际吞吐却没涨多少。这不是代码写得差而是 select/poll 这套 IO 多路复用机制本身的天花板到了。select 的核心问题有三个。第一是文件描述符数量限制FD_SETSIZE 默认 1024想突破就得重新编译内核代价太大。第二是每次调用都要把整个 fd 集合从用户态拷贝到内核态连接数上万时光是内存拷贝就吃掉大量 CPU。第三是返回后要线性扫描所有 fd 才知道谁就绪大量空闲连接白白浪费扫描时间。poll 用 pollfd 数组替代了位图解决了 1024 的数量限制但拷贝和线性扫描这两个根本问题没变。所以 poll 只是 select 的改良版不是质变。epoll 的出现是质变。它把「注册监控」和「等待事件」拆成两步epoll_ctl 负责把 fd 注册进内核的红黑树epoll_wait 只负责从就绪链表里取事件。这样一来每次等待不需要重复拷贝全量 fd返回的也只有真正就绪的那几个。连接数从一千涨到十万epoll_wait 的开销几乎不变。这篇文章会带你走完三件事搞清楚 epoll 的红黑树加就绪链表到底怎么工作、LT 和 ET 两种模式在代码上差在哪、写一个能跑的 epoll 服务端并用压测验证它的行为。最后我会顺带演示一下在用 AI 编码工具辅助开发这类底层代码时怎么通过 TaoToken 统一 Key 和 Base URL 把工具链接入配好省得每个工具单独折腾一遍。适合谁看写过 socket 但没深入过 epoll 的后端同学、准备面试被问 IO 多路复用的人、以及想搞明白 Nginx 为什么默认用 ET 模式的工程师。下面所有代码都可以直接复制编译运行不需要额外依赖。2. epoll 的红黑树与就绪链表内核里到底发生了什么要理解 epoll 为什么快得先看它在内核里建了什么数据结构。调用 epoll_create 或 epoll_create1 时内核会创建一个 eventpoll 结构体这个结构体里有两个关键成员一棵红黑树 rbr和一条就绪链表 rdllist。红黑树负责存储所有被监控的 fd。当你调用 epoll_ctl 加一个 fd 时内核会为它建一个 epitem 节点挂到红黑树上。红黑树的查找、插入、删除都是 O(log n)所以即使监控十万个 fd增删改查也不会慢。这就是 epoll 支持大数量 fd 的底气。就绪链表负责存储已经触发事件的 fd。关键点在于内核不是等 epoll_wait 被调用时才去扫描谁就绪而是在 fd 上有数据到达时通过回调机制主动把对应的 epitem 挂到就绪链表上。这个回调是在网卡数据拷贝进内核、唤醒对应 socket 等待队列时触发的属于事件驱动不需要轮询。所以 epoll_wait 的工作变得极其简单看一眼就绪链表有没有数据有就返回没有就睡到超时。它返回的 events 数组里只有真正就绪的 fd数量通常远小于监控总数。这就是「IO 效率不随 FD 数目增加而线性下降」的原因。再对比一下 select/poll 的流程每次调用都要把用户态的 fd 集合完整拷贝进内核内核再逐个调用每个 fd 的 poll 方法检查状态返回后用户态还要再扫一遍。连接越多这两次全量操作越贵。epoll 把「全量检查」换成了「事件回调」把「每次拷贝」换成了「一次注册」省下的就是这部分开销。有一个细节值得注意epoll 用 mmap 让内核和用户空间共享一块内存来传递就绪事件避免了传统方式里内核到用户态的又一次拷贝。虽然现代实现里这块的收益没有早期宣传的那么夸张但配合就绪链表只返回少量 fd整体拷贝量确实小得多。理解了这套结构LT 和 ET 的区别就好解释了。LT 模式下只要 fd 的缓冲区还有数据没读完内核就会一直把它留在就绪链表里每次 epoll_wait 都通知你。ET 模式下只有状态发生变化比如从无数据变成有数据时才通知一次如果你没读完剩下的数据不会再次触发通知除非对端又发了新数据。这就是为什么 ET 必须配非阻塞 socket而且必须循环读到 EAGAIN 为止。Nginx 默认用 ET是因为它追求极致性能愿意承担「必须读干净」的编程复杂度。而大多数业务代码用 LT 更稳妥不容易因为漏读导致请求卡死。选哪个不是绝对的取决于你对代码正确性的把控能力。3. 可复制配置一个完整的 epoll ET 服务端下面这份代码是一个可以直接编译运行的 epoll 服务端采用 ET 模式加非阻塞 socket。它监听指定端口接受连接把收到的数据回显到标准输出。你可以把它保存为 epoll_server.c。#include stdio.h #include stdlib.h #include unistd.h #include errno.h #include string.h #include fcntl.h #include sys/socket.h #include netdb.h #include sys/epoll.h #define MAXEVENTS 64 static int create_and_bind(const char *port) { struct addrinfo hints; struct addrinfo *result, *rp; int s, sfd; memset(hints, 0, sizeof(struct addrinfo)); hints.ai_family AF_UNSPEC; hints.ai_socktype SOCK_STREAM; hints.ai_flags AI_PASSIVE; s getaddrinfo(NULL, port, hints, result); if (s ! 0) { fprintf(stderr, getaddrinfo: %s\n, gai_strerror(s)); return -1; } for (rp result; rp ! NULL; rp rp-ai_next) { sfd socket(rp-ai_family, rp-ai_socktype, rp-ai_protocol); if (sfd -1) continue; int opt 1; setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); s bind(sfd, rp-ai_addr, rp-ai_addrlen); if (s 0) break; close(sfd); } if (rp NULL) { fprintf(stderr, Could not bind\n); return -1; } freeaddrinfo(result); return sfd; } static int make_socket_non_blocking(int sfd) { int flags fcntl(sfd, F_GETFL, 0); if (flags -1) { perror(fcntl F_GETFL); return -1; } flags | O_NONBLOCK; if (fcntl(sfd, F_SETFL, flags) -1) { perror(fcntl F_SETFL); return -1; } return 0; } int main(int argc, char *argv[]) { int sfd, s, efd; struct epoll_event event; struct epoll_event *events; if (argc ! 2) { fprintf(stderr, Usage: %s [port]\n, argv[0]); exit(EXIT_FAILURE); } sfd create_and_bind(argv[1]); if (sfd -1) abort(); s make_socket_non_blocking(sfd); if (s -1) abort(); s listen(sfd, SOMAXCONN); if (s -1) { perror(listen); abort(); } efd epoll_create1(0); if (efd -1) { perror(epoll_create1); abort(); } event.data.fd sfd; event.events EPOLLIN | EPOLLET; s epoll_ctl(efd, EPOLL_CTL_ADD, sfd, event); if (s -1) { perror(epoll_ctl listen); abort(); } events calloc(MAXEVENTS, sizeof(event)); while (1) { int n epoll_wait(efd, events, MAXEVENTS, -1); for (int i 0; i n; i) { if ((events[i].events EPOLLERR) || (events[i].events EPOLLHUP) || (!(events[i].events EPOLLIN))) { fprintf(stderr, epoll error on fd %d\n, events[i].data.fd); close(events[i].data.fd); continue; } if (sfd events[i].data.fd) { while (1) { struct sockaddr in_addr; socklen_t in_len sizeof(in_addr); int infd accept(sfd, in_addr, in_len); if (infd -1) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } make_socket_non_blocking(infd); event.data.fd infd; event.events EPOLLIN | EPOLLET; if (epoll_ctl(efd, EPOLL_CTL_ADD, infd, event) -1) { perror(epoll_ctl add conn); abort(); } printf(Accepted connection fd%d\n, infd); } continue; } int done 0; while (1) { char buf[512]; ssize_t count read(events[i].data.fd, buf, sizeof(buf)); if (count -1) { if (errno ! EAGAIN) { perror(read); done 1; } break; } else if (count 0) { done 1; break; } write(STDOUT_FILENO, buf, count); } if (done) { printf(Closed connection fd%d\n, events[i].data.fd); close(events[i].data.fd); } } } free(events); close(sfd); return EXIT_SUCCESS; }编译命令gcc -O2 -Wall -o epoll_server epoll_server.c运行./epoll_server 9000这份代码里有几个容易踩的点。第一listen 的 socket 也设成了非阻塞这样 accept 循环在 ET 模式下才能正确读到 EAGAIN 退出。第二每个新连接都设非阻塞这是 ET 的硬性要求。第三读数据用 while 循环读到 EAGAIN保证一次事件把缓冲区读干净否则 ET 不会再通知你。如果你在用 AI 编码助手写这类底层代码工具链的接入配置可以统一走 TaoToken。它的 API 地址是 https://taotoken.net/api在支持自定义 Base URL 的工具里填这个地址再配上在控制台申请的 Key 就行。比如 Cline 这类插件配置项里 Base URL、API Key、Model ID 三件套填全即可。Key 在 https://taotoken.net/api-keys 申请模型 ID 可以参考文档 https://taotoken.net/doc 里的列表。这样多个工具共用一个 Key切换时不用反复改配置。4. 验证请求与压测确认 ET 模式真的按预期工作代码跑起来后先做功能验证。开一个终端运行服务端另开一个终端用 telnet 或 nc 连接telnet 127.0.0.1 9000连上后随便输入几个字符回车服务端终端应该打印出你输入的内容。输入 Ctrl] 再 quit 断开服务端会打印 Closed connection。这一步确认基本收发和连接关闭都正常。接下来验证 ET 模式的关键行为。ET 的特点是「一次事件只通知一次」如果你没读完剩余数据不会再次触发。我们可以用一个小实验观察客户端一次性发送大量数据服务端每次 read 512 字节看它是否在单次事件里循环读完。用 Python 发一段 4KB 数据python3 -c import socket; ssocket.create_connection((127.0.0.1,9000)); s.sendall(bA*4096); import time; time.sleep(1); s.close()服务端应该一次性打印出 4096 个 A说明 while 循环把缓冲区读干净了。如果把代码里的 while 循环改成只 read 一次你会发现只打印 512 个 A剩下的数据在 ET 模式下不会再触发事件这就是漏读的典型表现。压测环节用 wrk 或 ab 都行。这里用 ab 做并发连接测试ab -n 10000 -c 500 http://127.0.0.1:9000/注意我们的服务端不是 HTTP 服务ab 会收到非 HTTP 响应而报错但连接建立和关闭的流程是完整的可以用来看服务端在高并发连接下的稳定性。观察服务端进程的 CPU 占用和内存500 并发下应该很平稳。更贴近实际的压测可以用一个简单的 Python 脚本模拟大量长连接import socket, time socks [] for i in range(2000): try: s socket.create_connection((127.0.0.1, 9000)) socks.append(s) except Exception as e: print(fail at, i, e) break print(connected, len(socks)) time.sleep(30) for s in socks: s.close()这个脚本建立 2000 个连接并保持 30 秒。你可以同时用cat /proc/pid/fd | wc -l查看服务端打开的 fd 数量应该接近 2000 加几个基础 fd。用top -p pid看 CPU空闲连接下 CPU 应该接近 0这正是 epoll 相对 select 的优势所在——空闲连接不消耗扫描成本。如果想对比 select 的表现可以把连接数继续往上加。select 版本在 fd 超过 1024 后会直接失败而 epoll 版本加到几千甚至上万都没问题具体上限看cat /proc/sys/fs/file-max。5. 常见报错排查从 401 到 epoll_ctl 失败这一节把开发 epoll 服务和接入 AI 工具时最常撞到的错误集中列一下方便对照。epoll_ctl: Operation not permitted或epoll_ctl: Bad file descriptor。前者通常是 fd 已经被关闭还在操作后者多半是 epoll_ctl 的第三个参数传了无效 fd。检查你的 fd 生命周期close 之后要从红黑树里删掉虽然 close 会自动移除但代码逻辑上别再用旧 fd。read: Resource temporarily unavailable。这个不是错误是 ET 模式下读到 EAGAIN 的正常返回。如果你的代码把它当错误处理并关闭连接就会误杀正常连接。正确做法是 errno 等于 EAGAIN 或 EWOULDBLOCK 时 break 退出读循环不关连接。accept: Too many open files。fd 耗尽。检查有没有连接关闭后没 close或者 ulimit 设得太低。用ulimit -n 65535临时提高永久生效要改 /etc/security/limits.conf。连接建立后收不到数据。ET 模式下最常见的原因是 socket 没设非阻塞导致 read 阻塞住整个事件循环。另一个原因是注册事件时漏了 EPOLLIN或者用了 EPOLLONESHOT 但没重新 MOD。epoll_wait 返回 0。说明超时了没有就绪事件。如果你传的 timeout 是 0会立即返回适合非阻塞轮询场景传 -1 会一直阻塞到有事件。别把 0 和 -1 搞混。接入 AI 工具时的报错也顺带说一下。401 Unauthorized一般是 Key 没填对或者过期去 https://taotoken.net/api-keys 重新生成一个。local proxy failed或连接超时检查 Base URL 是否填成了 https://taotoken.net/api注意结尾不要多加斜杠或路径。reading choices 相关报错通常是返回体解析失败多半是 Model ID 填错对照 https://taotoken.net/doc 确认模型名。OAuth 相关报错出现在 Claude Code 这类工具里说明认证方式选错了应该用 API Key 方式而不是 OAuth 登录。如果你用的是 Claude Code 做代码润色或补全接入时同样填 Base URL 加 Key 加 Model ID 三件套。配置写好后可以先发一个简单请求验证确认返回正常再开始正式编码。模型对话的在线验证入口在 https://taotoken.net/chat可以先用它确认 Key 和模型都通再去配本地工具。6. 把 epoll 服务端和 AI 工具链串起来写到这里epoll 的核心机制和可运行代码都过了一遍。回到实际开发场景你在写这类底层网络代码时往往需要 AI 工具帮你补全、解释内核结构体、或者生成压测脚本。这时候工具链的接入效率就很重要。我的做法是把所有支持自定义 Base URL 的工具都指向同一个 API 地址 https://taotoken.net/apiKey 用同一个模型按需选。这样不管是 Cline、Codex 还是 Claude Code配置逻辑一致换工具不用重新学一套认证流程。长期做编码和 Agent 任务的话Coding Plan 的额度模式比按次调用更划算具体在 https://taotoken.net/coding-plan 看。回到技术本身epoll 的 LT 和 ET 选择没有标准答案。业务代码优先 LT稳追求极致性能且能保证读干净的场景用 ET。红黑树加就绪链表这套设计本质是把「每次全量检查」换成「事件驱动加增量返回」这个思路在很多高性能系统里都能看到影子。把这份代码跑起来改改 ET 和 LT 对比一下行为比看十篇文章都管用。
返回列表