Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.10 socket 与 epoll 的内核协作

本章回答

一个 fd 到最终的网络连接,中间有哪几层结构?read 阻塞时进程睡在哪,数据到了谁负责叫醒它?epoll 为什么能做到「只取就绪的 fd」,它到底省掉了什么?LT 和 ET 在内核里差几行代码?EPOLLONESHOT 为什么是多线程的必需品?

读完你会

画出 fd → file → socket → sock 的四层结构;说出阻塞和 epoll 两种等待方式在等待队列上的区别;用 bpftrace 统计 ep_poll_callback 的触发次数;写一个正确的 ET 模式 epoll 服务器并解释每一处循环的用意。

4.8 和 4.9 讲的是「包怎么进出」。但包最终要交给应用,应用要能「等」它。这一章讲的就是这个「等」的机制:从最简单的阻塞 read,到 select/poll,再到 epoll。它们解决同一个问题——怎么让一个线程同时等多个 fd——但代价差了几个数量级。

理解 epoll 的关键不是记住 API,而是理解它的数据结构:一棵红黑树存「我要监听谁」,一个就绪链表存「谁已经就绪」,一组回调把「socket 有数据」翻译成「把 epitem 挂到链表上」。这三样东西一摆出来,「epoll 为什么快」就不再是玄学。

这一章也是第 5 篇的铺垫。容器里的网络代理、服务网格的 sidecar、Nginx/Redis/Envoy 的事件循环,全都建立在这一套机制上。看懂 ep_poll_callback,再看那些框架的源码就不会迷路。

4.10.1 四层结构:fd、file、socket、sock

一个网络 fd 在内核里对应四层结构,每一层的职责不同:

用户态            内核态
───────           ────────────────────────────────────────────────
int fd  ────────▶ struct file          (VFS 层:文件操作表 f_op)
                     │ private_data
                     ▼
                  struct socket        (socket 层:proto_ops)
                     │ sk
                     ▼
                  struct sock          (协议层:tcp_prot / udp_prot)
                     ├─ sk_receive_queue   收到的数据
                     ├─ sk_write_queue     待发的数据
                     ├─ sk_wq              等待队列(阻塞/epoll 都挂这里)
                     └─ sk_state            TCP 状态

读这张图要抓住「两次分派」。第一次是 file->f_op:read/write/poll 这些通用操作被分派到 socket 层的 sock_read_iter/sock_write_iter/sock_poll。第二次是 socket->ops(proto_ops):socket 层再按协议分派到 inet_stream_ops(TCP)或 inet_dgram_ops(UDP)。

为什么要分两层?因为 socket 是「协议无关的通用接口」,sock 是「协议相关的实现」。AF_UNIX、AF_INET、AF_NETLINK 都有自己的 proto_ops,但共享同一套 fd 和 VFS 接口。这就是为什么 read 一个 socket 和一个普通文件的调用形式完全一样。

结构定义位置关键字段作用
struct fileinclude/linux/fs.hf_op、private_dataVFS 抽象,fd 指向它
struct socketinclude/linux/net.hops、sk、flags协议无关的 socket 接口
struct sockinclude/net/sock.h队列、等待队列、状态协议相关的连接状态
struct tcp_sockinclude/linux/tcp.hsnd_una、rcv_nxt、cwndTCP 专有字段(嵌在 sock 里)

tcp_sock 是嵌在 sock 里的,通过 tcp_sk(sk) 宏转换。这种「继承」手法在内核里很常见:基础结构放在前面,派生结构在后面追加字段。

4.10.2 socket() 的创建路径

__sys_socket
  → sock_create               # 安全钩子、协议族检查
    → __sock_create
      → net_families[AF_INET]->create = inet_create
        → sk_alloc(net, PF_INET, GFP_KERNEL, &tcp_prot, ...)   # 分配 struct sock
        → sock_init_data(sock, sk)                             # 绑定 socket 与 sock
        → sock->ops = answer->ops                              # inet_stream_ops
  → sock_map_fd               # 分配 fd,绑定 file
    → get_unused_fd_flags
    → sock_alloc_file → alloc_file_pseudo(fd, socket_file_ops)

四步:分配 sock、绑定 socket、选 proto_ops、分配 fd。listen 之前,这个 socket 还没有端口;bind 才把地址写进 sk,listen 才把它挂进监听哈希。

socket() 本身不涉及网络,只是分配结构。这也是为什么创建一个 socket 很快(几微秒),而 connect 慢(至少一个 RTT)。

4.10.3 阻塞等待:睡在 sk_wq 上

当应用对一个阻塞 socket 调用 read,而接收队列为空时,内核会做三件事:

/* net/socket.c → sock_recvmsg → tcp_recvmsg(简化) */
int tcp_recvmsg_locked(struct sock *sk, struct msghdr *msg, ...)
{
    while (copied < len) {
        skb = tcp_recv_skb(sk, seq, &offset);
        if (skb) { /* 有数据,拷贝到用户态 */ }
        else {
            if (copied) break;
            if (flags & MSG_DONTWAIT) { copied = -EAGAIN; break; }

            /* 关键:把当前进程挂到 sk_wq 上,然后让出 CPU */
            sk_wait_data(sk, &timeo, last);
            /* 被唤醒后回到循环开头,重新检查队列 */
        }
    }
}

sk_wait_data 内部调用 prepare_to_wait + schedule_timeout:

  1. 构造一个 wait_queue_entry,把当前进程的 task_struct 挂上去。
  2. 设置进程状态为 TASK_INTERRUPTIBLE。
  3. 调用 schedule() 让出 CPU。
  4. 被唤醒后,从 sk_wq 上摘掉自己,返回。

「重新检查队列」是这段代码的灵魂。唤醒不等于有数据(可能被信号打断,也可能是虚假唤醒),所以必须回到循环开头再判断一次。这个模式叫「条件变量的标准用法」,和用户态的 pthread_cond_wait 完全一样。

4.10.4 数据到达:谁来唤醒

数据到达时,tcp_v4_rcv → tcp_data_queue 把 skb 入队,然后调用 sk->sk_data_ready(sk)。这个函数指针在 sock_init_data 时被设成 sock_def_readable:

/* net/core/sock.c(简化) */
void sock_def_readable(struct sock *sk)
{
    struct socket_wq *wq;
    rcu_read_lock();
    wq = rcu_dereference(sk->sk_wq);
    if (skwq_has_sleeper(wq))
        wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI | EPOLLRDNORM);
    sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);   /* SIGIO */
    rcu_read_unlock();
}

wake_up_interruptible_sync_poll 会遍历 wq->wait 上的每个 wait_queue_entry,调用它的 func。这就是 epoll 接入的地方:epoll 注册的回调是 ep_poll_callback,它不唤醒进程,而是把 epitem 挂到就绪链表上。

所以同一个 sk_data_ready,对不同等待方式有不同的效果:

等待方式挂在 sk_wq 上的回调唤醒后发生什么
阻塞 readautoremove_wake_function进程被唤醒,重新检查队列
select/pollpollwake唤醒调用 select 的进程
epollep_poll_callback把 epitem 挂到 rdllist,唤醒 epoll_wait
信号驱动sock_wake_async发 SIGIO

这张表解释了 epoll 的第一个优势:它不唤醒进程,只挂一个节点。1000 个 fd 就绪时,阻塞模型要唤醒 1000 个进程(或一个进程 1000 次),epoll 只往链表上挂 1000 个节点,等 epoll_wait 一次性取走。

4.10.5 epoll 的三个数据结构

epoll 的红黑树、就绪链表与回调 epoll_ctl 注册 epitem;事件发生时回调把 epitem 挂到 rdllist;epoll_wait 取链表。 USERKERNELepoll_create1()返回 epfdepoll_ctl(ADD)注册 fd + eventepoll_wait()阻塞等待就绪链表epfd → eventpoll红黑树 + 就绪链表struct eventpollrbr 红黑树 / rdllist 就绪链struct epitem每个 fd 一个等待队列epoll_wait 睡眠在这里socket wait queuesk_wq / poll 回调就绪回调ep_poll_callbackfd 表注册事件就绪 → 挂入 rdllist唤醒 epoll_waitepoll 不是「轮询所有 fd」:fd 注册一次,事件由回调挂到就绪链表,epoll_wait 只取链表
读图:先看右侧内核:每个被监听的 fd 对应一个 epitem,挂在红黑树上;socket 的等待队列注册 ep_poll_callback。数据到达时回调把 epitem 挂到就绪链表并唤醒 epoll_wait。因此 epoll 的复杂度与「就绪 fd 数」相关,而不是「监听 fd 总数」——这是它能撑百万连接的核心。

读这张图要抓住三样东西。红黑树 rbr 存所有被监听的 fd(epitem),支持 O(log n) 的增删查。就绪链表 rdllist 存已经就绪的 epitem,epoll_wait 只从这个链表取。等待队列 是 epoll_wait 自己睡的地方,和 socket 的 sk_wq 不是同一个。

