ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

epoll原理与实战:I/O多路复用、LT/ET与百万连接调优

epoll原理与实战:I/O多路复用、LT/ET与百万连接调优 epoll 这个词只要在 Linux 上写过网络服务基本都绕不过去。我第一次真正被它教育是在做一个长连接推送网关的时候——老方案用的是 select单机撑到八九百个连接就开始肉眼可见地发飘CPU 一大半烧在了无谓的轮询和 fd 集合的来回拷贝上。换成 epoll 之后同一台机器、同一套业务逻辑连接数直接拉到五位数CPU 反而更闲。那一次之后我才真正明白epoll 不是更快的 select它是另一套完全不同的设计哲学把我该问谁变成谁有事谁来告诉我。这篇内容我打算把它写透一点。不管你是刚看完 APUE 想上手写第一个 epoll 服务还是已经在生产环境里跑着几十万连接、被 ET 模式的各种怪异现象折腾过我都尽量把是什么为什么这么设计怎么写才对写错了会怎样四件事串起来讲。核心关键词 epoll 会贯穿始终但我不想写成一份 API 手册——手册官方就有我更像把自己这些年踩过的坑、翻过的内核源码、以及线上排障的经验摊开来讲。看完之后你至少应该能做到三件事能独立写出一个正确的 epoll 服务、能看懂 epoll 的行为为什么和你想的不一样、能在 fd 数上去之后知道该调哪些参数。1. 先搞清楚 epoll 在 I/O 模型里的位置1.1 从一连接一线程到 I/O 多路复用要讲 epoll得先讲清楚它要解决什么问题。最朴素的并发服务器写法是来一个连接就 fork 一个进程或者来一个连接就开一个线程代码写起来最直观read/write 都是阻塞的逻辑跟单连接版本没区别。但这套东西的扩展性是有硬上限的线程/进程本身要占内存内核调度它们也要开销线程一多上下文切换的成本会迅速吃掉你所有的 CPU。而且绝大多数长连接大部分时间是安静的——没有数据可读可线程还傻乎乎地阻塞在那里占着一个栈。I/O 多路复用就是为了对付这种连接多、活跃少的场景。它的核心思想是用一个线程同时盯着成千上万个 fd哪个 fd 有动静就处理哪个没动静的完全不占用 CPU。这就像前台不是给每个客户配一个专属服务员而是一个服务员站在大厅里谁举手就去谁那儿。select 是这套思路的第一代实现。它的接口长这样你把关心的 fd 塞进 fd_set 位图调 select内核帮你阻塞等待回来之后你遍历整个位图看哪些位被置上了。poll 是第二代把位图换成了 pollfd 数组解决了 fd 数量受 FD_SETSIZE通常是 1024限制的问题。但这俩有个共同的硬伤每次调用都要把整个 fd 集合从用户态拷进内核态内核还要线性扫描一遍。1000 个连接还行10 万个连接就是 10 万次拷贝加 10 万次扫描每来一批事件就重来一遍纯属浪费。epoll 就是在这个背景下出现的。它把注册和等待这两件事拆开了fd 只在 epoll_ctl 里注册一次之后内核自己维护一份清单epoll_wait 只管去取已经就绪的那几个不需要重新提交也没必要全量扫描。1.2 三代实现的差异一张表说清楚我平时给团队做分享的时候喜欢用一张表把三者的差别砸在屏幕上因为这几个维度基本决定了你在什么场景下该选谁对比维度selectpollepoll数据结构fd_set 位图pollfd 数组内核红黑树 就绪链表fd 数量上限FD_SETSIZE通常 1024仅受系统 fd 上限约束仅受系统 fd 上限约束每次调用是否拷贝全部 fd是是否只在 ctl 时处理单个 fd查找就绪 fd 的代价O(n) 遍历O(n) 遍历O(就绪数量)触发模式只有水平触发只有水平触发水平触发 边缘触发跨平台几乎全平台类 Unix仅 Linux这张表里最容易被误读的是最后那行。很多人以为 epoll 比 select 快就是因为红黑树比数组快其实不对。真正的差别在于工作量的量级select/poll 的每次调用开销正比于你监听了多少 fdepoll_wait 的开销只正比于这次有多少 fd 就绪。前者是 O(n)后者是 O(ready)。在一个 10 万连接、每秒只有 200 个活跃连接的服务器上这个差距是三个数量级。这里还要顺手纠正一个流传很广的说法网上经常能看到epoll 用了 mmap 共享内存所以省掉了拷贝。这是错的。epoll 的内核实现里没有 mmap就绪事件是从内核空间往用户空间正常拷贝的只是拷贝的量只跟本次就绪的事件数有关而不是跟注册总数有关。这类听起来很合理的错误结论我见过太多次所以特别提醒一句判断一个技术细节对不对最好去看内核源码或者 man 手册别信二手转述。1.3 epoll 不适合做什么有经验的人分享技术通常也会说清楚什么时候别用它。epoll 有三个场景是明确不适用的我在实际项目里都遇到过第一监听普通文件。epoll_ctl 对磁盘文件会直接返回 EPERM。原因是普通文件的可读永远是就绪状态除非到了 EOF注册进 epoll 只会得到一个永远触发的事件没有任何意义。磁盘 I/O 的瓶颈在于块设备的读写速度多路复用这套机制对它根本不适用——你要解决磁盘 I/O 的并发该用异步 I/O 或者线程池不是 epoll。第二连接数很少的场景。如果服务常年只处理十几个连接select 和 epoll 的性能差别你根本测不出来而 select 的代码更短、跨平台更好。别为了看起来先进而上 epoll。第三需要跨平台。epoll 是 Linux 独有的BSD/macOS 对应的是 kqueueWindows 是 IOCP而且 IOCP 是完全不同的完成通知模型不是简单的接口替换。如果你的服务要跑在多个平台老老实实用 libevent/libuv 这类封装库别自己写两套。2. 内核里 epoll 长什么样三个关键数据结构2.1 eventpoll一个 epoll 实例的总台账当你调用epoll_create1()的时候内核会创建一个struct eventpoll这就是你手里的那个 epoll fd 背后真正代表的东西。它的关键字段大致是这样的基于fs/eventpoll.c不同内核版本字段会略有增减但核心成员一直稳定struct eventpoll { struct mutex mtx; // 保护本结构的互斥锁 struct wait_queue_head wq; // epoll_wait 时挂上来的等待队列 struct wait_queue_head poll_wait; struct list_head rdllist; // 就绪链表所有有事件的 epitem struct rb_root_cached rbr; // 红黑树根所有被注册的 epitem struct epitem *ovflist; // 溢出链表见 2.3 节 struct user_struct *user; // 用于 max_user_watches 配额统计 struct file *file; // 对应的匿名 inode 文件 int visited; };我习惯把它理解成一个总台账rbr是登记簿谁被监听了rdllist是待办清单谁现在有事wq是值班室哪个线程在等着取活干。你在用户态拿到的那个 int 类型的 epfd本质上就是一个指向这个结构的文件描述符。这里有个细节值得说epoll_create返回的 fd 其实对应内核里的一个匿名 inode它不是网络 fd 也不是磁盘 fd所以你对它调 read/write 是没有意义的会失败。它的唯一用途就是当句柄传给 epoll_ctl 和 epoll_wait。理解这一点你就能明白为什么 epoll fd 也要记得 close——它同样占着系统 fd 配额。2.2 红黑树为什么不用哈希表存被监听的 fd被监听的 fd 集合用红黑树rbr来组织每个节点是一个struct epitem。看到这里很多人会问查找、插入、删除哈希表不都是 O(1) 吗红黑树只有 O(log n)为什么不用哈希这个问题我问过自己很久后来把几个维度的取舍捋清楚就懂了内存开销的可预期性。哈希表要么预先分配一大块桶数组要么做动态扩容。一个监听 100 万 fd 的服务如果哈希桶按 2 倍扩容扩容瞬间会申请一大块连续内存并做 rehash这在低延迟服务里是不可接受的抖动源。红黑树是按节点逐个分配没有某一次操作特别贵的问题延迟曲线很平。不需要哈希函数。fd 是整数看着很适合哈希但内核里要选一个够好的哈希函数、还要防哈希碰撞攻击成本和复杂度都不低。红黑树靠数值比较就行简单且稳定。需要有序遍历的场景。释放整个 epoll 实例时内核需要遍历所有注册项做清理树结构天然支持。删除时的定位需求。epoll_ctl 的 DEL/MOD 要按 (fd, file) 二元组精确定位节点树的查找路径确定、无聚集风险。所以这个选择不是内核作者懒得写哈希而是综合了延迟稳定性、内存分配模式和实现复杂度的结果。做技术选型的时候理论复杂度更优永远要让位于实际运行曲线更稳这一点在很多工程场景里都成立。顺带记一下红黑树的代价epoll_ctl 的 ADD/DEL/MOD 单次操作是 O(log n)。所以如果你的服务有高频注册注销短连接的特征比如每秒几万次的短连接接入epoll_ctl 的开销也要纳入考虑。这也是为什么有些超高性能的短连接代理会自己实现 fd 池尽量避免频繁 ctl。2.3 就绪链表与回调事件驱动真正的来源rdllist是一条双向链表里面挂的是当前已经有事件待处理的 epitem。epoll_wait 大部分时候做的第一件事就是看这条链表空不空——不空就立刻取走返回空就睡觉等通知。这就是 epoll 高效的核心。那链表里的节点是谁放进去的答案是回调。当你在 epoll_ctl 里 ADD 一个 socket fd 时内核会做一件关键的事通过 socket 的poll方法把一个回调函数ep_poll_callback注册到该 socket 的等待队列上。之后当网卡收到数据、协议栈把数据放进 socket 的接收缓冲区时socket 会唤醒自己等待队列上的所有等待者ep_poll_callback就被调用了它做的事很直接——把对应的 epitem 挂到rdllist顺便唤醒正睡在eventpoll-wq上的线程。这个机制带来的最大好处是事件是推送过来的不是扫描出来的。10 万个连接里只有 3 个有数据就只会有 3 次回调触发其余 99997 个连接完全不会被碰到。select 的模型下内核得老老实实扫完这 10 万个位。struct epitem的字段也值得看一眼struct epitem { union { struct rb_node rbn; // 挂进红黑树的节点 struct rcu_head rcu; }; struct list_head rdllink; // 挂进就绪链表的节点 struct epitem *next; // 溢出链表用 struct epoll_filefd ffd; // {fd, struct file *} int nwait; // 等待队列项数量 struct list_head pwqlist; // 挂在该 fd 等待队列上的 eppoll_entry struct eventpoll *ep; // 所属的 eventpoll struct epoll_event event; // 用户注册时传进来的 events 和 data };注意rdllink是个list_head内核用的是链表节点自挂的技巧当 epitem 不在就绪链表中时rdllink-next指向它自己。这样判断是否已在链表中只需要一句list_empty不需要额外的标志位也就避免了重复插入导致的链表成环。这种手法在内核里到处都是看懂了对读源码很有帮助。再说ovflist溢出链表。它解决的是一个很刁钻的并发问题当 epoll_wait 正在把就绪事件往用户态拷贝时如果此刻又有新事件回调进来理论上会修改正在被遍历的rdllist可能造成混乱。内核的做法是设置一个visited标记拷贝期间新来的事件先挂到ovflist上拷贝结束后再合并回rdllist。这个细节普通人写代码时感知不到但它解释了为什么 epoll_wait 在大规模场景下能同时保持高吞吐和正确性。2.4 eppoll_entry连接 epitem 与 fd 等待队列的桥除了 epitem还有一个辅助结构struct eppoll_entrystruct eppoll_entry { struct list_head llink; // 挂到 epitem-pwqlist struct epitem *base; // 反向指回 epitem wait_queue_entry_t wait; // 真正挂到 fd 等待队列上的节点 wait_queue_head_t *whead; // 该 fd 的等待队列头 };结构上epitem挂在 epoll 的红黑树上eppoll_entry挂在目标 fd 的等待队列上两者互相引用。这样一个 epitem 就能被它监听的那个 fd 反向找到回调触发时可以直接定位到该进哪个 epoll 的就绪链表。删除时的顺序也很讲究必须先从 fd 的等待队列上摘掉 eppoll_entry再把 epitem 从红黑树移除否则会出现回调访问已释放内存的问题。这一段在内核里对应ep_remove的逻辑有兴趣可以对着源码读一遍比看十篇博客都管用。3. epoll 三大接口的正确用法与参数细节3.1 epoll_create1从 epoll_create 到 EPOLL_CLOEXEC最早的原型是int epoll_create(int size)那个size参数其实从 Linux 2.6 开始就被忽略了内核只是要求它大于 0纯粹是历史包袱。现在应该用的是int epfd epoll_create1(EPOLL_CLOEXEC); if (epfd 0) { perror(epoll_create1); exit(EXIT_FAILURE); }EPOLL_CLOEXEC这个标志非常关键意思是当进程执行 exec 系列函数时这个 fd 会被自动关闭。为什么必须加因为服务端程序经常要 fork exec 去跑子进程比如执行外部命令、拉起工作进程如果不加这个标志epoll fd 会被子进程继承导致一个你以为已经关闭的 epoll 实例一直活着占着内存和 fd 配额严重的还会引起连接关了但资源没释放的问题。注意epoll_create1需要 Linux 2.6.27 及以上。如果你的代码要兼容更老的内核现在其实很少了可以用epoll_create(1)然后手动fcntl(fd, F_SETFD, FD_CLOEXEC)补上。不过现代部署环境基本不需要这个兼容了。3.2 epoll_ctlADD / MOD / DEL 的使用要点控制接口的原型是int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);op有三个值EPOLL_CTL_ADD注册新 fd、EPOLL_CTL_MOD修改已注册 fd 的监听事件、EPOLL_CTL_DEL注销。struct epoll_event的定义是这样的struct epoll_event { uint32_t events; // 事件掩码如 EPOLLIN | EPOLLET epoll_data_t data; // 用户数据通常是 fd 或指针 }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;这里有几个我在实际项目里反复强调的点第一data是个联合体只能用一个成员。最常见的选择是.data.fd fd简单直接。但如果你用 C 写更推荐.data.ptr conn直接指向连接对象回调里不用再去查表。我见过有人在同一个程序里一部分地方用 fd 一部分地方用 ptr结果排查了半天才发现是数据不一致——这种 bug 特别隐蔽。第二ADD 一个已经注册过的 fd 会返回 EEXIST。正确的做法是新连接用 ADD已存在的连接改事件用 MOD。如果你不区分代码在压力测试下一定会暴露问题。还有一种情况是 MOD 一个没注册过的 fd会返回 ENOENT同样要处理好。第三注册时传入的 events 会被完整保存但 EPOLLERR 和 EPOLLHUP 永远会被报告不管你注册没注册。这一点很重要后面讲问题排查时会展开。事件标志里最常用的几个标志含义使用要点EPOLLIN可读连接接入、数据到达都靠它EPOLLOUT可写只在发送缓冲区满时才需要关注EPOLLERR错误无需注册永远上报必须处理EPOLLHUP挂断无需注册永远上报必须处理EPOLLRDHUP对端关闭写端需 2.6.17比 read 返回 0 更早感知EPOLLET边缘触发见第 4 章用法和 LT 完全不同EPOLLONESHOT一次性触发一次后自动失效需重新 MODEPOLLEXCLUSIVE独占唤醒4.5缓解多进程 accept 惊群第四一个很容易忽视的习惯先epoll_ctl(DEL)再close(fd)。按理说 close 一个已注册的 fd内核会自动把它从 epoll 里摘掉确实如此——但有个前提没有任何其他引用指向同一个 file 描述符。如果你的 fd 被 dup 过或者被 fork 出来的子进程继承过close 并不会立刻触发注销结果就是 epoll_wait 可能返回一个已经失效的 fd 号你去操作它就会拿到 EBADF 或者更糟——操作到了另一个被复用的 fd 上。这种 bug 的排查成本极高所以养成显式 DEL 再 close的习惯代价只是多一次系统调用。3.3 epoll_waitmaxevents、timeout 与返回值的门道等待接口int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);三个参数各有讲究events是你提供的一个数组内核把就绪事件填进去。maxevents是数组容量必须大于 0否则返回 EINVAL。这个值设多大有讲究设太小一次取不完剩下的留到下次会多一轮系统调用设太大数组占内存而且单次系统调用里拷贝的量也大。我的经验值是 1024 或者 4096具体看你的活跃连接比例——高并发网关通常活跃比例不高1024 足够如果是个内部服务只有几十个连接但几乎全部活跃那设 64 都行。timeout单位是毫秒三种取值语义不同-1表示无限阻塞直到有事件适合纯事件驱动的服务0表示立即返回不管有没有事件配合其他 fd 做轮询时用正数表示最多等这么多毫秒适合需要做定时任务的场景很多人就用它来实现心跳检查、超时踢连接。返回值也要处理好大于 0 是就绪事件数等于 0 是超时timeout 到了但没事件等于 -1 是出错此时要看errno。errno EINTR是最常见的一种表示被信号打断了这种不算真错误正确处理是重新调用而不是退出程序。很多新手写的 epoll 循环没处理 EINTR一收到信号服务就挂了。还有一个经典错误events数组里的元素只有前ret个是有效的。我见过有人写成for (i 0; i maxevents; i)把上一次调用的残留数据也当成事件处理结果就是随机地处理到已经关掉的 fd各种诡异崩溃。正确的写法是for (i 0; i ret; i)。再补一句性能相关的epoll_wait的复杂度是 O(就绪数)不是 O(注册数)但注意这有个前提——如果就绪事件的量级本身很大比如 10 万连接里有 8 万都活跃那再快也快不到哪去瓶颈会转移到你的业务处理逻辑上。所以epoll 单机百万连接这个说法通常指的是 C10K/C100K 的连接保持场景不是 100 万个连接同时高压收发数据。3.4 一份可以直接跑的 LT 版 echo 服务光讲参数容易飘直接上代码。下面是一个水平触发LT模式的回显服务我刻意写得紧凑但完整你复制到 Linux 上编译就能跑#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include arpa/inet.h #include sys/socket.h #include sys/epoll.h #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static void handle_read(int epfd, int fd) { char buf[BUF_SIZE]; while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 简单回显写不完整也没关系LT 模式会再通知可写 ssize_t off 0; while (off n) { ssize_t w write(fd, buf off, n - off); if (w 0) { if (errno EINTR) continue; break; } off w; } } else if (n 0) { printf(peer closed, fd%d\n, fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); return; } else { if (errno EINTR) continue; if (errno EAGAIN || errno EWOULDBLOCK) return; // 读空了 perror(read); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); return; } } } int main(void) { int lfd socket(AF_INET, SOCK_STREAM, 0); int on 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); 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(8888); if (bind(lfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } listen(lfd, 511); set_nonblock(lfd); int epfd epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); printf(listening on 8888 ...\n); 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; uint32_t e events[i].events; // 错误和挂断必须优先处理否则容易死循环 if (e (EPOLLERR | EPOLLHUP)) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); continue; } if (fd lfd) { // 监听 fd 可读有新连接LT 模式下 accept 一次也行 // 但为稳妥起见循环 accept 到 EAGAIN while (1) { struct sockaddr_in cli; socklen_t len sizeof(cli); int cfd accept(lfd, (struct sockaddr *)cli, len); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno EINTR) continue; perror(accept); break; } set_nonblock(cfd); ev.events EPOLLIN; ev.data.fd cfd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev) 0) { perror(epoll_ctl add); close(cfd); } } } else { handle_read(epfd, fd); } } } close(lfd); close(epfd); return 0; }编译命令是gcc epoll_lt.c -o epoll_lt -Wall。这个版本虽然是 LT 模式但我在handle_read里也写了循环读因为配合非阻塞 fd 一起用是更稳的写法——读空就靠 EAGAIN 退出循环不会因为边缘触发的语义问题踩坑。有几个细节我要专门点一下都是代码里容易写错的地方accept 一定要循环。即使是 LT 模式如果同时来了 3 个新连接你只 accept 一次剩下的两个下次 epoll_wait 还会通知你LT 的特性保证了这点所以不会丢连接。但多一轮系统调用往返性能损失是实打实的。ET 模式下不循环 accept 就会真的丢连接因为 ET 不会再通知你。所以统一按循环写省心。写操作要处理部分写。write返回的字节数可能小于你要写的字节数尤其是在缓冲区快满的时候。上面代码里用 while 循环推进偏移量处理了这一点。如果你的发送数据量可能很大比如推送大文件正确做法是维护一个发送缓冲区写不完的部分挂上 EPOLLOUT等可写事件再继续。EPOLLERR/EPOLLHUP 要最先判断。如果你只判断 EPOLLIN忽略了一个已经出现 EPOLLHUP 的连接在 LT 模式下 epoll_wait 会像疯了一样不停地返回这个事件CPU 直接被打满。我在线上见过两次这种事故一次是别人写的代码一次是我自己年轻时写的。4. LT 与 ET一次把触发模式讲透4.1 水平触发与边缘触发在内核里到底差在哪这两个名字来自数字电路水平触发Level TriggeredLT看的是电平状态只要高电平还在就一直报边缘触发Edge TriggeredET看的是跳变瞬间只在状态从低变高的那一下报一次。放到 epoll 里具体表现为LT默认只要 socket 接收缓冲区里还有数据没被读完epoll_wait 每次都会把 EPOLLIN 事件报给你。你读了一半下次还报直到你读完为止。ET只有在不可读变成可读的那一次跳变时才报。报了之后如果你没读完剩下的数据就静静躺在缓冲区里epoll_wait 不会再通知你——直到有新数据到达状态再次发生跳变。内核实现上的差别也很清楚epoll_wait在把事件拷贝给用户态之后对于 LT 模式会重新调用一次该 fd 的 poll 方法检查状态如果还是就绪的就把它重新挂回rdllist对于 ET 模式则不会做这个重挂动作只有下一次真正的状态跳变触发ep_poll_callback才会重新入队。理解了这个机制很多玄学现象就有解释了。比如为什么 ET 模式下你的服务偶尔会卡住——数据明明来了却没人处理答案基本就是这个连接某次没读完。4.2 ET 必须配合非阻塞与循环读的真实原因ET 模式的黄金法则是两条fd 必须设为非阻塞读写必须循环到 EAGAIN。这两条不是最佳实践是不这么做就是错的。先说循环读。ET 只在状态跳变时报一次所以你在收到通知后必须把缓冲区里的数据全部读干净一直读到read返回 -1 且errno EAGAIN这代表现在确实没数据了。如果你只读了一次就停下假设缓冲区里有 100KB 数据你只读了 4KB那么剩下的 96KB 就永远躺在那里了——直到对端又发新数据触发下一次跳变。如果对端发完这 100KB 就等着你回应很常见的请求-响应模式那就是死锁你等对端发数据对端等你回响应。这个 bug 的可怕之处在于它在低负载、小数据量的测试环境下完全不会出现一上生产、数据稍微大一点就炸。再说非阻塞。原因在于循环读的退出条件。如果 fd 是阻塞的你在循环里读读到缓冲区空了的时候read会阻塞等待整个线程就卡在这个连接上了——本来你是想用它来服务上千个连接的结果被一个连接拖死。而有了非阻塞缓冲区空了read立刻返回 -1 EAGAIN循环就可以干净地退出。这两个设计是配套的拆开任何一个都不成立。同样的道理适用于 accept。ET 模式下的监听 fd收到 EPOLLIN 你必须循环 accept 到 EAGAIN否则并发来的第 2 个、第 3 个连接就丢了。我见过一个很隐蔽的版本代码里用if (cfd accept(...))只接一个压测时单线程发请求一切正常一上多线程并发就随机丢连接查了两天才定位到。4.3 写事件用 ET 时容易踩的坑ET 模式下的 EPOLLOUT 有个反直觉的地方可写是常态不可写才是异常。socket 刚建立的时候发送缓冲区基本是空的也就是一直可写。如果你在注册连接的时候顺手把 EPOLLOUT 也加上在 ET 模式下会立刻触发一次可写事件——然后你写完了缓冲区又空了但 ET 不会再报因为状态没有跳变它一直就是可写的。看起来没事但如果数据大、写了一半剩下的部分你等 EPOLLOUT 通知等来的可能是永远不来。正确的写法是默认只注册 EPOLLIN只有在 write 返回 EAGAIN发送缓冲区满时才用 epoll_ctl MOD 加上 EPOLLOUT等可写事件来了、数据写完了再 MOD 把 EPOLLOUT 去掉。// 需要写但缓冲区满时 if (errno EAGAIN || errno EWOULDBLOCK) { struct epoll_event ev; ev.events EPOLLIN | EPOLLOUT | EPOLLET; ev.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev); // 把剩余数据挂到该连接的发送队列等 EPOLLOUT } // 可写事件来了继续发 if (events[i].events EPOLLOUT) { int done flush_send_queue(conn); // 返回是否全部发完 if (done) { struct epoll_event ev; ev.events EPOLLIN | EPOLLET; // 去掉 EPOLLOUT ev.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev); } }这个动态开关 EPOLLOUT的习惯是 epoll 编程里提升性能最立竿见影的一招。原因是如果每个连接都常驻注册 EPOLLOUT在多线程或单线程的 epoll_wait 里所有连接都会持续报可写事件你会陷入什么都没干但 epoll_wait 一直返回的空转CPU 白烧。我做过对比测试一个 1000 连接的推送服务常驻 EPOLLOUT 时空转占了将近 70% 的 CPU 时间。另外提一句 EPOLLRDHUP。它在对端关闭写端发 FIN时触发比 read 返回 0 更早让你知道对端不会再发数据了。对于需要做半关闭half-close处理的协议非常有用比如 HTTP 里客户端发完请求就 shutdown 写端服务端处理完再关连接。如果你只是等 read 返回 0也没问题只是感知晚一点。5. 实战疑难排查与调优清单5.1 常见问题速查表这一节是我这些年处理 epoll 相关问题的经验汇总按现象整理成表方便你排查时直接对号入座现象大概率原因排查与修复CPU 100%epoll_wait 一直立即返回就绪事件没被消费LT 下没读完或只判断 EPOLLIN 忽略了 EPOLLERR/EPOLLHUP检查读循环是否有 EAGAIN 退出在所有事件分支里优先处理 ERR/HUPET 模式下数据偶尔丢失、请求卡住没有循环读到 EAGAIN或读缓冲区小于一次到达的数据量改成 while 读至 EAGAIN读缓冲区至少 4KB建议 16KBET 模式下随机丢新连接accept 没有循环到 EAGAIN循环 acceptepoll_wait 返回的 fd 操作时报 EBADFfd 被 close 但没 epoll_ctl DEL或者 fd 被复用养成先 DEL 再 close 的习惯日志里记录 fd 生命周期epoll_ctl 返回 EEXIST对已注册的 fd 调了 ADD新连接 ADD老连接 MOD用状态位区分epoll_ctl 返回 EPERM试图监听普通磁盘文件磁盘 I/O 不要走 epoll程序一收到信号就退出epoll_wait 没处理 EINTRif (ret 0 errno EINTR) continue;处理了 maxevents 个事件而非 ret 个遍历循环写成了 i maxevents改成i retfork 出的子进程莫名持有连接epoll fd 或连接 fd 没设 CLOEXEC所有 fd 都加 FD_CLOEXEC / EPOLL_CLOEXEC大量 TIME_WAIT 影响新连接主动关闭方未开启地址复用设 SO_REUSEADDR评估 tcp_tw_reuse这张表里我个人觉得最值得反复念叨的是前两条。epoll 的绝大多数疑难杂症追根到底都是没把就绪状态处理干净。你要在心里建立一个模型就绪是一种内核交给你的责任你有义务把它清掉读空或者写空否则 LT 下它会一直烦你ET 下它会让你丢数据。5.2 惊群、EPOLLONESHOT 与多线程收编当你的服务要利用多核时很自然会想到开多个线程每个线程一个 epoll 实例。但这里有几个坑。第一个坑是 accept 惊群。如果你让多个线程/进程监听同一个 listen fd新连接到来时所有等待者都会被唤醒但只有一个能 accept 成功其余白白被唤醒一次这就是惊群。Linux 4.5 引入的EPOLLEXCLUSIVE标志可以缓解给 listen fd 注册事件时加上它内核只会唤醒一个等待者。更彻底的方案是SO_REUSEPORT——每个线程创建自己独立的 listen socket 绑到同一端口内核在协议栈层面做负载均衡完全没有惊群而且线程之间零共享扩展性最好。我自己现在写多线程服务器基本都用 SO_REUSEPORT代码更简单性能也更好。第二个坑是一个连接被多个线程同时处理。如果用每个线程一个 epoll共享连接池的模型一个连接上的事件可能同时被两个线程取到然后两个线程同时 read 同一个 socket数据就乱了。EPOLLONESHOT就是为这个场景设计的注册时加上它事件触发一次后该 fd 的注册就自动失效处理线程处理完后需要显式用 MOD 重新注册才能接受下一次事件。这样就保证了一个连接在任一时刻只被一个线程持有。// 注册时 ev.events EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.ptr conn; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev); // 处理完毕交还 ev.events EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.ptr conn; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev);提示用了 EPOLLONESHOT 之后每一次处理完都必须重新 MOD包括读出错、需要关闭连接的路径也要考虑清楚否则连接会沉默在那里再也不响应。我见过因为漏了一次 MOD 导致连接彻底僵死的线上问题排查时完全看不出异常日志。不过说实话多线程模型的复杂度远高于单线程 epoll 工作线程池。如果业务逻辑本身不重比如只是做协议解析和转发单线程 epoll 配合 SO_REUSEPORT 开几个进程往往比精心设计的多线程 epoll 更稳、更好维护。不要把多线程当成性能的同义词架构复杂度本身也是成本。5.3 突破百万连接的参数与习惯最后聊聊大家最感兴趣的单机百万连接。这件事在技术上早就可行了难点不在 epoll 本身而在一堆配套的系统参数和编码习惯。我做过一个 C100K 级别的压测项目这里把关键清单列一下。系统层面要动的参数参数位置作用参考值文件描述符上限ulimit -n/limits.conf单进程能打开的 fd 数1048576系统级 fd 上限/proc/sys/fs/file-max全系统 fd 总量按内存估算epoll 监听配额/proc/sys/fs/epoll/max_user_watches单用户可注册的 fd 总数按需调大本地端口范围net.ipv4.ip_local_port_range主动建连时的源端口范围1024 65535somaxconnnet.core.somaxconnlisten backlog 上限65535tcp_max_syn_backlognet.ipv4.tcp_max_syn_backlog半连接队列长度65535这里特别说一下max_user_watches。它统计的是所有 epoll 实例注册的 fd 总数默认值跟内存相关在内存小的机器上可能只有几万。如果你是长连接服务这个值不调大注册到一半就会拿到 ENOSPC表现为连接能建立但加不进 epoll非常容易被误判成业务 bug。编码层面的习惯每个连接的内存占用要压到最低。100 万连接如果你给每个连接分配 4KB 的读写缓冲区光缓冲区就是 8GB。我的做法是接收缓冲区用一个全局的共享缓冲比如 16KB事件到达时读进共享缓冲、立刻处理完只有需要暂存数据的连接才分配独立缓冲而且按需增长、闲置时回收。这一条比任何 epoll 参数调优都更能决定你的连接上限。另外一个反直觉的经验连接数上去之后瓶颈经常不是 epoll而是你处理事件的方式。比如每个事件都写一条日志100 万连接的日志 IO 就能把你压垮比如每个事件都去查一次全局哈希表锁竞争就会成为热点。我在那次压测里最大的性能提升来自把日志从每事件同步写改成批量异步写单这一项就提升了 30% 多的吞吐。至于定时器很多人用epoll_wait(timeout)加一个全局定时器链表来管理心跳和超时。这个方案的坑在于精度timeout 设成 1000ms那所有超时检查的最小粒度就是 1 秒而且事件密集时 epoll_wait 频繁返回定时器检查反而不准。更稳的做法是用时间轮time wheel或者小顶堆把超时管理独立出来epoll_wait的 timeout 只用它该用的值——也就是距离下一个超时还有多久。我个人在这些年折腾 epoll 的过程中最大的体会是它本身的 API 就这么三个函数半天就能学会真正的门槛在于对事件这件事的心智模型——你要时刻清楚当前内核认为的就绪状态是什么以及你的代码有没有把这个状态消费掉。把这个模型建立起来之后ET 和 LT 的选择就不再是玄学而是根据业务特征做的工程决策需要极致性能和可控读写行为的场景用 ET追求代码简单和容错性的场景用 LT两者没有绝对的高下之分。
返回列表