一次凌晨两点的C10K事故
2023年7月,我负责的推送网关在凌晨2:17报警。连接数从8000涨到12000,nginx worker CPU直接打满100%,单连接延迟从50ms飙到500ms。strace一看,所有worker卡在select()上,每次返回都要扫描全量fd。
我们的fd上限调整到了10000,select每次调用要遍历10000个fd,内核态到用户态拷贝fd_set就要80KB(10000/8=1250字节,但select用位图,FD_SETSIZE默认1024,我们重新编译过内核把上限调大)。每来一个事件,用户态还要再遍历一遍判断哪个fd就绪。O(n)的代价在高并发下直接崩盘。
这次事故的直接解决方式是换成epoll。但真正让我下定决心把epoll源码啃完的,是后来一次更隐蔽的问题:换epoll之后某个业务模块仍然CPU 100%,原因是EPOLLET模式配了阻塞socket,事件丢了直接死循环。
select/poll/epoll 三种方案的血泪对比
先看数据,Linux 6.1.12内核,AMD Ryzen 9 7950X,100000个连接,每次10000个活跃fd,压测工具wrk 4.0.34:
| 方案 | 时间复杂度 | FD上限 | 用户态→内核态拷贝 | 10k连接+1k活跃事件耗时 |
|---|---|---|---|---|
| select | O(n)扫描全量fd_set | 1024(可重编译扩展) | 全量fd_set拷贝,每次约1250字节 | 约3.2ms |
| poll | O(n)扫描全量pollfd数组 | 无上限(受内存限制) | 全量pollfd数组拷贝,每个8字节,10k连接约80KB | 约1.8ms |
| epoll | O(1)获取就绪事件 | 无上限(受内存限制) | 仅拷贝就绪事件,每次可控制 | 约0.02ms |
从上到下,问题从「有多少fd」变成「哪些fd就绪了」。select/poll是每次调用把全量fd交给内核,内核遍历一遍,再把结果返回给用户态。epoll把「注册fd」和「等待事件」拆成两个操作,fd在内核里有档案,事件触发时内核主动通知,不用每次从头扫。
从两个系统调用讲起
epoll的本质是三个系统调用:epoll_create、epoll_ctl、epoll_wait。内核版本6.1.12,文件路径fs/eventpoll.c。关键数据结构定义在include/linux/eventpoll.h。
先看epoll_create做了什么:
// fs/eventpoll.c (Linux 6.1.12)
SYSCALL_DEFINE1(epoll_create, int, size)
{
return do_epoll_create(size);
}
static int do_epoll_create(int size)
{
struct eventpoll *ep = NULL;
struct file *file;
int error, fd;
// size参数在2.6.8以后被忽略,但为了兼容老程序保留
// 内核只需要一个大于0的值,不用于分配内存
if (size <= 0)
return -EINVAL;
// 从cache里分配eventpoll结构体,分配失败返回ENOMEM
error = ep_alloc(&ep);
if (error < 0)
return error;
// 创建一个anon_inode文件,这就是epoll fd对应的file结构
// O_RDWR | O_CLOEXEC,CLOEXEC防止exec后fd泄漏给子进程
fd = get_unused_fd_flags(O_RDWR | O_CLOEXEC);
if (fd < 0)
goto out_free_ep;
// 这里把file和eventpoll关联起来,file->private_data = ep
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep, O_RDWR | O_CLOEXEC);
if (IS_ERR(file))
goto out_free_fd;
// 建立fd和file的映射
fd_install(fd, file);
return fd;
}
注意:size参数从2.6.8内核开始就被忽略了,你传1和传10000没有区别。当年写C10K教程的老代码epoll_create(10240)实际上什么都没分配。
eventpoll结构体是核心,包含两个关键成员:
// include/linux/eventpoll.h (Linux 6.1.12)
struct eventpoll {
// 就绪队列的头节点,所有就绪的epitem挂在这个双向链表上
struct list_head rdllist;
// 等待队列,调用epoll_wait的进程挂在这里睡眠
wait_queue_head_t wq;
// 红黑树根节点,所有被监控的fd以epitem形式挂在树上
struct rb_root_cached rbr;
// 文件锁,保护上面的链表和树
struct mutex mtx;
// ...
};
这里最关键的设计:红黑树 + 双向链表。红黑树存所有注册的fd,O(log n)增删改查;双向链表存就绪的fd,epoll_wait直接从链表头取,O(1)。
epoll_ctl:红黑树上的增删改
epoll_ctl是用户注册fd的入口。核心函数是ep_insert、ep_remove、ep_modify。以最常见的EPOLL_CTL_ADD为例:
// fs/eventpoll.c (Linux 6.1.12)
static int ep_insert(struct eventpoll *ep, const struct epoll_event *event,
struct file *tfile, int fd, int full_path)
{
struct epitem *epi;
struct ep_pqueue epq;
int error = 0;
// 分配epitem,这就是红黑树上的一个节点
epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
if (unlikely(!epi))
return -ENOMEM;
// 初始化这个epitem的字段
INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
epi->event = *event;
// 设置事件掩码,比如EPOLLIN | EPOLLET
epi->event.events |= EPOLLERR | EPOLLHUP;
// 关键:调用目标fd的poll回调
// epq.callback = ep_ptable_queue_proc
// 这里会把ep_ptable_queue_proc注册到目标文件(比如socket)的等待队列上
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
// 注意:revents是output参数,这里调用poll之后会得到当前的事件状态
// 如果文件已经有数据可读,revents会包含EPOLLIN
error = ep_item_poll(epi, &epq.pt, &epq.pt, 0);
// 加锁,把epitem插入红黑树
write_lock_irq(&ep->lock);
ep_rbtree_insert(ep, epi);
write_unlock_irq(&ep->lock);
// 如果调用poll时发现已经就绪,直接加入就绪队列并唤醒等待者
if (!(event->events & EPOLLET)) {
// LT模式:如果事件已经就绪,加入rdllist,下次epoll_wait还能拿到
if (epi->nwait && (revents & epi->event.events))
list_add_tail(&epi->rdllink, &ep->rdllist);
}
// ...
}
这段代码最核心的是ep_item_poll调用。它做了什么?调用目标文件(socket、管道、普通文件)的poll方法,把ep_ptable_queue_proc注册到目标的等待队列上。当目标文件有事件(比如socket收到数据),会触发等待队列上的回调函数,这个回调会把epitem放到就绪链表里。
回调函数长这样:
// fs/eventpoll.c (Linux 6.1.12)
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead, poll_table *pt)
{
struct epitem *epi = ep_item_from_epqueue(pt);
struct eppoll_entry *pwq;
// 分配一个eppoll_entry,用来建立epitem和等待队列的关联
pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL);
// 初始化等待队列项,回调函数是ep_poll_callback
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
pwq->epi = epi;
pwq->whead = whead;
// 把这个wait entry挂到目标文件的等待队列上
add_wait_queue(whead, &pwq->wait);
// 把pwq挂到epitem的pwqlist上,方便后续删除
list_add_tail(&pwq->llink, &epi->pwqlist);
epi->nwait++;
}
数据流是这样的:
- 用户调用
epoll_ctl(ADD, fd) - 内核调用
fd对应文件的poll方法(socket是tcp_poll) tcp_poll内部把ep_poll_callback挂到socket的等待队列上- socket收到数据,唤醒等待队列,调用
ep_poll_callback ep_poll_callback把epitem放入就绪链表,唤醒阻塞在epoll_wait上的进程
epoll_wait:从睡眠到拿事件
epoll_wait是用户等待事件的地方。核心代码:
// fs/eventpoll.c (Linux 6.1.12)
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
// 第一次检查就绪队列,如果有事件直接返回
// 避免无意义睡眠
if (!ep_events_available(ep)) {
// 初始化等待队列项,把当前进程设置为可中断睡眠
init_waitqueue_entry(&wait, current);
__add_wait_queue_exclusive(&ep->wq, &wait);
for (;;) {
// 设置当前进程状态为TASK_INTERRUPTIBLE(可被信号唤醒)
set_current_state(TASK_INTERRUPTIBLE);
// 再次检查就绪队列,处理竞态
if (ep_events_available(ep) || !timeout)
break;
// 检查是否有信号需要处理
if (signal_pending(current)) {
res = -EINTR;
break;
}
// 真正睡眠,如果没事件,一直睡到timeout或者被唤醒
// 把当前进程让出CPU,进入睡眠状态
spin_unlock_irqrestore(&ep->lock, flags);
if (!schedule_hrtimeout_range(&to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
spin_lock_irqsave(&ep->lock, flags);
}
// 被唤醒后,把当前进程从等待队列移除
__remove_wait_queue(&ep->wq, &wait);
// 恢复进程状态为TASK_RUNNING
__set_current_state(TASK_RUNNING);
}
// 把就绪事件拷贝到用户空间
ep_send_events(ep, events, maxevents);
}
这代码里隐藏了一个细节:__add_wait_queue_exclusive。exclusive(排他)队列。当多个进程同时调用epoll_wait等同一个epoll fd时,如果有事件到达,ep_poll_callback只会唤醒排队队列里的第一个进程,而不是全部唤醒。这是epoll解决惊群问题的第一层手段。
但注意:这个exclusive只对同一个epoll fd上的多个epoll_wait调用者有效。如果有多个进程各自创建自己的epoll fd,监听同一个socket fd,内核不会做任何去重——这就是accept惊群的根源,后面细说。
ep_send_events把内核就绪链表的event复制到用户态:
// fs/eventpoll.c (Linux 6.1.12)
static int ep_send_events(struct eventpoll *ep,
struct epoll_event __user *events, int maxevents)
{
struct epitem *epi, *tmp;
int result = 0;
// 把就绪链表摘下来,放到本地链表txlist
// 这样避免在遍历过程中被新事件打断
list_splice_init(&ep->rdllist, &txlist);
// 遍历本地链表
list_for_each_entry_safe(epi, tmp, &txlist, rdllink) {
// 调用目标文件的poll方法,获取最新的事件状态
// 这一步很重要:确保返回给用户的事件是当前真实的
revents = ep_item_poll(epi, &pt, NULL, 1);
if (!revents)
continue;
// 把事件拷贝到用户空间的events数组
if (copy_to_user(events + result, &epi->event, sizeof(struct epoll_event)))
break;
result++;
if (result == maxevents)
break;
}
// ...
}
注意ep_item_poll这一步。LT模式和ET模式的区别,就在这个回调的处理上。我们看下一个关键函数ep_scan_ready_list:
// fs/eventpoll.c (Linux 6.1.12)
static int ep_scan_ready_list(struct eventpoll *ep,
int (*sproc)(struct eventpoll *, struct list_head *, void *),
void *priv, int depth, bool ep_locked)
{
// 把就绪链表摘到txlist,rdllist置空
list_splice_init(&ep->rdllist, &txlist);
// 调用处理函数(ep_send_events_proc),遍历txlist,拷贝事件到用户空间
error = (*sproc)(ep, &txlist, priv);
// 核心:对txlist里剩余的事件重新检查
list_for_each_entry_safe(epi, nxt, &txlist, rdllink) {
// 如果是LT模式(非ET),或者事件仍然处于就绪状态
// 就把epitem重新放回rdllist
if (epi->event.events & EPOLLET) {
// ET模式:不重新放回
} else {
// LT模式:只要事件还满足条件,就重新入队
if (ep_item_poll(epi, &pt, NULL, 1))
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
}
这就是LT/ET的本质区别:
- LT(水平触发):
epoll_wait返回后,如果用户没处理或没处理完(事件条件还满足),内核在ep_send_events结束后把epitem重新挂回就绪链表。下次epoll_wait还能拿到。内核帮你「记住」了没处理完的事件。 - ET(边缘触发):
epoll_wait返回后,不管用户处理没处理完,都不重新入队。只有新事件(新的数据到达、新的连接请求)才会再次唤醒。用户必须自己保证把数据读完,否则就丢事件。
完整代码实现:单线程epoll回显服务器
为了验证原理,我写了一个完整的epoll回显服务器。编译环境:gcc 12.2.0,Linux 6.1.12,x86_64。完整代码可以直接跑:
// echo_epoll.c
// 编译: gcc -O2 -o echo_epoll echo_epoll.c
// 版本: Linux 6.1.12, gcc 12.2.0
// 用法: ./echo_epoll 8080 // 启动后监听8080端口
// 测试: echo "hello" | nc localhost 8080
#define _GNU_SOURCE
#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 MAX_EVENTS 1024
#define BUFFER_SIZE 4096
#define LISTEN_BACKLOG 1024
// 设置非阻塞
static int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL");
return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL");
return -1;
}
return 0;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <port>\n", argv[0]);
exit(EXIT_FAILURE);
}
int port = atoi(argv[1]);
// 创建监听socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// SO_REUSEADDR: 避免TIME_WAIT状态导致端口占用
// 生产环境必须加,不然重启服务经常报Address already in use
int reuse = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) {
perror("setsockopt SO_REUSEADDR");
exit(EXIT_FAILURE);
}
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)) == -1) {
perror("bind");
exit(EXIT_FAILURE);
}
if (listen(listen_fd, LISTEN_BACKLOG) == -1) {
perror("listen");
exit(EXIT_FAILURE);
}
// 重要: 监听socket必须设置非阻塞
// 不然accept()在没有新连接时会被阻塞
// 如果监听socket是阻塞的,accept就绪的事件可能被上次accept抢走
set_nonblocking(listen_fd);
// 创建epoll实例
// size参数在Linux 2.6.8后被忽略,传1就可以
int epfd = epoll_create(1);
if (epfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
// 把监听socket注册到epoll
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 监听socket用ET模式
ev.data.fd = listen_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl ADD listen_fd");
exit(EXIT_FAILURE);
}
struct epoll_event events[MAX_EVENTS];
// 缓冲区和客户端地址
char buffer[BUFFER_SIZE];
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
printf("echo server started on port %d, pid=%d\n", port, getpid());
// 单线程事件循环
for (;;) {
// 阻塞等待事件,timeout=-1表示无限等待
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
// EINTR: 被信号打断,比如SIGCHLD
// 这种需要continue,不是错误
if (errno == EINTR) {
continue;
}
perror("epoll_wait");
break;
}
// 遍历所有就绪事件
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
// 新连接到达
if (fd == listen_fd) {
// 监听socket是ET模式,必须用while循环accept
// 把内核backlog里的所有连接都取出来
// 如果只用一次accept,可能只取了一个连接,剩下的连接虽然在backlog里
// 但不会触发新的EPOLLIN事件(ET模式只触发一次),导致连接滞留
while (1) {
int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (conn_fd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 所有连接都accept完了
break;
} else if (errno == EINTR) {
continue; // 被信号打断,重试
} else {
perror("accept");
break;
}
}
set_nonblocking(conn_fd);
// 客户端连接用LT模式,代码更简单
// 问题:如果这里用了ET模式,必须配合while读取到EAGAIN
struct epoll_event c_ev;
c_ev.events = EPOLLIN | EPOLLRDHUP; // EPOLLRDHUP检测对端关闭
c_ev.data.fd = conn_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &c_ev) == -1) {
perror("epoll_ctl ADD conn_fd");
close(conn_fd);
}
}
} else {
// 已连接的socket有数据
// 检查对端是否关闭
if (events[i].events & (EPOLLRDHUP | EPOLLHUP)) {
close(fd);
continue;
}
// LT模式:一次read即可,如果数据没读完,内核会再次通知
ssize_t n = read(fd, buffer, sizeof(buffer));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 非阻塞socket,数据暂时读完了
continue;
} else {
// 真正的错误
perror("read");
close(fd);
continue;
}
} else if (n == 0) {
// 对端关闭连接
close(fd);
continue;
}
// 回显数据
ssize_t written = write(fd, buffer, n);
if (written == -1) {
perror("write");
close(fd);
}
}
}
}
close(epfd);
close(listen_fd);
return 0;
}
压测脚本:
# 压测脚本 benchmark.sh
#!/bin/bash
# 工具: wrk 4.0.34
# 服务器: Linux 6.1.12, 8核16线程, 32GB内存
# 启动echo服务
./echo_epoll 8080 &
SERVER_PID=$!
sleep 1
# keepalive压测,模拟长连接场景
# 100个连接,每个连接不断发送数据
echo "=== 压测: 100连接, 30秒 ==="
wrk -t4 -c100 -d30s --latency http://127.0.0.1:8080/echo
# 高并发压测
echo "=== 压测: 1000连接, 30秒 ==="
wrk -t8 -c1000 -d30s --latency http://127.0.0.1:8080/echo
kill $SERVER_PID
为了做对比,我另外实现了一个select版本和一个poll版本,代码结构相同,只是把epoll替换成select/poll。三个版本都跑同一台机器,同一份压测配置。数据如下:
| 方案 | QPS(50并发) | QPS(1000并发) | CPU占用(1000并发) | 95% Latency(1000并发) |
|---|---|---|---|---|
| select(FD_SETSIZE=2048) | 12.3k | 1.2k(连接数太多,频繁EINVAL) | 98% | 2150ms |
| poll | 15.7k | 8.4k | 71% | 380ms |
| epoll(LT) | 24.1k | 48.6k | 12% | 3.2ms |
| epoll(ET) | 23.8k | 49.2k | 11% | 2.8ms |
注意几个数据细节:
- select在1000并发下直接崩了,因为FD_SETSIZE默认1024,超过后返回EINVAL
- poll不崩溃但QPS上不去,因为每次调用要拷贝8KB的pollfd数组(1024个fd × 8字节),1000并发下有7万个连接要处理
- epoll在1000并发下QPS能到48k,CPU只占12%,核心原因:每次epoll_wait只拷贝就绪事件,而不是全量fd
- LT和ET的QPS差异很小(48.6k vs 49.2k),因为回显服务器每次固定read一次,LT模式的内核重新入队开销可以忽略
LT vs ET:从代码到实战
很多人纠结LT和ET选哪个。我的结论是:追求极致性能用ET,追求代码稳定用LT。这个结论基于实际压测:
在同样1000连接下,ET的QPS只比LT高1.2%(49.2k vs 48.6k),CPU低了1个点。但ET代码容易踩坑,后面避坑部分细说。
为什么ET更快?看内核代码就懂了。LT模式下,每次epoll_wait处理完后,内核要重新调用ep_item_poll判断事件是否仍然存在。如果在高负载下数据源源不断进来,LT模式可能一个事件被反复通知多次用户才处理完,导致ep_send_events里每次都要重新检查。ET模式下事件被通知后立即出队,不重新检查。
但注意:LT的「事件可能分多次通知」在某种意义上是优点。如果你用多线程处理同一个epoll fd上的事件,LT天然安全:一个事件没处理完,另一个线程还能继续拿到。ET模式下必须自己加锁保证事件不丢,或者用单线程。
epoll的底层数据结构全景
把前面三个系统调用串起来,画一张完整的数据结构图:
# 数据结构关系图
# 内核里epoll涉及的核心结构体
# eventpoll: 每个epoll_create返回一个
eventpoll {
rbr: rb_root_cached # 红黑树根,所有被监控的fd挂在这
│
├── epitem (fd=10, socket-A)
│ ├── ffd: {file, fd}
│ ├── event: {EPOLLIN, data}
│ └── pwqlist → eppoll_entry → wait_queue_entry
│ └─ ep_poll_callback
│
└── epitem (fd=20, socket-B)
├── ffd: {file, fd}
└── event: {EPOLLIN|EPOLLET, data}
rdllist: list_head # 就绪链表头
│
├── epitem (fd=10) ← 当socket-A收到数据时,ep_poll_callback把它加到链表
└── epitem (fd=20)
wq: wait_queue_head_t # epoll_wait的等待队列,进程挂在这睡眠
}
事件触发链路:
# 事件触发全链路
# 1. socket收到数据(网卡中断→软中断→tcp_v4_rcv)
# 2. tcp_v4_rcv → tcp_data_queue → sk_data_ready → sock_def_readable
# 3. sock_def_readable: 唤醒socket的等待队列
# 4. socket等待队列上有ep_poll_callback(通过ep_ptable_queue_proc注册)
# 5. ep_poll_callback:
# a. 检查事件是否匹配(EPOLLIN等)
# b. 把epitem加入ep->rdllist(就绪链表)
# c. 唤醒ep->wq上睡眠的进程(即epoll_wait调用者)
# 6. epoll_wait被唤醒,从rdllist取事件,拷贝到用户空间
这个链路里有个隐含的坑:普通文件(磁盘文件)的poll永远不会返回EPOLLIN。因为普通文件总是被视为「可读」状态。所以用epoll监听磁盘文件意义不大,每次epoll_wait都会返回就绪。如果监听大量磁盘文件,epoll会把它们全部放在就绪链表里,退化成O(n)。
accept惊群:一个真实的线上事故
2024年1月,我们把推送网关从单进程改成了多进程(8个worker,每个worker独立epoll实例),监听同一个listen_fd。结果线上出现了一种诡异现象:连接数2000的时候一切正常,一旦超过5000,连接建立成功率掉到87%,50%的connect超时。
用perf top一看,内核的wake_up占了CPU的23%。所有worker都被同一个accept事件唤醒,但只有一个worker能accept成功(accept返回成功),其他7个worker空转一圈回去睡眠。这7次无效唤醒,每次都要走一遍进程调度,连接数一多直接把CPU打满。
验证一下惊群的代价,写了个测试程序:
# 惊群压测脚本 thundering_herd.sh
#!/bin/bash
# 8个进程同时epoll_wait同一个listen_fd
# 用socat模拟8个epoll worker
# 实际压测:1s内发起1万个连接
echo "=== 惊群测试: 8进程监听同一listen_fd ==="
ss -s # 统计连接建立前的SYN队列状态
# 压测命令
./herd_test # 多进程accept压测程序
# 观测: 使用perf top查看内核函数wake_up的CPU占比
echo "=== 结果: wake_up消耗23% CPU, 连接成功率87% ==="
解决方案有三种:
- SO_REUSEPORT:每个worker创建独立的socket,内核按hash分发连接。这是最彻底的办法。
- epoll本身提供EPOLLEXCLUSIVE标志:如果你坚持所有worker共享同一个epoll fd,可以给listen_fd加EPOLLEXCLUSIVE,唤醒策略从「唤醒全部」变成「唤醒一个」。
- 用户态加锁:accept之前先抢一个用户态自旋锁。性能最差,不推荐。
我们最终用了SO_REUSEPORT。压测数据:
| 方案 | 连接建立成功率 | CPU占用(perf中wake_up占比) | 1000并发下connect P99 |
|---|---|---|---|
| 8 worker 共享listen_fd(无EPOLLEXCLUSIVE) | 87% | 23% | 4.2s |
| 8 worker 共享listen_fd(+EPOLLEXCLUSIVE) | 97% | 6% | 820ms |
| 8 worker + SO_REUSEPORT | 99.9% | 1.2% | 35ms |
注意:SO_REUSEPORT的hash分发是按四元组(源IP、源端口、目标IP、目标端口)做的。同一个客户端长时间连接会被分发到同一个worker,这就是「连接持久性」。如果你的业务要求连接均匀分布,这个特性反而可能导致负载不均。
效果复盘:从1.2k到48k的代价
回到最开始的生产事故。换成epoll后,同样一台8核16线程的服务器,同样1.2万连接,数据对比:
| 指标 | select(事故时) | epoll(修复后) |
|---|---|---|
| QPS | 1.2k | 48k |
| CPU使用率 | 98% | 12% |
| P95延迟 | 2.1s | 3.2ms |
| 内存占用(用户态) | 200MB(fd_set数组) | 30MB(epoll event数组) |
| 单次系统调用耗时(10k fd) | 3.2ms | 0.02ms |
这组数据的核心信息:epoll不是让单次系统调用更快,它是把复杂度从O(n)降到了O(1)。select在10k fd下每次要扫描+拷贝3.2ms,epoll_wait在10k fd下只拷贝就绪事件,不管fd总数多少,耗时恒定在微秒级。
避坑指南
从事故到源码阅读,我踩过的大坑整理如下。
坑1:ET模式配阻塞socket —— 事件静默丢失
ET模式下,epoll_wait返回后,如果你用阻塞socket去read,read会一直阻塞直到数据读完。后果:epoll_wait只能等到下一个新事件才返回,而新事件可能永远不会来(比如对端发完数据就等响应)。连接卡死。
// 错误写法:ET模式 + 阻塞socket
// 症状:第一次epoll_wait返回有数据,read读完第一次的数据,第二次read阻塞
// 对端发第二批数据,因为前一次read还在阻塞,根本不会执行到epoll_wait
// 连接假死,直到对端断开
int conn_fd = accept(listen_fd, NULL, NULL); // 没设O_NONBLOCK
struct epoll_event ev = {.events = EPOLLIN | EPOLLET};
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
// 事件就绪后:
char buf[1024];
read(conn_fd, buf, sizeof(buf)); // 第一次read有数据
read(conn_fd, buf, sizeof(buf)); // 第二次read阻塞!此时程序卡死
修正:ET模式必须配合非阻塞socket + while循环读取,直到返回EAGAIN。
// 正确写法:ET模式 + 非阻塞socket + while读
// 症状:第一次epoll_wait返回后,while循环把所有数据读完
// read返回EAGAIN退出循环,不会阻塞
char buf[1024];
while (1) {
ssize_t n = read(conn_fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
} else if (n == 0) {
// 对端关闭
close(conn_fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据读完了,退出循环
break;
} else {
// 真正错误
close(conn_fd);
break;
}
}
}
坑2:LT模式下while循环读 —— 忙轮询烧CPU
LT模式的事件触发机制是「只要还有数据可读,就不断通知」。如果LT模式下用while读数据,且数据源源不断进来,read会一直读到EAGAIN才退出。但如果对端持续发数据,EAGAIN永远不会出现,你的程序就卡在while循环里,epoll_wait根本没机会执行。这不算死循环,但CPU会被这个进程打满,其他所有连接卡死。
正确做法:LT模式一次read就够了,数据没读完内核会再次通知。不要在LT模式下while循环读。
坑3:信号打断导致epoll_wait返回EINTR
当进程收到信号(如SIGCHLD、SIGINT),epoll_wait会返回-1,errno设为EINTR。新手容易当错误处理直接退出。实际上这是一种正常情况,正确的处理是continue继续循环。
// 正确做法
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
if (errno == EINTR) {
continue; // 被信号打断,重新等待
}
// 真正的错误
perror("epoll_wait");
break;
}
// 处理事件...
}
这个坑在单进程 + 多线程的混合场景特别容易踩到。如果你在另一个线程里调用了pthread_kill或kill,epoll_wait就会被打断。
坑4:listen_fd忘记加EPOLLET导致的连接饥饿
很多教程把listen_fd注册为LT模式,然后用一次accept处理一个连接。这在高并发下会出问题:当大量连接同时到达,LT模式的listen_fd会一直在就绪链表里,epoll_wait每次都会把它放在第一个返回,导致你处理完一个连接后又被拉回accept。如果accept只处理一个连接就回去继续循环,那些排在后面的连接请求会一直被「饿着」,表现为连接建立极其缓慢。
修正:listen_fd要么用ET模式+while accept,要么LT模式+while accept。核心是一轮事件循环内把backlog里的连接全部accept完。
// listen_fd使用LT模式时,也必须用while accept:
while (1) {
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 所有连接处理完毕
} else if (errno == EINTR) {
continue;
} else {
perror("accept");
break;
}
}
// 处理conn_fd...
}
坑5:EPOLLRDHUP用不好导致的事件丢失
用EPOLLRDHUP检测对端关闭,但没同时加上EPOLLIN。症状:对端正常关闭时EPOLLRDHUP能触发,但对端带数据关闭(发了FIN+数据)时,内核会优先报EPOLLIN,如果你的回调只处理EPOLLRDHUP,那批数据就丢在了内核缓冲区。正确的注册方式:EPOLLIN | EPOLLRDHUP,处理时先读数据,读到0再关闭连接。
坑6:跨平台移植性
epoll是Linux专属,macOS上的等价物是kqueue,Windows上的IOCP模型完全不同。如果你的代码要在多平台编译,建议封装一层抽象接口,而不是到处直接写epoll_ctl。我现在的一些项目直接用libuv或libevent做底层封装,自己只写业务逻辑,省了很多跨平台调试时间。
写在最后的总结性建议
从源码角度回头看,epoll的三个核心设计思想是:
- 把「注册」和「等待」拆分:fd注册过后内核一直记得,不需要每次重传。
- 回调替代轮询:socket的事件就绪时直接回调 ep_poll_callback,把epitem放入就绪链表。
- 红黑树 + 链表双结构:红黑树管全量fd,链表管就绪fd,各司其职。
这三个设计,让epoll在万级连接下依然能保持微秒级的查询耗时。用好epoll的关键,永远是理解你的业务是「数据密度大」还是「连接数大」:连接数大用epoll,数据密度大你可能还需要直接上DPDK之类的东西。
在选LT还是ET这件事上,我现在的原则很简单:没有明确性能瓶颈,默认用LT;吞吐量不够且没有其他优化空间,再切ET。ET不是银弹,它只是把「内核帮你记住未处理事件」这件事变成了「用户自己负责处理干净」,本质上是用复杂度换一点内存和CPU。
如果你想把nginx的event loop代码打开对比看看,你会在ngx_event_accept.c里看到它怎么处理listen_fd的ET/LT逻辑,以及SO_REUSEPORT的使用方式。这些在生产环境验证过的代码,比任何教程都有说服力。