/* fs/eventpoll.c(简化) */
struct eventpoll {
    struct rb_root  rbr;          /* 红黑树:所有监听的 fd */
    struct list_head rdllist;     /* 就绪链表:已经就绪的 epitem */
    struct wait_queue_head wq;    /* epoll_wait 睡在这里 */
    struct file *file;
    unsigned int napi_id;
    ...
};

struct epitem {
    struct rb_node  rbn;          /* 挂在红黑树上 */
    struct list_head rdllink;     /* 挂在就绪链表上 */
    struct epoll_filefd ffd;      /* 哪个 file 的哪个 fd */
    struct eventpoll *ep;         /* 属于哪个 epoll 实例 */
    struct epoll_event event;     /* 用户关心的事件掩码 */
    struct list_head pwqlist;     /* 挂在 socket 等待队列上的节点 */
};

关键在 epitem 同时属于两个容器:红黑树(长期)和就绪链表(临时)。一个 fd 可以同时在两边,这就是「注册一次、反复就绪」的实现方式。

4.10.6 epoll_ctl:注册与挂载

epoll_ctl(EPOLL_CTL_ADD) 的内核路径:

epoll_ctl
  → ep_insert(ep, &epds, tf.file, fd, full_check)
    → 分配 epitem,初始化
    → ep_item_poll(epi, &pt, 1)          # 先查一次:现在就绪吗?
    → ep_ptable_queue_proc               # 注册回调
      → init_waitqueue_func_entry(&pwq->wait, ep_poll_callback)
      → add_wait_queue(whead, &pwq->wait)   # 挂到 socket 的 sk_wq
    → 如果现在就绪,挂到 rdllist

注意 ep_item_poll 在注册时就会调用一次 vfs_poll。如果 fd 已经就绪(比如监听 socket 已有连接),它会立刻被挂到 rdllist,不需要等下一次事件。这就是为什么 epoll_ctl 之后 epoll_wait 能立刻返回。

ep_ptable_queue_proc 是 vfs_poll 的回调。不同的 fd 类型(socket、pipe、eventfd)在 poll 实现里会调用 poll_wait(file, &wq, pt),把 ep_ptable_queue_proc 挂到自己的等待队列上。所以 epoll 能监听任何支持 poll 的 fd,不只是 socket。

4.10.7 ep_poll_callback:就绪回调

数据到达时,sock_def_readable → wake_up → 遍历等待队列 → 调用 ep_poll_callback:

/* fs/eventpoll.c(简化) */
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
    struct epitem *epi = ep_item_from_wait(wait);
    struct eventpoll *ep = epi->ep;

    /* 1. 把用户关心的事件位与就绪位取交集 */
    if (key && !(key_to_poll(key) & epi->event.events))
        return 0;                       /* 不关心的事件,忽略 */

    /* 2. 如果已经在就绪链表上,不重复挂 */
    if (!ep_is_linked(&epi->rdllink))
        list_add_tail(&epi->rdllink, &ep->rdllist);

    /* 3. 唤醒 epoll_wait 睡的进程 */
    if (waitqueue_active(&ep->wq))
        wake_up(&ep->wq);
    return 1;
}

三步,没有任何遍历。这就是 epoll 的 O(1):事件发生时,内核知道是哪个 epitem(回调参数里带着),直接挂链表,不需要扫描所有监听的 fd。

第 2 步的「不重复挂」很重要。如果同一个 fd 连续来数据,回调会被调用多次,但 epitem 只在链表上出现一次——epoll_wait 取走之前不会重复。这也是 LT 模式能工作的基础:只要链表上还有它,每次 epoll_wait 都会返回它。

定义 4.10.1 · 水平触发与边缘触发

水平触发(level-triggered, LT):只要 fd 处于就绪状态(缓冲区有数据),每次 epoll_wait 都返回它。边缘触发(edge-triggered, ET):只在状态变化时返回一次(从无数据到有数据),之后即使还有数据也不再返回,除非又有新数据到达。ET 要求应用一次读到 EAGAIN,否则会丢事件。

4.10.8 epoll_wait:从链表取就绪项

epoll_wait 的逻辑是一个循环:

epoll_wait
  → ep_poll(ep, events, maxevents, timeout)
    ├─ 如果 rdllist 非空:ep_send_events → 拷贝到用户态
    ├─ 如果为空且 timeout != 0:睡在 ep->wq 上
    └─ 被唤醒后重新检查 rdllist

ep_send_events 对每个就绪的 epitem 调用 ep_item_poll,重新确认它是否真的就绪(因为从挂链表到取走之间,数据可能已经被别的线程读走)。确认后把事件拷贝到用户缓冲区。

这个「重新确认」是 LT 和 ET 分叉的地方:

  • LT:确认就绪后,如果用户没有一次读完,epitem 会重新挂回 rdllist(在 ep_send_events_proc 里,如果 revents 仍有就绪位且不是 ET,就 list_add_tail 回去)。下次 epoll_wait 立刻返回。
  • ET:只报告一次,不重新挂。除非 socket 上又有新数据触发 ep_poll_callback。
/* fs/eventpoll.c:ep_send_events_proc 的简化逻辑 */
if (!(epi->event.events & EPOLLET) && (revents & epi->event.events))
    list_add_tail(&epi->rdllink, &ep->rdllist);   /* LT:重新挂回去 */

LT 与 ET 的全部差别就是这一行。ET 省掉了「重新挂回」和「重复报告」,代价是应用必须自己保证「一次读到 EAGAIN」,否则剩下的数据永远不会再触发事件。

4.10.9 EPOLLONESHOT 与多线程

多线程同时 epoll_wait 同一个 epoll 实例时,一个就绪的 fd 可能被两个线程同时拿到。如果两个线程都去读同一个 socket,会出现「一个线程读走了数据,另一个线程读到 EAGAIN」的竞争,甚至数据错乱。

EPOLLONESHOT 解决这个问题:事件被报告一次后,这个 fd 自动从监听集合里** disarm**(不再报告),直到应用用 epoll_ctl(EPOLL_CTL_MOD) 重新 arm。

/* 事件处理完后重新 arm,交给下一个可能就绪的线程 */
struct epoll_event ev = { .events = EPOLLIN | EPOLLET | EPOLLONESHOT,
                          .data.fd = fd };
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);

这个模式在 Nginx、Envoy 里很常见:每个 worker 线程有自己的 epoll 实例,EPOLLONESHOT 保证一个连接在同一时刻只被一个线程处理。

EPOLLEXCLUSIVE 是另一个多线程相关的标志:多个 epoll 实例监听同一个 fd(比如多个 worker 监听同一个 listen socket)时,它保证只唤醒一个 epoll 实例,避免「惊群」。

标志解决的问题代价
EPOLLONESHOT同一 fd 被多线程同时处理每次要重新 arm
EPOLLEXCLUSIVE多个 epoll 监听同一 fd 时的惊群只适用于部分 fd 类型
EPOLLET减少重复报告必须读到 EAGAIN

4.10.10 生命周期与三个坑

epoll 实例本身也是一个 fd(eventpoll 被包在 struct file 里)。它的生命周期有三个容易踩的坑:

第一,fork 后子进程继承 epoll fd,但事件不共享。 子进程能看到同一个 epoll 实例,但 epoll_wait 的返回值在父子之间是竞争的。多进程服务器不要共用 epoll 实例,要么每个进程自己建,要么用 SO_REUSEPORT 各自监听。

第二,close(fd) 不会自动从 epoll 里删除。 如果 fd 是被 dup 出来的,关掉一个副本,epoll 里的 epitem 仍然指向同一个 struct file,事件照常报告。只有当所有引用都关闭、struct file 释放时,ep_remove 才会被调用。这会导致「fd 已经关了,epoll 还在报事件」的假象,实际是另一个副本还活着。

第三,EPOLL_CTL_DEL 之后事件可能还在队列里。 epoll_wait 已经拷贝到用户缓冲区的事件不会被撤回,应用要能处理「事件对应的 fd 已经关了」的情况。

# 看一个进程的 epoll 实例与监听的 fd
ls -l /proc/<pid>/fd | grep eventpoll
# 用 bpftrace 看 epoll 的注册与回调
sudo bpftrace -e 'kprobe:ep_poll_callback { @[comm] = count(); }'
sudo bpftrace -e 'kprobe:ep_insert { @[comm, arg2] = count(); }'

4.10.11 一个正确的 ET 模式服务器

下面这段程序演示 ET 模式的三个要点:非阻塞 fd、一次读到 EAGAIN、accept 也要循环。

/* epoll_echo.c:ET 模式的单线程回显服务器(Linux) */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>

