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 file | include/linux/fs.h | f_op、private_data | VFS 抽象,fd 指向它 |
struct socket | include/linux/net.h | ops、sk、flags | 协议无关的 socket 接口 |
struct sock | include/net/sock.h | 队列、等待队列、状态 | 协议相关的连接状态 |
struct tcp_sock | include/linux/tcp.h | snd_una、rcv_nxt、cwnd | TCP 专有字段(嵌在 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:
- 构造一个
wait_queue_entry,把当前进程的task_struct挂上去。 - 设置进程状态为
TASK_INTERRUPTIBLE。 - 调用
schedule()让出 CPU。 - 被唤醒后,从
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 上的回调 | 唤醒后发生什么 |
|---|---|---|
阻塞 read | autoremove_wake_function | 进程被唤醒,重新检查队列 |
select/poll | pollwake | 唤醒调用 select 的进程 |
epoll | ep_poll_callback | 把 epitem 挂到 rdllist,唤醒 epoll_wait |
| 信号驱动 | sock_wake_async | 发 SIGIO |
这张表解释了 epoll 的第一个优势:它不唤醒进程,只挂一个节点。1000 个 fd 就绪时,阻塞模型要唤醒 1000 个进程(或一个进程 1000 次),epoll 只往链表上挂 1000 个节点,等 epoll_wait 一次性取走。
4.10.5 epoll 的三个数据结构
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 都会返回它。
水平触发(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 耗尽。
- 不循环读。只读一次,剩下的数据留在缓冲区,但没有新事件,连接就「卡住」了。
- accept 不循环。ET 下 listen socket 只在「新连接到达」时报告一次,一次 accept 只取一个,剩下的连接要等下一次事件。
- 用阻塞 fd。ET 要求读到
EAGAIN,阻塞 fd 会卡在read上,整个事件循环停摆。 - 忽略
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 对比时才清楚。三者的接口长得像,但每次调用的成本完全不同。
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | FD_SETSIZE(通常 1024) | 无硬上限 | 无硬上限 |
| 每次调用传什么 | 位图 fd_set(3 个) | pollfd 数组 | 无(注册一次) |
| 内核每次做什么 | 拷贝位图 + 遍历所有 fd | 拷贝数组 + 遍历所有 fd | 只取就绪链表 |
| 就绪检测 | O(n) | O(n) | O(就绪数) |
| 返回什么 | 位图,应用还要再扫一遍 | 数组(revents 被改写) | 就绪数组 |
| 水平/边缘触发 | 只有 LT | 只有 LT | LT + 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 是因为它「没有 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 让每个进程独立监听,内核按四元组哈希分流,看起来完美。但它有两个坑:一是哈希固定,某些源 IP 段可能集中落到一个进程;二是进程重启时连接会断(内核把该进程的队列一起销毁)。所以它适合「无状态、可重连」的服务,不适合长连接网关。
4.10.15 epoll 的三个结构体与内存账
4.10.5 说 epoll 有「红黑树 + 就绪链表 + 等待队列」,这一节把三个结构体的字段与内存占用拆开。理解它们,才能解释「百万 fd 的 epoll 为什么只占几十 MB」。
| 结构体 | 头文件 | 一个实例代表 | 数量级 |
|---|---|---|---|
struct eventpoll | fs/eventpoll.c | 一个 epoll 实例(epoll_create 的返回值) | 每进程几个 |
struct epitem | 同上 | 一个被监听的 fd | 等于注册的 fd 数 |
struct eppoll_entry | 同上 | 一次 poll_wait 注册 | 每个「有等待队列的 fd」一个 |
eventpoll 的关键字段:
| 字段 | 类型 | 作用 |
|---|---|---|
mtx | struct mutex | 保护 epoll_ctl 的增删改 |
wq | wait_queue_head_t | epoll_wait 睡眠的队列 |
poll_wait | wait_queue_head_t | 嵌套 epoll 时父实例等待子实例 |
rdllist | struct list_head | 就绪链表,epoll_wait 从这里取 |
rbr | struct rb_root_cached | 红黑树根,按 (file, fd) 排序 |
ovflist | struct epitem * | 溢出链表,回调在中断上下文时暂存 |
user | struct user_struct * | 记账到用户,受 max_user_watches 限制 |
refcount | refcount_t | 实例引用计数 |
epitem 是「fd 的注册项」:
| 字段 | 类型 | 作用 |
|---|---|---|
rbn | struct rb_node | 红黑树节点 |
rdllink | struct list_head | 就绪链表节点 |
ffd | struct epoll_filefd | {file, fd},红黑树的键 |
nwait | int | 该 fd 上注册了多少个等待队列项 |
pwqlist | struct list_head | eppoll_entry 链表 |
event | struct epoll_event | 用户注册的 events 与 data |
ep | struct eventpoll * | 指回所属实例 |
eppoll_entry 是把 epitem 挂到目标文件等待队列上的「钩子」:
| 字段 | 作用 |
|---|---|
wait | wait_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_insert | fs/eventpoll.c | 分配 epitem,插入红黑树 |
| 挂等待队列 | ep_item_poll → vfs_poll → sock_poll | 同上 / net/socket.c | 调目标文件的 poll,注册 eppoll_entry |
| 事件到达 | sock_def_readable → wake_up → ep_poll_callback | net/core/sock.c / eventpoll.c | 把 epitem 挂到 rdllist,唤醒 ep->wq |
| 取事件 | epoll_wait → ep_poll → ep_send_events | eventpoll.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 重新注册。它适合多线程「一个连接同时只有一个线程处理」的模型。
三个阶段的观测点:
| 观测 | 手段 | 说明 |
|---|---|---|
| 注册了多少 fd | cat /proc/PID/fdinfo/EPFD | tfd: 行数 |
| 回调频率 | bpftrace kprobe:ep_poll_callback | 按进程统计 |
| 每次取多少事件 | strace -e epoll_wait | 返回值是事件数 |
| 是否空转 | perf top 看 ep_send_events | 空转时 epoll_wait 立即返回 0 |
epoll_wait的maxevents不是「最多注册数」:它限制单次返回的事件数,注册数受max_user_watches限制。- LT 不是「每次
epoll_wait都返回所有就绪 fd」:它只在rdllist里有条目时返回;条目由回调或上一次扫描放回。 - ET 下
EPOLLOUT可能只触发一次:连接可写后不再变化,必须在需要时主动写,或重新MOD。 EPOLLERR/EPOLLHUP总是被监听:即使用户没注册,内核也会报告,必须处理,否则会忙循环。- 嵌套 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 -tn | sock_diag netlink | 低 | 普通用户看自己的,root 看全部 |
| 单条连接的 RTT、cwnd、重传 | ss -ti | TCP_INFO | 低 | 同上 |
| 包级时序、谁先发谁后发 | tcpdump | AF_PACKET | 高(每包一次拷贝) | CAP_NET_RAW |
| CPU 花在哪个内核函数 | perf top/record | perf_event | 中(采样) | perf_event_paranoid |
| 函数调用次数/延迟分布 | bpftrace | kprobe/tracepoint | 中高(每事件) | root / CAP_BPF |
| 协议栈计数 | nstat / /proc/net/netstat | per-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_rate | B/s | 交付速率(BBR 用) | 与 pacing_rate 对照 |
tcpi_pacing_rate | B/s | 当前 pacing 速率 | BBR/fq 下有效 |
tcpi_busy_time | µs | 忙于发送的时间 | 判断发送端是否瓶颈 |
tcpi_rwnd_limited | µs | 因接收窗口受限的时间 | 对端读得慢 |
tcpi_sndbuf_limited | µs | 因发送缓冲受限的时间 | 本端写得多 |
tcpi_min_rtt | µs | 观测到的最小 RTT | BBR 的基准 |
一个最小程序(连接后打印关键字段):
/* 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 是微秒,不是毫秒。
- 把
tcpi_snd_cwnd当字节:它是「包数」,乘tcpi_snd_mss才是字节;ss -ti的cwnd:10也是包。 - 把
tcpi_rtt当毫秒:单位是微秒;ss -ti显示rtt:0.038/0.019时单位是毫秒,换算关系别弄反。 len传太大/太小:内核只拷贝min(len, sizeof(struct tcp_info));传太小会缺字段,传太大会被截断,不报错。- 在
SYN_SENT上读TCP_INFO:很多字段还是 0,tcpi_state能告诉你连接没建好;先确认状态再解读。 - 把
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_wmem | write 阻塞/EAGAIN | ss -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 行
| 计数 | 含义 | 处理 |
|---|---|---|
ListenOverflows | accept 队列满,已完成连接被丢 | 调大 somaxconn/backlog,或加快 accept() |
ListenDrops | accept 队列满导致的各种丢弃总和 | 同上,结合 tcp_abort_on_overflow |
TCPReqQFullDrop | SYN 队列满,SYN 被丢 | 调大 tcp_max_syn_backlog,开 syncookies |
SyncookiesSent | 用 cookie 回复 SYN | SYN 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_CPU | socket | getsockopt(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:RSS 已经把流分到不同队列,RPS 只会多一次 IPI;先
ethtool -l看队列数。 rps_cpus写十进制:位掩码按十六进制解析,10是 CPU4 不是 CPU1/CPU2。- 只开 RPS 不开 RFS:
rps_flow_cnt=0时 RFS 不工作,收包 CPU 不会跟随应用 CPU。 SO_INCOMING_CPU当固定值:它随最近收包变化;连接迁移后旧值会误导绑核。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 的 @us | Recv-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 本章陷阱清单
- ET 模式不读到
EAGAIN:边沿只通知一次,剩余数据会永久滞留。 - LT 模式在就绪时反复
epoll_wait不处理:每次立即返回,CPU 空转。 EPOLLONESHOT后忘记EPOLL_CTL_MOD:连接再也收不到事件。- 不处理
EPOLLERR/EPOLLHUP:内核总会报告,忽略会忙循环或泄漏连接。 dup后以为是一个注册项:epoll 的键是(file, fd),dup出不同 fd 会有两个epitem,事件可能重复。fork后父子共享 epoll 兴趣列表:两边都epoll_wait会争抢事件(惊群),需要EPOLLEXCLUSIVE或单进程 accept。- 关闭 fd 就以为自动注销:只有最后一个引用关闭才移除;
dup或fork持有的 fd 会让epitem继续存在。 epoll_wait超时单位当秒:第三个参数是毫秒,-1表示永久阻塞。max_user_watches当 fd 上限:它限制epitem数,和ulimit -n是两回事。- 嵌套 epoll 层级过深:每层多一次回调与唤醒,延迟累加,且调试困难。
- ET +
EPOLLOUT只注册一次:可写状态不会重复变化,发送缓冲满后要等EPOLLOUT必须重新MOD或先写失败再等。 - 用
epoll_ctl监听普通文件:普通文件永远就绪,LT 下会立刻忙循环;epoll 只适合流式/事件型 fd。 - 把
ss -ti的cwnd当字节:它是包数;rtt的单位在ss里是毫秒、在tcp_info里是微秒,换算别弄反。 - 用
tcpdump证明「包没到」:XDP 丢弃、硬件卸载、抓包 ring 满都可能让包不出现;先看bpftool net show与tcpdump自己的丢包提示。 - 只开 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 都到 EAGAIN | 4.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 |
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 本机网络、连接队列与百万连接。