Linux epoll源码拆解:从accept到惊群
发布日期: 2026/08/15 阅读总量: 0

一次凌晨两点的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活跃事件耗时
selectO(n)扫描全量fd_set1024(可重编译扩展)全量fd_set拷贝,每次约1250字节约3.2ms
pollO(n)扫描全量pollfd数组无上限(受内存限制)全量pollfd数组拷贝,每个8字节,10k连接约80KB约1.8ms
epollO(1)获取就绪事件无上限(受内存限制)仅拷贝就绪事件,每次可控制约0.02ms

从上到下,问题从「有多少fd」变成「哪些fd就绪了」。select/poll是每次调用把全量fd交给内核,内核遍历一遍,再把结果返回给用户态。epoll把「注册fd」和「等待事件」拆成两个操作,fd在内核里有档案,事件触发时内核主动通知,不用每次从头扫。

从两个系统调用讲起

epoll的本质是三个系统调用:epoll_createepoll_ctlepoll_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_insertep_removeep_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++;
}

数据流是这样的:

  1. 用户调用epoll_ctl(ADD, fd)
  2. 内核调用fd对应文件的poll方法(socket是tcp_poll
  3. tcp_poll内部把ep_poll_callback挂到socket的等待队列上
  4. socket收到数据,唤醒等待队列,调用ep_poll_callback
  5. 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.3k1.2k(连接数太多,频繁EINVAL)98%2150ms
poll15.7k8.4k71%380ms
epoll(LT)24.1k48.6k12%3.2ms
epoll(ET)23.8k49.2k11%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% ==="

解决方案有三种:

  1. SO_REUSEPORT:每个worker创建独立的socket,内核按hash分发连接。这是最彻底的办法。
  2. epoll本身提供EPOLLEXCLUSIVE标志:如果你坚持所有worker共享同一个epoll fd,可以给listen_fd加EPOLLEXCLUSIVE,唤醒策略从「唤醒全部」变成「唤醒一个」。
  3. 用户态加锁: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_REUSEPORT99.9%1.2%35ms

注意:SO_REUSEPORT的hash分发是按四元组(源IP、源端口、目标IP、目标端口)做的。同一个客户端长时间连接会被分发到同一个worker,这就是「连接持久性」。如果你的业务要求连接均匀分布,这个特性反而可能导致负载不均。

效果复盘:从1.2k到48k的代价

回到最开始的生产事故。换成epoll后,同样一台8核16线程的服务器,同样1.2万连接,数据对比:

指标select(事故时)epoll(修复后)
QPS1.2k48k
CPU使用率98%12%
P95延迟2.1s3.2ms
内存占用(用户态)200MB(fd_set数组)30MB(epoll event数组)
单次系统调用耗时(10k fd)3.2ms0.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_killkillepoll_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的三个核心设计思想是:

  1. 把「注册」和「等待」拆分:fd注册过后内核一直记得,不需要每次重传。
  2. 回调替代轮询:socket的事件就绪时直接回调 ep_poll_callback,把epitem放入就绪链表。
  3. 红黑树 + 链表双结构:红黑树管全量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的使用方式。这些在生产环境验证过的代码,比任何教程都有说服力。