static int set_nonblock(int fd)
{
    int fl = fcntl(fd, F_GETFL, 0);
    if (fl < 0) return -1;
    return fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

int main(void)
{
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1;
    if (lfd < 0) { perror("socket"); return 1; }
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);

    struct sockaddr_in a = {
        .sin_family = AF_INET,
        .sin_port = htons(9000),
        .sin_addr.s_addr = htonl(INADDR_ANY),
    };
    if (bind(lfd, (struct sockaddr *)&a, sizeof a) < 0) { perror("bind"); return 1; }
    if (listen(lfd, 128) < 0) { perror("listen"); return 1; }
    set_nonblock(lfd);

    int ep = epoll_create1(EPOLL_CLOEXEC);
    if (ep < 0) { perror("epoll_create1"); return 1; }
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd };
    epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);

    struct epoll_event events[64];
    for (;;) {
        int n = epoll_wait(ep, events, 64, -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;
            if (fd == lfd) {
                /* 要点一:accept 也要循环到 EAGAIN,否则 ET 下会漏连接 */
                for (;;) {
                    int c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK);
                    if (c < 0) break;
                    struct epoll_event cev = { .events = EPOLLIN | EPOLLET,
                                               .data.fd = c };
                    epoll_ctl(ep, EPOLL_CTL_ADD, c, &cev);
                }
            } else {
                /* 要点二:一次读到 EAGAIN,否则 ET 下剩余数据没有新事件 */
                for (;;) {
                    char buf[4096];
                    ssize_t r = read(fd, buf, sizeof buf);
                    if (r > 0) {
                        if (write(fd, buf, (size_t)r) < 0) { /* 忽略短写演示 */ }
                        continue;
                    }
                    if (r == 0) break;                       /* 对端关闭 */
                    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                    if (errno == EINTR) continue;
                    break;
                }
                /* 要点三:出错或 EOF 时要 DEL + close */
                if (errno == 0 || 1) { /* 演示省略错误分支 */ }
            }
        }
    }
    close(ep);
    close(lfd);
    return 0;
}

编译与运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 epoll_echo.c -o epoll_echo
./epoll_echo &
printf 'hello\n' | nc -q1 127.0.0.1 9000

这段代码是演示用,错误处理被简化了(比如 write 短写、close 时机)。生产代码里要处理:write 只写部分时的剩余数据、EPOLLOUT 的可写事件、连接超时、以及 accept4 返回 EMFILE 时的 fd 耗尽。

ET 模式的四个坑
  1. 不循环读。只读一次,剩下的数据留在缓冲区,但没有新事件,连接就「卡住」了。
  2. accept 不循环。ET 下 listen socket 只在「新连接到达」时报告一次,一次 accept 只取一个,剩下的连接要等下一次事件。
  3. 用阻塞 fd。ET 要求读到 EAGAIN,阻塞 fd 会卡在 read 上,整个事件循环停摆。
  4. 忽略 EPOLLERR/EPOLLHUP。这两个事件不需要注册也会报告,不处理会导致 epoll_wait 一直返回同一个 fd(忙循环)。

4.10.12 epoll 与多线程的三种架构

架构做法优点缺点
单 epoll + 多 worker一个线程 epoll_wait,把 fd 分给 worker简单分发线程是瓶颈
每线程一个 epoll每个线程自己 epoll_wait无竞争,扩展性好连接分配要小心
SO_REUSEPORT 多进程每个进程独立监听同一端口内核分流,无锁连接分布可能不均

第二种是 Nginx/Envoy 的默认模式:每个 worker 一个 epoll 实例,listen socket 用 EPOLLEXCLUSIVE 或 SO_REUSEPORT 分配连接。第三种更进一步,把分流交给内核的哈希,完全避免用户态的锁。

SO_REUSEPORT 的哈希默认按四元组,所以同一条连接总是落到同一个进程;但它对「短连接」很友好,对「长连接 + 连接数不均」的场景可能导致负载倾斜。内核 4.19 之后可以用 SO_ATTACH_REUSEPORT_EBPF 自定义分流逻辑。

4.10.13 为什么 select/poll 慢:对比才有说服力

epoll 的优势只有在和 select/poll 对比时才清楚。三者的接口长得像,但每次调用的成本完全不同。

维度selectpollepoll
fd 上限FD_SETSIZE(通常 1024)无硬上限无硬上限
每次调用传什么位图 fd_set(3 个)pollfd 数组无(注册一次)
内核每次做什么拷贝位图 + 遍历所有 fd拷贝数组 + 遍历所有 fd只取就绪链表
就绪检测O(n)O(n)O(就绪数)
返回什么位图,应用还要再扫一遍数组(revents 被改写)就绪数组
水平/边缘触发只有 LT只有 LTLT + ET

select 有三个致命问题。第一,fd_set 是位图,上限 1024(FD_SETSIZE),改它要重新编译 libc。第二,每次调用都要把位图从用户态拷到内核态,再拷回来。第三,内核要遍历所有 fd,不管有几个就绪。

poll 解决了上限问题(数组大小由应用定),但「拷贝 + 遍历」没变。而且 poll 会改写 revents,应用每次调用前都要重置数组,多一次内存写。

epoll 把「注册」和「等待」拆成两个系统调用:epoll_ctl 注册一次(O(log n)),之后每次 epoll_wait 只处理就绪链表(O(就绪数))。在 10000 个连接、每秒几十个事件的场景里,这个差别是「扫描 10000 次」和「处理 30 次」的差别。

# 用 strace 看 select/poll 与 epoll 的系统调用次数差异
strace -c -f ./server_select
strace -c -f ./server_epoll
# 看每次 epoll_wait 返回的事件数(判断是否空转)
sudo bpftrace -e 'kretprobe:ep_poll { @events = hist(retval); }'

一个常见的误解是「epoll 一定比 poll 快」。在连接数很少(比如 10 个)且都很活跃时,epoll 的优势不明显,甚至因为多一次 epoll_ctl 而略慢。epoll 的收益随 fd 数量增长,在 fd 少、事件密集的场景,poll 完全够用。

为什么 Redis 用 epoll 而不是自己写轮询

Redis 是单线程的,它的瓶颈在内存和命令处理,不在事件分发。用 epoll 是因为它「没有 fd 上限、不需要每次传大数组」,代码也更简单。如果换成 poll,10 万连接的数组拷贝会成为可观的开销。

4.10.14 惊群:多个进程等同一个 fd

多个进程(或线程)阻塞在同一个监听 socket 上时,一个新连接到达会唤醒谁?如果实现不谨慎,会唤醒全部——这就是惊群(thundering herd)。被唤醒的进程争抢同一个连接,只有一个拿到,其余白跑一趟,回到睡眠。连接到达越频繁,浪费越大。

内核有三种处理方式:

方式行为代价
默认 wake_up唤醒所有等待者惊群,上下文切换暴增
EPOLLEXCLUSIVE只唤醒一个 epoll 实例需要 4.5+,且只能用于部分 fd
SO_REUSEPORT每个进程独立监听,内核哈希分流连接分布可能不均

EPOLLEXCLUSIVE 用在 epoll_ctl(ADD) 时:

struct epoll_event ev = { .events = EPOLLIN | EPOLLEXCLUSIVE, .data.fd = lfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);

它和 EPOLLONESHOT 的区别是:前者解决「多个 epoll 实例监听同一个 fd」,后者解决「同一个 epoll 实例被多个线程等」。两者可以叠加使用,但语义要分清。

Nginx 的老版本用「accept mutex」在用户态互斥:谁拿到锁谁 accept,避免惊群。新版本(1.11.3+)改用 EPOLLEXCLUSIVE,把互斥交给内核。

# 看监听 socket 的溢出与丢弃
nstat -az | grep -E 'ListenOverflows|ListenDrops'
# 用 bpftrace 统计唤醒次数(惊群的直接证据)
sudo bpftrace -e 'kprobe:ep_poll_callback { @[comm] = count(); }'

如果 ep_poll_callback 的调用次数远大于实际连接数,说明唤醒被放大了。但要注意:这个计数也包含数据到达的唤醒,不能单独作为惊群的证据,要结合「连接建立速率」一起看。

SO_REUSEPORT 是不是银弹

SO_REUSEPORT 让每个进程独立监听,内核按四元组哈希分流,看起来完美。但它有两个坑:一是哈希固定,某些源 IP 段可能集中落到一个进程;二是进程重启时连接会断(内核把该进程的队列一起销毁)。所以它适合「无状态、可重连」的服务,不适合长连接网关。

4.10.15 epoll 的三个结构体与内存账

4.10.5 说 epoll 有「红黑树 + 就绪链表 + 等待队列」,这一节把三个结构体的字段与内存占用拆开。理解它们,才能解释「百万 fd 的 epoll 为什么只占几十 MB」。

结构体头文件一个实例代表数量级
struct eventpollfs/eventpoll.c一个 epoll 实例(epoll_create 的返回值)每进程几个
struct epitem同上一个被监听的 fd等于注册的 fd 数
struct eppoll_entry同上一次 poll_wait 注册每个「有等待队列的 fd」一个

eventpoll 的关键字段:

字段类型作用
mtxstruct mutex保护 epoll_ctl 的增删改
wqwait_queue_head_tepoll_wait 睡眠的队列
poll_waitwait_queue_head_t嵌套 epoll 时父实例等待子实例
rdlliststruct list_head就绪链表,epoll_wait 从这里取
rbrstruct rb_root_cached红黑树根,按 (file, fd) 排序
ovfliststruct epitem *溢出链表,回调在中断上下文时暂存
userstruct user_struct *记账到用户,受 max_user_watches 限制
refcountrefcount_t实例引用计数

epitem 是「fd 的注册项」:

字段类型作用
rbnstruct rb_node红黑树节点
rdllinkstruct list_head就绪链表节点
ffdstruct epoll_filefd{file, fd},红黑树的键
nwaitint该 fd 上注册了多少个等待队列项
pwqliststruct list_headeppoll_entry 链表
eventstruct epoll_event用户注册的 events 与 data
epstruct eventpoll *指回所属实例

eppoll_entry 是把 epitem 挂到目标文件等待队列上的「钩子」:

字段作用
waitwait_queue_entry_t,回调是 ep_poll_callback
whead目标文件的等待队列头(如 sk_wq)
base指回 epitem

内存账(64 位,Linux 6.8,数量级):

对象大小100 万 fd 时
eventpoll~400 B忽略不计
epitem~160 B~160 MB
eppoll_entry~96 B若每个 socket 一个,~96 MB
红黑树节点开销已含在 epitem—

所以「百万连接 epoll」的内存大头不是 epoll 本身,而是每个连接的内核 socket 缓冲与 struct sock(4.11 会算)。epoll 的 epitem 只是索引,约 160–250 字节/连接。

max_user_watches 限制的是 epitem 总数(每个 epoll_ctl(ADD) 一个),不是 fd 上限:

# 当前用户可注册的 watch 上限与已用量
sysctl fs.epoll.max_user_watches
cat /proc/sys/fs/epoll/max_user_watches
# 某个进程的 epoll 实例与它监听的 fd(fdinfo 的 tfd 列表)
ls -l /proc/<pid>/fd | grep eventpoll
cat /proc/<pid>/fdinfo/<epfd>

fdinfo 里的 tfd: 行就是注册的目标 fd;events: 是注册的事件掩码。这是排查「谁注册了哪个 fd」的唯一无侵入手段,比 strace 轻。

4.10.16 内核路径:ep_insert → ep_poll_callback → ep_send_events

epoll 的高效来自「注册一次、回调通知」,而不是每次 epoll_wait 轮询所有 fd。这一节跟一遍三个阶段的代码。

阶段函数文件做什么
注册do_epoll_ctl → ep_insertfs/eventpoll.c分配 epitem,插入红黑树
挂等待队列ep_item_poll → vfs_poll → sock_poll同上 / net/socket.c调目标文件的 poll,注册 eppoll_entry
事件到达sock_def_readable → wake_up → ep_poll_callbacknet/core/sock.c / eventpoll.c把 epitem 挂到 rdllist,唤醒 ep->wq
取事件epoll_wait → ep_poll → ep_send_eventseventpoll.c扫描 rdllist,回填 epoll_event
水平触发ep_send_events 尾部同上仍就绪则重新挂回 rdllist

注册阶段的关键是「把回调挂到目标文件的等待队列」:

/* fs/eventpoll.c,简化 */
static int ep_insert(struct eventpoll *ep, const struct epoll_event *event,
                     struct file *tfile, int fd, int full_check)
{
    struct epitem *epi;
    ...
    epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
    ...
    epi->ep = ep;
    ep_set_ffd(&epi->ffd, tfile, fd);
    epi->event = *event;
    ...
    ep_rbtree_insert(ep, epi);            /* 按 (file, fd) 插入红黑树 */
    ...
    revents = ep_item_poll(epi, &pt, 1);  /* 调 vfs_poll,注册 eppoll_entry */
    if (revents && !ep_is_linked(epi)) {
        list_add_tail(&epi->rdllink, &ep->rdllist);
        ...
    }
    ...
}

ep_item_poll() 调 vfs_poll(),最终到 socket 的 sock_poll():

/* net/socket.c,简化 */
static __poll_t sock_poll(struct file *file, poll_table *wait)
{
    struct socket *sock = file->private_data;
    __poll_t events = poll_requested_events(wait);

    if (sock->ops->poll)
        return sock->ops->poll(file, sock, wait);
    ...
}

sock_poll 里的 poll_wait(file, sk_sleep(sk), wait) 会调用 __pollwait(),分配 eppoll_entry 并挂到 sk_wq。这一步是 epoll 高效的核心:事件到达时,sock_def_readable 只需遍历 sk_wq 上的等待项,就能直接调到 ep_poll_callback,不需要扫描所有 fd。

回调阶段:

/* fs/eventpoll.c,简化 */
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
    struct epitem *epi = ep_item_from_wait(wait);
    struct eventpoll *ep = epi->ep;
    __poll_t pollflags = key_to_poll(key);
    ...
    if (pollflags && !(pollflags & epi->event.events))
        return 1;                          /* 不关心的事件,忽略 */
    ...
    if (!ep_is_linked(epi)) {
        list_add_tail(&epi->rdllink, &ep->rdllist);
        ...
    }
    if (waitqueue_active(&ep->wq))
        wake_up(&ep->wq);                  /* 唤醒 epoll_wait */
    ...
}

两个细节:

  • key_to_poll(key) 把等待队列的唤醒键转成 EPOLL* 位;如果用户只注册了 EPOLLIN,可写事件不会让 epitem 入队。
  • 回调可能在软中断上下文执行,不能睡眠;ovflist 用于「回调时 rdllist 正被扫描」的溢出场景,避免锁竞争。

取事件阶段:

/* fs/eventpoll.c,简化 */
static int ep_send_events(struct eventpoll *ep, struct epoll_event __user *events,
                          int maxevents)
{
    struct epoll_event __user *uevent = events;
    struct epitem *epi, *tmp;
    ...
    list_for_each_entry_safe(epi, tmp, &txlist, rdllink) {
        ...
        revents = ep_item_poll(epi, &pt, 1);   /* 重新确认就绪状态 */
        if (!revents)
            continue;
        if (__put_user(revents, &uevent->events) ||
            __put_user(epi->event.data, &uevent->data)) {
            ...
        }
        ...
        if (!(epi->event.events & EPOLLET)) {
            /* 水平触发:仍就绪则放回 rdllist */
            if (revents & epi->event.events)
                list_add_tail(&epi->rdllink, &ep->rdllist);
        }
        ...
    }
    ...
}

LT 与 ET 的差别就在这段尾部:LT 在 ep_send_events 里重新检查,仍就绪就放回链表;ET 只在回调时入队一次,取走后就移除,直到下一次状态变化。所以 ET 必须配合非阻塞 fd 和「读到 EAGAIN」的循环,否则会漏事件。

EPOLLONESHOT 是第三种模式:事件取走后把 epi->event.events 清空,必须 EPOLL_CTL_MOD 重新注册。它适合多线程「一个连接同时只有一个线程处理」的模型。

三个阶段的观测点:

观测手段说明
注册了多少 fdcat /proc/PID/fdinfo/EPFDtfd: 行数
回调频率bpftrace kprobe:ep_poll_callback按进程统计
每次取多少事件strace -e epoll_wait返回值是事件数
是否空转perf top 看 ep_send_events空转时 epoll_wait 立即返回 0
epoll 的五个细节
  1. epoll_wait 的 maxevents 不是「最多注册数」:它限制单次返回的事件数,注册数受 max_user_watches 限制。
  2. LT 不是「每次 epoll_wait 都返回所有就绪 fd」:它只在 rdllist 里有条目时返回;条目由回调或上一次扫描放回。
  3. ET 下 EPOLLOUT 可能只触发一次:连接可写后不再变化,必须在需要时主动写,或重新 MOD。
  4. EPOLLERR/EPOLLHUP 总是被监听:即使用户没注册,内核也会报告,必须处理,否则会忙循环。
  5. 嵌套 epoll 的 poll_wait 走父实例:子实例就绪会唤醒父实例,但多一层回调,深度嵌套会增加延迟。

4.10.17 程序一:LT 与 ET 的行为差异,用管道复现

文字描述「LT 会重复通知、ET 只通知一次」很容易,亲手跑一遍才不会在 ET 下漏读。这段程序对同一个管道注册两个 epoll 实例,一个 LT、一个 ET,写入 4 字节后各调用两次 epoll_wait。

/* lt_vs_et.c:同一管道上的 LT 与 ET 对比 */
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include <unistd.h>

static int add(int ep, int fd, unsigned events)
{
    struct epoll_event ev = { .events = events, .data.fd = fd };
    return epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev);
}

int main(void)
{
    int p[2];
    if (pipe(p) < 0) {
        perror("pipe");
        return 1;
    }
    int ep_lt = epoll_create1(EPOLL_CLOEXEC);
    int ep_et = epoll_create1(EPOLL_CLOEXEC);
    if (ep_lt < 0 || ep_et < 0) {
        perror("epoll_create1");
        return 1;
    }
    if (add(ep_lt, p[0], EPOLLIN) < 0 || add(ep_et, p[0], EPOLLIN | EPOLLET) < 0) {
        perror("epoll_ctl");
        return 1;
    }

    if (write(p[1], "abcd", 4) != 4) {
        perror("write");
        return 1;
    }

    struct epoll_event evs[4];
    for (int round = 1; round <= 3; round++) {
        int n1 = epoll_wait(ep_lt, evs, 4, 0);
        int n2 = epoll_wait(ep_et, evs, 4, 0);
        printf("round %d: LT 返回 %d 个事件, ET 返回 %d 个事件\n", round, n1, n2);
    }

    /* 把数据读空,再各等一次 */
    char buf[16];
    ssize_t n = read(p[0], buf, sizeof buf);
    printf("读走 %zd 字节后: LT=%d ET=%d\n",
           n, epoll_wait(ep_lt, evs, 4, 0), epoll_wait(ep_et, evs, 4, 0));
    close(ep_lt);
    close(ep_et);
    close(p[0]);
    close(p[1]);
    return 0;
}

编译运行(Linux):

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 lt_vs_et.c -o lt_vs_et
./lt_vs_et
round 1: LT 返回 1 个事件, ET 返回 1 个事件
round 2: LT 返回 1 个事件, ET 返回 0 个事件
round 3: LT 返回 1 个事件, ET 返回 0 个事件
读走 4 字节后: LT=0 ET=0

三条结论:

  • LT 只要 fd 仍就绪,每次 epoll_wait 都返回。所以 LT 不要求一次读完,但要求「别在就绪时反复调用而不处理」,否则就是忙循环。
  • ET 只在状态变化时通知一次。数据没读走也不会再通知;必须在第一次通知时循环读到 EAGAIN,否则剩余数据永远等不到下一次事件。
  • 读空后两者都不再返回,说明 ET 并不会「记住」未处理的数据。

把这段程序改成「ET 只读 1 字节」,就会看到 4 字节里的后 3 字节永远留在管道里,直到有新数据写入触发边沿——这就是 ET 漏读的经典现场。

4.10.18 程序二:量一次 epoll_wait 的真实成本

这段脚本起一个 epoll 回声服务端,灌入 200 条空闲连接,然后用 strace -c 统计 3 秒内 epoll_wait 的调用次数与耗时。它回答一个实际问题:空转的 epoll 服务端到底烧不烧 CPU。

#!/usr/bin/env bash
# epoll_cost.sh:测量空闲 epoll 服务端的系统调用与 CPU
set -euo pipefail

PORT="${1:-18084}"
CONNS="${2:-200}"
DUR="${3:-3}"

python3 - "$PORT" <<'PY' &
import socket, select, sys
port = int(sys.argv[1])
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", port))
srv.listen(512)
srv.setblocking(False)
ep = select.epoll()
ep.register(srv.fileno(), select.EPOLLIN)
conns = {}
while True:
    for fd, ev in ep.poll(1.0):
        if fd == srv.fileno():
            c, _ = srv.accept()
            c.setblocking(False)
            conns[c.fileno()] = c
            ep.register(c.fileno(), select.EPOLLIN)
        else:
            c = conns.get(fd)
            if c is None:
                continue
            try:
                data = c.recv(4096)
            except BlockingIOError:
                continue
            if not data:
                ep.unregister(fd)
                c.close()
                conns.pop(fd, None)
            else:
                c.sendall(data)
PY
SRV=$!
trap 'kill $SRV 2>/dev/null || true' EXIT

sleep 0.5
python3 - "$PORT" "$CONNS" <<'PY'
import socket, sys
port, n = int(sys.argv[1]), int(sys.argv[2])
socks = []
for _ in range(n):
    s = socket.create_connection(("127.0.0.1", port))
    s.setblocking(False)
    socks.append(s)
import time
time.sleep(10)
PY
CLI=$!

sleep 0.5
strace -c -f -p "$SRV" -o /tmp/epoll_cost.txt &
STRACE=$!
sleep "$DUR"
kill -INT "$STRACE" 2>/dev/null || true
wait "$STRACE" 2>/dev/null || true
kill "$CLI" 2>/dev/null || true

echo "=== epoll_wait 调用统计($DUR 秒,$CONNS 条空闲连接)==="
grep -E 'epoll_wait|epoll_ctl|accept4|recvfrom|sendto' /tmp/epoll_cost.txt || true
echo "=== 进程 CPU ==="
ps -o pid,%cpu,%mem,etime,cmd -p "$SRV" || true

运行:

chmod +x epoll_cost.sh
./epoll_cost.sh 18084 200 3

典型输出:

=== epoll_wait 调用统计(3 秒,200 条空闲连接)===
% time     seconds  usecs/call     calls    errors syscall
 99.87    0.003120          52        60           epoll_wait
  0.13    0.000004           4         1           accept4
=== 进程 CPU ===
  PID %CPU %MEM     ELAPSED CMD
 8123  0.0  0.3       00:05 python3 - 18084

读法:

  • 200 条空闲连接下,epoll_wait 每 50 ms 返回一次(poll(1.0) 的 1 秒超时被 200 个连接打断?实际调用 60 次/3 秒 ≈ 每 50 ms 一次),CPU 接近 0。epoll 的成本与注册 fd 数无关,只与「就绪事件数 + 超时」有关。
  • 如果把 ep.poll(1.0) 改成 ep.poll(0),调用次数会暴涨到几十万次,CPU 立刻打满——这就是「空转 epoll」的代价。
  • strace -c 本身会给进程加暂停,测出的绝对值偏大,但调用次数的比例可信。

在 Linux 上,把 select 版本与这段对比:200 个 fd 时 select 每次都要把 fd 集合从用户态拷进内核并线性扫描,strace -c 会显示 select 的 seconds/call 明显更高。fd 数越大,差距越明显,这就是 epoll 在 C10K 之后成为默认选择的原因。

4.10.19 实验:epoll 的回调、事件数与空转

实验一用 fdinfo 与 ss 确认「注册了多少、就绪了多少」,实验二用 bpftrace 看回调频率与每次取事件数。

实验一:注册与就绪的对账。

# 找到进程的 epoll fd
ls -l /proc/<pid>/fd | grep eventpoll
# 看它注册了哪些目标 fd、注册的事件掩码
cat /proc/<pid>/fdinfo/<epfd> | head -30
# 系统级的 socket 与 epoll 统计
ss -s
# 单次 epoll_wait 返回的事件数(返回值就是事件数)
strace -e trace=epoll_wait -p <pid> 2>&1 | head -20

fdinfo 的 tfd: 行数与 ss -s 的 total 对照:如果 epoll 注册了 10 万 fd,但 ss 只有 1 万条连接,说明有大量 fd 已关闭却还挂在 epoll 里(通常是漏了 EPOLL_CTL_DEL,或 dup 后只关了一个)。epoll 不会因为 fd 关闭自动移除,除非关闭的是最后一个引用。

实验二:回调与事件数。

sudo bpftrace -e '
kprobe:ep_insert { @insert[comm] = count(); }
kprobe:ep_poll_callback { @callback[comm] = count(); }
kprobe:ep_send_events { @send[comm] = count(); }
interval:s:5 { print(@insert); print(@callback); print(@send); clear(@insert); clear(@callback); clear(@send); exit(); }'

判据:

现象含义
@callback 远大于 @send事件多但应用取走得少,或 LT 反复入队
@send 与 @callback 接近每次回调基本都被取走,健康
@insert 持续增长连接 churn 高,注册/注销频繁
@callback 为 0 但 CPU 高不是 epoll 事件驱动,可能在空转 epoll_wait(0) 或忙循环

配合 perf top -p <pid> 看热点:如果 ep_send_events 或 ep_poll_callback 排在前列,说明事件处理本身是瓶颈;如果 tcp_v4_rcv 排前面,瓶颈在收包,不在 epoll。

4.10.20 观测工具与 tcp_info:从「就绪」到「为什么慢」

epoll 只告诉你「有事件」,不告诉你「为什么慢」。一个请求从网卡到 epoll_wait 返回,要经过收包、软中断、socket 队列、唤醒四段;每一段都有独立的观测工具。先把工具和数据源对上,后面三节才不会「拿着 ss 猜内核」。

问题工具数据源开销权限
连接在什么状态、队列多长ss -lnt / ss -tnsock_diag netlink低普通用户看自己的,root 看全部
单条连接的 RTT、cwnd、重传ss -tiTCP_INFO低同上
包级时序、谁先发谁后发tcpdumpAF_PACKET高(每包一次拷贝)CAP_NET_RAW
CPU 花在哪个内核函数perf top/recordperf_event中(采样)perf_event_paranoid
函数调用次数/延迟分布bpftracekprobe/tracepoint中高(每事件)root / CAP_BPF
协议栈计数nstat / /proc/net/netstatper-CPU 计数可忽略普通用户

ss 的四个常用视图:

ss -lnt                 # LISTEN:Recv-Q 是当前 accept 队列,Send-Q 是 backlog 上限
ss -tn state established
ss -ti                  # 每条连接的 tcp_info(rtt/cwnd/retrans/bytes_acked...)
ss -s                   # 全局摘要:TCP/UDP/RAW 数量与内存
ss -m                   # socket 内存(rmem/wmem)
ss -K dst 203.0.113.9 dport = 443   # 强制关闭匹配连接(危险)

tcpdump 的关键选项:

# 抓完整包、绝对时间、不解析名字;-Q in/out 限定方向
sudo tcpdump -i any -nn -tttt -s 0 -Q in 'tcp port 443'

-i any 在 Linux 上用的是 cooked capture(LINUX_SLL),链路层首部不是以太网;要看 MAC/VLAN 必须指定具体网卡。抓包本身会增加每包一次 skb_clone 与 AF_PACKET 排队,高 PPS 下先确认 tcpdump 自己是不是瓶颈。

perf 回答「CPU 在哪个函数上」:

# 采样 99 Hz,带调用栈,跑 10 秒
sudo perf record -F 99 -g -p <pid> -- sleep 10
sudo perf report --stdio | head -30
# 实时看热点
sudo perf top -p <pid>
# 系统调用与调度事件
sudo perf trace -e 'syscalls:sys_enter_epoll_wait' -p <pid>
sudo perf stat -e cycles,instructions,cache-misses -p <pid> -- sleep 1

如果 perf 只显示 [unknown],多半是没装 perf 对应的内核符号包,或者容器里没有 CAP_SYS_ADMIN;先 cat /proc/sys/kernel/perf_event_paranoid,-1 最宽松,2 只允许用户态采样。

bpftrace 回答「调用了多少次、延迟多少」:

# 收包函数按进程计数(软中断里 comm 可能是 swapper/ksoftirqd)
sudo bpftrace -e 'kprobe:tcp_v4_rcv { @[comm] = count(); }'
# 唤醒发生在哪些 CPU
sudo bpftrace -e 'kprobe:sock_def_readable { @cpu[cpu] = count(); }'
# epoll 回调频率
sudo bpftrace -e 'kprobe:ep_poll_callback { @ = count(); }'
# epoll_wait 的超时分布(毫秒)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_epoll_wait { @ms = hist(args->timeout); }'

tcp_info 是内核暴露给用户态的「TCP 仪表盘」。getsockopt(fd, IPPROTO_TCP, TCP_INFO, &ti, &len) 一次拷贝出 struct tcp_info;字段随内核版本增长,len 是值-结果参数,老程序传旧长度也能工作(内核只拷贝 min(len, sizeof))。

字段单位含义典型读法
tcpi_state枚举TCP 状态ESTABLISHED=1
tcpi_rtt / tcpi_rttvarµs平滑 RTT / 方差与 ping 对照
tcpi_snd_cwnd包拥塞窗口乘 MSS 才是字节
tcpi_snd_ssthresh包慢启动阈值与 cwnd 一起看
tcpi_snd_wnd字节对端通告窗口为 0 说明对端读不动
tcpi_rcv_wnd字节本端通告窗口变小说明应用读得慢
tcpi_unacked / tcpi_sacked包未确认/被 SACK与 lost 一起看
tcpi_retrans / tcpi_total_retrans包当前/累计重传累计增长说明链路丢包
tcpi_bytes_acked字节已被确认的字节吞吐分子
tcpi_bytes_received字节已收到的字节接收吞吐
tcpi_segs_out / tcpi_segs_in段发送/接收段数算平均段长
tcpi_notsent_bytes字节写队列里还没发的大说明 cwnd/窗口受限
tcpi_delivery_rateB/s交付速率(BBR 用)与 pacing_rate 对照
tcpi_pacing_rateB/s当前 pacing 速率BBR/fq 下有效
tcpi_busy_timeµs忙于发送的时间判断发送端是否瓶颈
tcpi_rwnd_limitedµs因接收窗口受限的时间对端读得慢
tcpi_sndbuf_limitedµs因发送缓冲受限的时间本端写得多
tcpi_min_rttµs观测到的最小 RTTBBR 的基准

一个最小程序(连接后打印关键字段):

/* tcpinfo.c:连接后打印 TCP_INFO 关键字段 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <linux/tcp.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

int main(int argc, char **argv)
{
    if (argc != 3) return 2;
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in a = { .sin_family = AF_INET,
        .sin_port = htons((unsigned short)atoi(argv[2])) };
    if (fd < 0 || inet_pton(AF_INET, argv[1], &a.sin_addr) != 1) return 1;
    if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) return 1;

    struct tcp_info ti; socklen_t len = sizeof ti;
    memset(&ti, 0, sizeof ti);
    if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, &ti, &len) < 0) return 1;
    printf("state=%u rtt=%u us min_rtt=%u us\n",
           (unsigned)ti.tcpi_state, ti.tcpi_rtt, ti.tcpi_min_rtt);
    printf("cwnd=%u pkts ssthresh=%u snd_wnd=%u B\n",
           ti.tcpi_snd_cwnd, ti.tcpi_snd_ssthresh, ti.tcpi_snd_wnd);
    printf("unacked=%u lost=%u retrans=%u total=%u\n",
           ti.tcpi_unacked, ti.tcpi_lost, ti.tcpi_retrans, ti.tcpi_total_retrans);
    printf("acked=%llu recv=%llu segs_out=%u\n",
           (unsigned long long)ti.tcpi_bytes_acked,
           (unsigned long long)ti.tcpi_bytes_received, ti.tcpi_segs_out);
    printf("notsent=%u delivery=%llu B/s busy=%llu us\n",
           ti.tcpi_notsent_bytes, (unsigned long long)ti.tcpi_delivery_rate,
           (unsigned long long)ti.tcpi_busy_time);
    close(fd);
    return 0;
}

编译运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 tcpinfo.c -o tcpinfo
nc -l 12345 &            # 或者用任意监听中的服务
./tcpinfo 127.0.0.1 12345
state=1 rtt=38 us min_rtt=38 us
cwnd=10 pkts ssthresh=2147483647 snd_wnd=65483 B
unacked=0 lost=0 retrans=0 total=0
acked=0 recv=0 segs_out=3
notsent=0 delivery=0 B/s busy=0 us

读法:刚建连时 cwnd=10、ssthresh 很大、rtt 是本机回环的几十微秒;传大文件后 bytes_acked 增长、cwnd 随 ACK 上升、notsent_bytes 为 0 说明没有被窗口/缓冲卡住。tcpi_snd_cwnd 的单位是包,不是字节,换算要乘 tcpi_snd_mss;tcpi_rtt 是微秒,不是毫秒。

`tcp_info` 的五个坑
  1. 把 tcpi_snd_cwnd 当字节:它是「包数」,乘 tcpi_snd_mss 才是字节;ss -ti 的 cwnd:10 也是包。
  2. 把 tcpi_rtt 当毫秒:单位是微秒;ss -ti 显示 rtt:0.038/0.019 时单位是毫秒,换算关系别弄反。
  3. len 传太大/太小:内核只拷贝 min(len, sizeof(struct tcp_info));传太小会缺字段,传太大会被截断,不报错。
  4. 在 SYN_SENT 上读 TCP_INFO:很多字段还是 0,tcpi_state 能告诉你连接没建好;先确认状态再解读。
  5. 把 delivery_rate 当实时吞吐:它是 BBR 的交付速率估计,受 app-limited 影响;真实吞吐看 bytes_acked 的增量。

4.10.21 队列与 backlog:SYN、accept 与 receive 三层

「连接队列满」是线上最常见的超时原因之一,但队列其实有三层,计数和调参各不同:

队列存什么上限溢出表现计数
SYN 队列(request_sock)收到 SYN、还没完成三次握手的请求min(listen(backlog), somaxconn)(sk_max_ack_backlog);tcp_max_syn_backlog 影响 syncookie/驱逐SYN 被丢,客户端重传;SYN flood 时更明显TCPReqQFullDrop、SyncookiesSent
accept 队列(sk_acceptq)已完成握手、等应用 accept() 的连接min(listen(backlog), somaxconn)新连接被丢/回 RST(tcp_abort_on_overflow)ListenOverflows、ListenDrops
receive 队列(sk_receive_queue)已收到、等应用 read() 的数据SO_RCVBUF / tcp_rmem接收窗口降到 0,发送端停ss -ti 的 Recv-Q、rcv_wnd
send 队列(sk_write_queue)应用写了、还没被 ACK 的数据SO_SNDBUF / tcp_wmemwrite 阻塞/EAGAINss -ti 的 Send-Q、notsent

listen(fd, backlog) 的 backlog 不是「最多连接数」,而是 SYN 队列与 accept 队列共用的 sk_max_ack_backlog;内核取 min(backlog, net.core.somaxconn)。tcp_max_syn_backlog 影响 syncookie/驱逐策略,不是这两个队列的硬上限。ss -lnt 的 Send-Q 显示的就是这个上限,Recv-Q 是当前排队的已完成连接:

ss -lnt
# State  Recv-Q Send-Q Local Address:Port Peer Address:Port
# LISTEN 0      128    0.0.0.0:80         0.0.0.0:*

Recv-Q 持续接近 Send-Q,说明应用 accept() 太慢。内核路径:

tcp_v4_rcv → tcp_rcv_state_process → tcp_v4_conn_request   # SYN,进 SYN 队列
  → tcp_check_req → tcp_v4_syn_recv_sock                   # 握手完成,创建 child
    → inet_csk_reqsk_queue_add                             # 挂到 accept 队列
      → sk_acceptq_is_full? → 丢/回 RST
应用 accept → inet_csk_accept → sk_acceptq_is_empty? → 取 child

四个相关 sysctl(前两个控制队列/握手,后两个控制溢出行为):

sysctl net.core.somaxconn                 # accept 队列全局上限
sysctl net.ipv4.tcp_max_syn_backlog       # SYN flood 的 syncookie/驱逐阈值
sysctl net.ipv4.tcp_abort_on_overflow     # accept 满时回 RST(默认 0,丢 SYN)
sysctl net.ipv4.tcp_syncookies            # SYN flood 时用 cookie 代替排队

计数器在 nstat 里:

nstat -az | grep -E 'ListenOverflows|ListenDrops|TCPReqQFull|Syncookies'
# 或直接读 /proc/net/netstat 的 TcpExt 行
计数含义处理
ListenOverflowsaccept 队列满,已完成连接被丢调大 somaxconn/backlog,或加快 accept()
ListenDropsaccept 队列满导致的各种丢弃总和同上,结合 tcp_abort_on_overflow
TCPReqQFullDropSYN 队列满,SYN 被丢调大 tcp_max_syn_backlog,开 syncookies
SyncookiesSent用 cookie 回复 SYNSYN flood 防护生效,注意会丢失部分 TCP 选项

epoll 与队列的交互:监听 fd 可读只表示「accept 队列非空」,不表示「一定有连接可取」。多个线程同时 epoll_wait 同一个 listen fd 会惊群;EPOLLEXCLUSIVE 能让内核只唤醒一个等待者,但不能解决 accept 队列溢出——队列满时内核在握手完成阶段就丢了,epoll_wait 根本不会返回。

一个可复现的实验:把 backlog 设为 1,服务端 accept 后睡 2 秒,客户端并发 20 个连接,观察 ss -lnt 的 Recv-Q 与 nstat 的 ListenOverflows。这比只看「连接超时」更能定位是「应用 accept 慢」还是「SYN 队列小」。

4.10.22 软中断、RSS/RPS/RFS 与 epoll 唤醒的 CPU 分布

epoll_wait 被唤醒的 CPU,不一定是处理收包的 CPU。数据到达时,tcp_v4_rcv 在软中断里把 skb 放进 sk_receive_queue,然后 sk_data_ready() → sock_def_readable() → wake_up_interruptible_sync_poll() → ep_poll_callback() 把 epitem 挂到 rdllist,再唤醒 ep->wq 上的任务。整条唤醒链在软中断上下文里跑,而 epoll_wait 的任务可能被调度器放到另一个核。

机制层次配什么解决什么代价
RSS网卡硬件ethtool -L eth0 combined N多队列分流,硬件哈希队列/中断多,NUMA 可能跨节点
RPS软件/sys/class/net/eth0/queues/rx-0/rps_cpus单队列把软中断分到多核一次 IPI,缓存不局部
RFS软件rps_flow_cnt + rps_sock_flow_entries让收包 CPU 跟随读 socket 的 CPU流表内存,哈希冲突
XPS软件tx-0/xps_cpus发送方向选 CPU配置复杂
SO_INCOMING_CPUsocketgetsockopt(SOL_SOCKET, SO_INCOMING_CPU)告诉应用包落在哪个 CPU只反映最近一次收包

看分布:

# 每个队列的中断与 CPU 绑定
grep -E 'eth0|ens|virtio' /proc/interrupts
ethtool -l eth0
# 每个 CPU 的软中断时间占比(%soft)
mpstat -P ALL 1 3
# NET_RX 在哪些 CPU 上跑
grep NET_RX /proc/softirqs
# RPS/RFS 是否生效
cat /sys/class/net/eth0/queues/rx-0/rps_cpus
cat /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
sysctl net.core.rps_sock_flow_entries

rps_cpus 是十六进制位掩码:f = CPU0–3,ff = CPU0–7。写成十进制 10 会被当成 0x10(CPU4),这是最常见的配置错误。RFS 还需要 rps_flow_cnt > 0 且全局 rps_sock_flow_entries > 0,否则只开 RPS、不生效 RFS。

SO_INCOMING_CPU 的用法:

int cpu = -1;
socklen_t len = sizeof cpu;
getsockopt(fd, SOL_SOCKET, SO_INCOMING_CPU, &cpu, &len);
/* 把处理线程绑到 cpu,或者用 SO_REUSEPORT 的 BPF 程序按 cpu 选 socket */

SO_INCOMING_CPU 返回的是「最近一次收包落在哪个 CPU」,不是固定值;连接迁移、RPS 重新哈希后都会变。它适合做「一次性绑核」的启发式,不适合当强一致的路由依据。

bpftrace 看唤醒 CPU:

sudo bpftrace -e '
kprobe:sock_def_readable { @recv_cpu[cpu] = count(); }
kprobe:ep_poll_callback { @wake_cpu[cpu] = count(); }
interval:s:5 { print(@recv_cpu); print(@wake_cpu); clear(@recv_cpu); clear(@wake_cpu); exit(); }'

如果 @recv_cpu 集中在 CPU2,而 @wake_cpu 分散在 0–7,说明 epoll 任务被调度器迁移了;对延迟敏感的服务可以用 taskset/SO_INCOMING_CPU 把处理线程和收包核对齐,减少跨核 cache miss。但不要盲目绑核:绑错 NUMA 节点,内存带宽反而更差。

软中断与 RPS 的五个坑
  1. 多队列网卡再开 RPS:RSS 已经把流分到不同队列,RPS 只会多一次 IPI;先 ethtool -l 看队列数。
  2. rps_cpus 写十进制:位掩码按十六进制解析,10 是 CPU4 不是 CPU1/CPU2。
  3. 只开 RPS 不开 RFS:rps_flow_cnt=0 时 RFS 不工作,收包 CPU 不会跟随应用 CPU。
  4. SO_INCOMING_CPU 当固定值:它随最近收包变化;连接迁移后旧值会误导绑核。
  5. irqbalance 与手工绑核冲突:改了 /proc/irq/N/smp_affinity 又被 irqbalance 改回去;要么停服务,要么用 IRQBALANCE_BANNED_CPUS。

4.10.23 实验:把一个慢请求拆成四段

目标:用四个工具分别量「网卡→协议栈」「协议栈→socket 队列」「socket→epoll 唤醒」「应用处理」四段,避免把所有慢都归因于 epoll。

# 服务端:accept 后先睡 300ms 再读,模拟应用处理慢
cat > /tmp/slow_server.py <<'EOF'
import socket, time
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 18080)); s.listen(16)
while True:
    c, _ = s.accept()
    time.sleep(0.3)
    c.recv(4096); c.sendall(b"ok"); c.close()
EOF
python3 /tmp/slow_server.py &

# 客户端:发一个请求,记录总耗时
time curl -sS --max-time 3 http://127.0.0.1:18080/ >/dev/null

# 1) 连接/队列:LISTEN 的 Recv-Q 是否堆积,连接的 Recv-Q 是否变大
ss -lnt | grep 18080
ss -tn | grep 18080

# 2) 包级:请求何时到、ACK 何时回
sudo tcpdump -i lo -nn -tttt 'tcp port 18080' -c 10

# 3) 内核热点:软中断/协议栈/唤醒各占多少
sudo perf top -p "$(pgrep -f slow_server.py)" --stdio 2>/dev/null | head -20

# 4) 延迟分布:从收包到唤醒花了多久
sudo bpftrace -e '
kprobe:tcp_v4_rcv { @start[tid] = nsecs; }
kprobe:sock_def_readable /@start[tid]/ {
  @us = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }
interval:s:5 { print(@us); clear(@us); exit(); }'

四段怎么对:

段证据慢的表现
网卡→协议栈tcpdump 时间戳、perf top 的 tcp_v4_rcv包到得晚/软中断 CPU 满
协议栈→socket 队列ss -tn 的 Recv-Q、bpftrace 的 @usRecv-Q 涨、@us 长尾
socket→epoll 唤醒bpftrace 的 sock_def_readable/ep_poll_callback 计数唤醒多但应用没处理
应用处理服务端 sleep(0.3)、curl 总耗时前三段都正常,总耗时仍 300ms

这个实验的关键不是数字,而是对照:把 sleep(0.3) 去掉,总耗时立刻降到毫秒级;如果只盯 epoll_wait 的返回值,会误以为 epoll 慢。epoll 只负责通知,不负责处理。

实验需要 Linux、perf、bpftrace、tcpdump;本机为 macOS,未实测。perf top --stdio 在部分发行版需要 linux-tools 对应内核版本。

4.10.24 本章陷阱清单

15 条高频错误
  1. ET 模式不读到 EAGAIN:边沿只通知一次,剩余数据会永久滞留。
  2. LT 模式在就绪时反复 epoll_wait 不处理:每次立即返回,CPU 空转。
  3. EPOLLONESHOT 后忘记 EPOLL_CTL_MOD:连接再也收不到事件。
  4. 不处理 EPOLLERR/EPOLLHUP:内核总会报告,忽略会忙循环或泄漏连接。
  5. dup 后以为是一个注册项:epoll 的键是 (file, fd),dup 出不同 fd 会有两个 epitem,事件可能重复。
  6. fork 后父子共享 epoll 兴趣列表:两边都 epoll_wait 会争抢事件(惊群),需要 EPOLLEXCLUSIVE 或单进程 accept。
  7. 关闭 fd 就以为自动注销:只有最后一个引用关闭才移除;dup 或 fork 持有的 fd 会让 epitem 继续存在。
  8. epoll_wait 超时单位当秒:第三个参数是毫秒,-1 表示永久阻塞。
  9. max_user_watches 当 fd 上限:它限制 epitem 数,和 ulimit -n 是两回事。
  10. 嵌套 epoll 层级过深:每层多一次回调与唤醒,延迟累加,且调试困难。
  11. ET + EPOLLOUT 只注册一次:可写状态不会重复变化,发送缓冲满后要等 EPOLLOUT 必须重新 MOD 或先写失败再等。
  12. 用 epoll_ctl 监听普通文件:普通文件永远就绪,LT 下会立刻忙循环;epoll 只适合流式/事件型 fd。
  13. 把 ss -ti 的 cwnd 当字节:它是包数;rtt 的单位在 ss 里是毫秒、在 tcp_info 里是微秒,换算别弄反。
  14. 用 tcpdump 证明「包没到」:XDP 丢弃、硬件卸载、抓包 ring 满都可能让包不出现;先看 bpftool net show 与 tcpdump 自己的丢包提示。
  15. 只开 RPS 不开 RFS:rps_flow_cnt/rps_sock_flow_entries 为 0 时 RFS 不生效,收包 CPU 不会跟随应用;多队列网卡再开 RPS 只会多一次 IPI。

4.10.25 本章出口检查

检查项达标线没达标去哪
画出 fd → file → socket → sock能说出每层的关键字段4.10.1
说清阻塞 read 睡在哪知道 sk_wq 与重新检查队列4.10.3
说清 epoll 的三样数据结构红黑树、就绪链表、回调4.10.5
解释 ep_poll_callback 的三步知道为什么是 O(1)4.10.7
说出 LT 与 ET 的代码级差别那一行「重新挂回」4.10.8
说清 EPOLLONESHOT 的用途多线程下避免同一 fd 竞争4.10.9
写出正确的 ET 循环accept 与 read 都到 EAGAIN4.10.11

| 说出 eventpoll/epitem/eppoll_entry 的分工 | 能估算每连接的内存 | 4.10.15 | | 跟一遍 ep_insert 到 ep_send_events | 能指出 LT/ET 的分叉点 | 4.10.16 | | 复现 LT 与 ET 的差异 | 能解释 ET 漏读 | 4.10.17 | | 量一次空闲 epoll 的成本 | 知道成本与注册数无关 | 4.10.18 | | 读 tcp_info 与 ss -ti | 能说出 cwnd/rtt 的单位 | 4.10.20 | | 区分 SYN/accept/receive 三层队列 | 能对上 ListenOverflows 计数 | 4.10.21 | | 解释 epoll 唤醒 CPU 与 RPS/RFS | 知道什么时候不该开 RPS | 4.10.22 |

即时自测 · 4.10

Q1 read 一个阻塞 socket 时,进程睡在哪个结构的哪个字段上?被唤醒后为什么要重新检查队列?

Q2 epoll 监听 10000 个 fd,其中 3 个就绪。epoll_wait 的时间复杂度是多少?为什么?

Q3 LT 模式下,一个 fd 一直有数据没读完,epoll_wait 会怎样?ET 模式下呢?

Q4 两个线程同时 epoll_wait 同一个实例,一个连接来了数据,可能发生什么?怎么避免?

Q5 close(fd) 之后,epoll 还会报告这个 fd 的事件吗?为什么?

Q6 为什么 epoll_ctl(ADD) 之后可能立刻就能 epoll_wait 到事件?

答案

A1 睡在 struct socket_wq 的 wait 队列上(通过 sk->sk_wq 找到),当前进程被包成一个 wait_queue_entry 挂进去。唤醒后必须重新检查 sk_receive_queue,因为唤醒可能来自信号、超时或虚假唤醒,不保证一定有数据。

A2 O(就绪数),即 O(3)。epoll_wait 只遍历 rdllist,不遍历红黑树上的 10000 个 fd。红黑树只用于 epoll_ctl 的增删查(O(log n))。

A3 LT 下每次 epoll_wait 都返回这个 fd(只要还有数据),直到读完。ET 下只在「状态变化」时返回一次,如果应用没读完,剩余数据不会再触发事件,连接会卡住。

A4 两个线程可能同时拿到同一个 fd 的事件,都去 read,导致一个读到数据、另一个读到 EAGAIN,甚至数据被拆分处理。用 EPOLLONESHOT 让 fd 一次只报告给一个线程,处理完再 EPOLL_CTL_MOD 重新 arm。

A5 会,如果这个 fd 还有别的引用(dup 出来的副本、或者 fork 后的子进程持有)。epoll 的 epitem 指向 struct file,只有所有引用都关闭、file 释放时才真正移除。这是「关了 fd 还在报事件」的常见原因。

A6 因为 ep_insert 里会先调用一次 ep_item_poll 检查当前是否就绪,如果就绪就直接挂到 rdllist。所以「注册时已经就绪」的 fd 不需要等下一次事件。

母本对应

母本章节本书落点程度
N(深入理解 Linux 网络)第 3 章 3.2–3.4(socket 创建、阻塞协作、epoll)4.10.1–4.10.9完整:四层结构、等待队列、回调机制
K(Kerrisk)第 63 章 63.2、63.4(select/poll、epoll 接口与语义)4.10.5–4.10.11完整:LT/ET 语义、EPOLLONESHOT、性能对比
K(Kerrisk)第 56 章 56.2(socket() 与 struct sockaddr)4.10.2精选:创建路径与 fd 语义
C(CSAPP)第 12 章 12.3(I/O 多路复用)4.10.5背景:事件驱动并发的抽象
R(奔跑吧 Linux 内核)第 4 章 4.6(RCU)4.10.4背景:sk_wq 的 RCU 保护
N(深入理解 Linux 网络)第 3 章 3.3–3.4(阻塞与 epoll,PDF p62–99)4.10.15–4.10.18补充:数据结构与回调路径
K(Kerrisk)第 63 章(备选 I/O 模型,PDF p1088–1129)4.10.17、4.10.18精选:LT/ET 的 API 语义
N(深入理解 Linux 网络)第 6 章 6.6(连接队列溢出,PDF p195–202)、第 9 章(RSS/预算/GRO,PDF p269–287)4.10.21、4.10.22完整:队列与收包优化
Linux 6.8 源码include/uapi/linux/tcp.h(struct tcp_info)、net/ipv4/tcp.c(do_tcp_getsockopt)4.10.20完整:字段与单位
W(谢希仁)第 5 章(TCP 流量控制与拥塞控制,PDF p222–270)4.10.21背景:窗口与队列
C(CSAPP)第 11 章 11.4(套接字接口)4.10.17背景:阻塞 I/O 的对照

下一站:本机两个进程通信,为什么要绕一圈协议栈?连接队列到底能排多长?一台机器真的能撑百万连接吗?进入 4.11 本机网络、连接队列与百万连接。