本章把"一个 int 如何代表一个打开的文件"与"百万连接如何被一个线程照看"连成一条自洽的链路。

学习目标

读完本章,你应当能够:

  1. 画出 fd 表 → struct file → inode 三层结构,并据此推导 open / dup / fork 之后 offset 是独立还是共享;
  2. 说明 fd 的分配规则与耗尽后的故障现象,并用 /proc/[pid]/fd 与 /proc/[pid]/fdinfo 定位泄漏;
  3. 辨析 VFS 四类对象的职责边界,推导硬链接为什么不能跨文件系统、不能指向目录;
  4. 逐条对比 select / poll / epoll,并画出 eventpoll 的红黑树、就绪链表与回调路径;
  5. 写出一段正确的 ET 模式代码,解释"非阻塞 + 循环读到 EAGAIN“为什么是逻辑必要条件,并复述三种零拷贝实现各自每一步数据在哪、DMA 参与几次、几次上下文切换。

前置知识检查

你需要知道在哪一节补齐
文件系统与 inode 的基本概念、open/read/write 接口本章 7.1 ~ 7.3 节从头推导,无前置依赖
用户态/内核态切换、系统调用的完整路径见第 9 章 9.2 节;本章 7.5、7.9 节只统计"次数"并说明它为什么贵
page cache 的回收与脏页回写、直接 I/O见第 12 章 12.2 节;本章 7.7 节只讲 mmap 与 O_DIRECT 的取舍
阻塞/唤醒与等待队列的语义见第 6 章 6.3 节;本章 7.5 节只讲就绪队列与等待队列在 epoll 中的分工
进程的 fork 语义与写时复制见第 4 章 4.2 节;本章 7.1.2 节只取"fd 表被复制"这一条结论

7.1 三层结构:一个 int 背后的三张表

int fd 是进程内数组的下标,不是全局标识符。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
一个 fd 索引被三层结构逐级翻译的过程

  进程 A files_struct        系统级(与任何进程无关)
  ┌────────────────┐ ┌────────────────────────────────────┐
  │ 0 ─────────────┼▶│ struct file #1                     │
  │ 1 ─────────────┼┐│ f_pos=100  f_flags=O_RDWR|O_APPEND │
  │ 2 ─────────────┼┼▶│ f_count=2  ← 引用计数             │
  │ 3 ─────────────┼┼┐│ f_inode ───────────┐            │
  └────────────────┘││└────────────────────┼────────────┘
  进程 B(fork/dup)││ ┌────────────────────┼────────────┐
  ┌────────────────┐│└▶│ struct file #2     │            │
  │ 0 ─────────────┼┘  │ f_pos=0 ← 独立偏移 │            │
  │ 3 ─────────────┼──▶│ f_count=1          │            │
  └────────────────┘   └────────────────────┼────────────┘
                       ↑ 父子 fd 3 指向同一个 file
                                             ▼
   系统级 inode 表(唯一):i_ino=131074 i_size=4096 i_nlink=1
   i_mapping ─▶ page cache(文件页)   i_sb ─▶ superblock
  ★ 关键:f_pos 在 struct file 里,不在 inode 里。谁共享 file,谁就共享偏移。

表 7-1 三层结构各自负责什么

层次内核对象承载的状态谁在什么时候创建
fd 表files_struct / fdtablefd → struct file * 的映射fork 复制、exec 保留(除非 FD_CLOEXEC)
打开文件表struct filef_pos、f_flags、f_count、f_op每次 open/accept/pipe/socket 产生一个
inode 表struct inode元数据、i_nlink、页缓存指针首次访问该 inode 时读入并缓存

7.1.1 open 两次:两个 file,两个 offset

1
2
3
4
5
int fd1 = open("log.txt", O_RDWR);   // 生成 file#1,f_pos = 0
int fd2 = open("log.txt", O_RDWR);   // 生成 file#2,f_pos = 0,与 fd1 无关
read(fd1, buf, 10);                  // 只有 file#1.f_pos → 10
read(fd2, buf, 10);                  // 读到的仍是文件开头 10 字节
dup(fd1);   // dup 不新建 file,只把同一指针写入 fd 表另一槽位,f_count 加一

两次 open 即使指向同一个 inode,也各自持有一个 struct file,因此两个 fd 可以独立地从头读同一个文件;它们只在最底层共享 inode 与 page cache,第二次读不走磁盘。

7.1.2 dup 与 fork:共享同一个 file、同一个 offset

1
2
3
4
5
6
7
fork:子进程复制父进程整个 fd 表(数组内容是指针),所有 struct file 的 f_count 加一
"父写子读会串位"的因果链
  父 fork  ──▶ 父子 fd 表同址 → 同一个 struct file → 同一个 f_pos
  父 write(fd,"AAA",3)  ⟹  f_pos: 0 → 3
  子 read(fd,buf,3)     ⟹  从 f_pos = 3 读!拿到的是文件第 4~6 字节
  ★ f_pos 不是"每个进程各自的读写位置",而是"每个 struct file 的位置"。
    这就是"父子进程各写一半日志会互相覆盖半行"的根因。

重要

三条语义必须一次说清:open 两次 → 两个 file、两个独立 f_pos;dup → 同一个 file、共享 f_pos 与 f_flags;fork → 同样共享 f_pos。由此推出常被忽略的一条:O_NONBLOCK 这类打开标志也在 struct file 上,不能用 fcntl 单独把 dup 出来的某一个 fd 改成非阻塞——这正是 7.6 节必须统一设置标志、或各自 open 的原因。 [!NOTE] 面试官会追问的下一层:多进程追加写同一日志文件为什么一般不会撕裂? 因为 O_APPEND 的"定位到末尾 + 写"在内核里是原子完成的,与"先 lseek 再 write“这种两次系统调用的做法完全不同——后者两个进程会读到同一个末尾位置而互相覆盖。依据是 open(2) 对 O_APPEND 的原子性保证。

7.2 文件描述符的分配规则与耗尽

fd 的分配规则是最小可用:内核从 0 开始扫描位图,返回第一个未占用的整数。两个后果:fd 号会被复用,因此"把 fd 缓存在变量里、跨一次 close 后继续用"是典型悬垂引用,表现为数据莫名写到另一个文件;范围与容量由 RLIMIT_NOFILE 决定,软限制默认 1024。

1
2
3
4
5
6
7
8
9
ulimit -n                 # 软限制(bash 默认 1024);ulimit -Hn 看硬限制
ulimit -n 65535           # 为什么先调高:泄漏在 1024 时几分钟就撞墙,调高后
                          # 才能把"上限低导致的偶发失败"与"真正的泄漏"区分开
cat /proc/sys/fs/file-nr  # 系统级:已分配 / 未使用 / fs.file-max 上限
ls /proc/12345/fd | wc -l # 当前 fd 数量:泄漏排查第一步永远是"有多少、涨不涨"
ls -l /proc/12345/fd      # 指向 socket / 普通文件 / 管道 / anon_inode
cat /proc/12345/fdinfo/7  # pos、flags、mnt_id —— pos 就是 f_pos
lsof -p 12345 -n | awk '{print $5}' | sort | uniq -c | sort -rn
                          # 为什么加 -n:海量连接下 DNS 反解会让 lsof 卡住几十秒

表 7-2 fd 耗尽的三个层次与典型根因

层次上限来源默认量级撞上时的错误典型根因
进程RLIMIT_NOFILE(软限制)1024EMFILE连接/文件未 close,异常路径漏关
系统/proc/sys/fs/file-max与内存相关,数十万到数百万ENFILE多进程同时持大量 fd
内核内存file 结构体与 dentry 缓存——分配失败、OOM打开海量不同路径文件,dentry 无法回收

EMFILE 是本进程超限、ENFILE 是全系统超限。判据是时间维度上的稳定性:配置不足表现为 fd 数在稳态水平上下波动;泄漏表现为 fd 数单调上升、从不回落,或在特定操作后阶梯式上升。

警告

“fd 耗尽就调大 ulimit -n“只是延后崩溃时间。必须区分"确实需要更多连接"与"泄漏”:泄漏场景下调大上限会让进程更久地持续泄漏并占用更多内核内存,最终以更难看的方式(OOM 或全系统 ENFILE)失败。注意 systemd 的 LimitNOFILE 与容器运行时的限制会覆盖 shell 设置。

7.3 VFS 的四类对象与链接的本质

**虚拟文件系统(virtual file system, VFS)**让 open/read/write 这套接口对 ext4、XFS、NFS、/proc、socket、管道都成立,靠四类对象与操作表完成抽象。

表 7-3 VFS 四类对象的职责与两种链接的对比

对象 / 维度结构体或本质生命周期磁盘实体核心职责
超级块super_block挂载期间常驻文件系统超级块文件系统类型、块大小、根 dentry、s_op
inodeinode被引用期间缓存磁盘 inode元数据、i_nlink、页缓存指针、i_op/i_fop
dentrydentryLRU 缓存目录项“名字 → inode"的绑定,构成目录树
filefile每次 open 一个无(纯内存)f_pos、f_flags、f_op
硬链接目录项 → inode 的第二条路径随目录项仅一个目录项i_ino 与目标相同,i_nlink 加一
软链接独立文件,内容是路径字符串独立 inode一个 inode + 数据块目标可不存在;可跨文件系统、可指向目录

dentry 缓存(dcache)的意义:路径解析的本质是逐级查目录。若每次都读目录文件再查找名字,每次 open 都要多次磁盘 I/O。dcache 把"名字 → inode"的绑定缓存在内存里,使路径解析通常在内存中完成;它可被回收(所以 slabtop 里 dentry 占用高不等于泄漏)。

7.3.1 硬链接与软链接的本质差异

1
2
3
4
5
6
7
8
9
硬链接:两个目录项指向同一个 inode
  /data/a.txt ──┐                ★ 没有"谁是原件";删掉任一名字只是 i_nlink 减一,
                ├──▶ #131074        减到 0 才释放数据(引用计数式生命周期)
  /data/b.txt ──┘
软链接:独立文件,内容是"目标路径字符串"
  /data/link.txt ──▶ 自身 #131080,数据 = "/data/a.txt"(12 字节)
                       └─ 解析时读出字符串、再走一次路径解析 ──▶ #131074
  ★ 目标可不存在(悬垂);可跨文件系统、可指向目录;解析深度上限 40 次嵌套。
  ★ 跨文件系统与指向目录这两条,恰好就是硬链接做不到的两条 —— 见 7.3 节表格。

为什么硬链接不能跨文件系统? 因为硬链接的语义是"目录项里存一个 inode 号”,而 inode 号只在单个文件系统内有意义——它是该文件系统 inode 表的索引,不同文件系统各自从 1 开始编号。若允许跨挂载点建立硬链接,目录项里的编号会被解释成另一个文件系统 inode 表中的条目,指向完全无关的数据。软链接不受此限,因为它的内容是路径字符串,解析时由 VFS 从起点重新逐级查找。

为什么硬链接不能指向目录? 其一是环:若允许指向目录,就能制造 a/b → a 这样的循环,find、du -s、递归删除都会陷入无限递归或重复计数。其二更根本:目录树必须是每个目录只有一个父的有向无环图,这是路径解析唯一性与 .. 语义的前提。软链接之所以安全,是因为解析器有深度上限(ELOOP),且递归工具默认不跟随。

小心

把"硬链接不能跨文件系统"答成"跨文件系统要拷数据、代价大"是完全错误的:硬链接本身不拷贝任何数据。正确答案是"inode 号只在单个文件系统的 inode 表内唯一”。同理,把"不能指向目录"答成"空间浪费"或"历史遗留限制”,会被立刻追问"那 .. 为什么可以”——.. 是内核创建目录时自动维护的特殊条目,并不走通用的 link 接口。

7.4 select / poll / epoll 的完整演进

多路复用解决的问题可以一句话定义:一个线程要照看 N 个 fd,既要不错过任何一个就绪事件,又不能阻塞在任何一个上。

7.4.1 select 的三个硬伤与 poll 的改进

1
2
3
4
5
fd_set rfds;                 // 位图,每一位对应一个 fd 号
FD_ZERO(&rfds); FD_SET(sockfd, &rfds);
int n = select(sockfd + 1, &rfds, NULL, NULL, &tv);
// poll 的改进:struct pollfd pfds[2]; pfds[0]={sockfd,POLLIN,0};
// poll(pfds, 2, timeout_ms) —— 数组替代位图,去掉 1024 上限,events/revents 分离

三个硬伤都可以从类型定义推出来:fd_set 是定长位图,FD_SETSIZE 在 glibc 中为 1024,位图按 fd 号索引,fd 号超过 1023 就越界,所以"1024 上限"是位图类型与 ABI 的直接结果;每次调用都要把 fd_set 拷进内核、返回时拷回用户态,且返回的集合被就地改写,用户态必须每次重新构造(这就是每次都要重新 FD_SET 的由来);返回后只知道"有事件",用户态必须再线性扫描 0 ~ maxfd 逐位判断。再加上内核也必须线性扫描位图,select 在内核与用户态各扫一遍。

poll 只去掉了位图的 1024 限制,没有解决两个根本问题:每次调用仍需把整个数组拷进内核(O(n)),内核仍要遍历整个数组逐个查状态(O(n))。瓶颈是 O(n) 的扫描与拷贝,不是 1024 这个数字。

7.4.2 epoll 与三者的完整对比

1
2
3
4
int epfd = epoll_create1(0);                     // ① 创建 eventpoll 对象
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sockfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);     // ② 注册一次,此后不再重复传
int n = epoll_wait(epfd, events, MAXEV, -1);     // ③ 只取"就绪的那几个"

epoll_wait 直接返回就绪的 fd 与事件,无需用户态再扫描全部 fd。这就是"O(1) 就绪返回"的实际含义:返回代价与该次就绪的 fd 数成正比,与注册的总 fd 数无关。它并非"任何操作都 O(1)"——注册与注销是 O(log n)。

表 7-4 select / poll / epoll 的逐项对比

维度selectpollepoll
数据结构fd_set 定长位图pollfd 数组内核 eventpoll(红黑树 + 就绪链表)
fd 数量上限1024(FD_SETSIZE)无固定上限,受 RLIMIT_NOFILE 限制无固定上限,受 RLIMIT_NOFILE 与 max_user_watches 限制
每次调用用户→内核拷贝整个位图 O(n)整个数组 O(n)无(注册阶段已拷贝)
内核扫描成本O(n) 遍历位图O(n) 遍历数组无扫描;回调驱动 O(k)(k 为就绪数)
用户态定位就绪 fd需再扫 O(n)需再扫 O(n)直接给出 O(k)
事件长期有效否,每次重新构造否是;fd 关闭时自动摘除
触发模式只有 LT只有 LTLT 与 ET 可选
合计代价O(n) 拷贝 + O(n) 扫两遍同 select,但无 1024 限制epoll_ctl O(log n),epoll_wait O(k)

重要

面试时必须把"epoll 比 select 快"拆成可验证的三层,否则会被追问到答不上来:①省掉每次调用的 O(n) 用户态↔内核拷贝;②省掉内核侧的 O(n) 扫描(改为事件回调,只有活跃 fd 产生工作);③省掉用户态返回后的 O(n) 定位扫描。至于 1024 上限,只是 select 位图类型的副作用,不是性能差异的主因——把重点放在这个数字上会被反例打回(poll 没有上限却依然慢)。

7.5 epoll 的内部实现

三个系统调用分别对应 eventpoll 对象的三块数据结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
epoll 的三块数据结构与回调路径(这才是 epoll_wait 为 O(k) 的根因)

  epoll_create1 → struct eventpoll
    rbr(红黑树): 节点 = epitem{fd, event, 回调入口}
       (10)  键 (fd, struct file*) 有序,插入/删除 O(log n)
      /    \   ★ 服务于 epoll_ctl 的快速定位与去重
    (7)    (15)
    / \    /  \
  (3)(9)(12)(18)
  epoll_ctl(ADD) 额外做一件事:把 ep_poll_callback 注册到该 fd 对应文件的
    等待队列上(socket 即 sk_wq)—— 从此内核不再轮询,而是"数据到达时回调"。

  epoll_ctl → rdllist(就绪链表,双向): ep9 ⇄ ep18 ⇄ ep3 → NULL
    ★ 只挂"已就绪"的 epitem,epoll_wait 直接摘取
  epoll_wait → wq(等待队列): 链表为空时,调用线程挂此睡眠

  ①网卡收包→硬中断→软中断(NAPI)→协议栈,见第 9 章 9.1 节
  ②数据入 socket 接收队列,socket 变"就绪"  ③协议栈调 sk_data_ready()
  ④该 socket 等待队列上的回调被逐个调用  ⑤其中一个是 ep_poll_callback(epitem)
  ⑥它把该 epitem 挂入 rdllist(已在链表中则跳过 ⟹ 事件合并)
  ⑦唤醒阻塞在 eventpoll.wq 上的线程  ⑧epoll_wait 摘取就绪项,返回到用户态
  ★ 工作是在 ⑥ 做的,不是在 ⑧ 做的 ⟹ 代价只与"这次真正就绪了多少 fd"有关。

三个系统调用的分工:epoll_create1 初始化红黑树、就绪链表、等待队列(O(1));epoll_ctl(ADD/MOD/DEL) 在红黑树上建/改/摘 epitem 并注册或注销回调(O(log n)),关闭 fd 后不 DEL 会在 ET + dup 场景留下幽灵事件;epoll_wait 从就绪链表摘取 O(k) 个条目,每次事件只读一次就返回会在 ET 下丢事件(见 7.6 节)。

注意

面试官会追问的下一层:红黑树到底存了什么,为什么必须用它? 树节点是 epitem,键是 (fd, struct file *) 组合。需要 O(log n) 查找结构,是因为 epoll_ctl(DEL) 与 MOD 必须能快速定位到要操作的那一项;若用链表,注销会退化为 O(n),在 fd 频繁上下线的短连接场景里就退化成了另一种 select。反之若每秒新建/销毁几万个连接,epoll_ctl 的 O(log n) 与 fd 分配开销同样不可忽略——这正是 SO_REUSEPORT 多队列与多线程 epoll 分流的动因。

7.6 LT 与 ET:实现差异与正确的代码写法

两种触发模式的差异,本质是**“回到用户态之前要不要把就绪状态清掉”**:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
LT(水平触发,默认):条件持续满足就持续通知
  【就绪入链】─▶【返回就绪】─(还剩 1000 B 未读)─▶【再次返回】─ ...
  ★ 读不干净也安全;代价是 fd 长期未读干净时 epoll_wait 一直返回它(忙转)。
ET(边缘触发,EPOLLET):只在就绪状态跃迁的那一刻通知一次
  【未就绪】─数据到达(0→有)─▶【通知一次】─读了 200 B─▶【不再通知】
                                    (还剩 800 B,状态没跃迁)
  ★ 剩下 800 B 必须自己循环读完;之后若又有新数据,会再触发一次边沿。
ET + 阻塞 fd 的死锁:最后一次 read 时数据已读完,阻塞 read 无法返回 EAGAIN
  ⟹ 线程在内核睡眠等"下一次数据到达",而该边沿本来要靠它去 epoll_wait 才能
    拿到 ⟹ 线程卡死在 read 上,事件循环停摆,该线程照看的连接全部饿死。
  ★ 所以"ET 必须非阻塞"是逻辑必要条件:只有非阻塞 read 能用 EAGAIN 告诉你
    "这一轮读完了",你也才有机会回 epoll_wait 等下一次边沿。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 为什么循环 + 判定 EAGAIN:ET 模式下内核只通知一次,
// "读多少"必须由应用负责到"确认再无数据"为止。
void handle_read(int fd) {                       // fd 必须在 epoll_ctl 之前设好
    char buf[8192];                              // fcntl(fd,F_SETFL,fl|O_NONBLOCK)
    for (;;) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n > 0) { process(buf, n); continue; }    // 关键:不 break,继续榨干
        if (n == 0) { close(fd); return; }           // 0 是 EOF(对端关闭)
        if (errno == EINTR) continue;                // 被信号打断不算读完
        if (errno == EAGAIN || errno == EWOULDBLOCK) return;  // 唯一正确退出条件
        perror("read"); close(fd); return;           // 真错误
    }
}
// event_loop 里对每个就绪项分派,并必须处理 EPOLLHUP/EPOLLERR
// (没在 events 里申请也会被返回,忽略它会导致 fd 永不关闭、泄漏累积)
// 为什么用 EPOLLET:长连接下避免"未读干净的 fd 反复被返回"。

表 7-5 LT 与 ET 的选择判据

维度LTET
通知条件只要就绪(有数据可读 / 可写)就通知仅就绪状态发生跃迁时通知一次
fd 是否必须非阻塞不必须,但强烈建议必须
是否必须循环读到 EAGAIN不必须,逻辑更简单必须
读写语义可"读一部分就返回",下次再读必须一次事件内读到 EAGAIN;写要处理 EAGAIN 后补写
事件风暴风险有(未读干净的 fd 反复返回)低
典型使用者多数简单服务、libevent 默认Nginx、Redis、Netty 的 EpollEventLoop

警告

三个高频错误:①**“ET 更快所以应该总用 ET”——ET 的快只体现在"省掉重复返回",若应用每次都能读干净,两者的系统调用次数相同;ET 的代价是必须自己维护写队列。②“ET 下 read 返回 0 表示没数据”——返回 0 是对端关闭连接**(EOF),返回 -1 且 errno == EAGAIN 才是"暂时没数据";混淆两者会把小包读取误判为连接关闭而错关连接。③**“ET 下不用处理 EPOLLOUT”**——发送缓冲区满时 write 返回 EAGAIN,必须等 EPOLLOUT,并注意"加 EPOLLOUT 处理完再摘掉"的两次 epoll_ctl 开销。

7.7 mmap 与 read、page cache 与 O_DIRECT

page cache 是内核用空闲内存缓存文件页的机制:read 先查页缓存,命中则直接拷到用户缓冲区,未命中则从磁盘读入页缓存再拷。因此 buffered I/O 的一次读至少要两次数据搬运(磁盘→页缓存、页缓存→用户态)。见第 12 章 12.2 节。mmap 的取舍可以精确表述为:把"拷贝数据"换成"建立页表映射 + 缺页时按需装入"。

表 7-6 read/write 与 mmap 的取舍

维度read/write(buffered)mmap + 内存访问
数据拷贝每次调用都有页缓存↔用户态的一次拷贝建立映射后按页访问,无显式拷贝,缺页时装页
系统调用次数每次读/写一次一次 mmap,之后是普通访存指令
适合的访问模式大块顺序流式处理、小文件随机访问、反复访问同一区域(索引文件、共享库)
错误处理返回错误码越界、I/O 错误或文件被截断时收到 SIGBUS

write 返回成功不等于落盘:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
write(fd, buf, n) 返回 n 之后,数据处于哪一层

  用户缓冲区 ─copy─▶ page cache(内存)─▶ 脏页标记 ─回写─▶ 磁盘/SSD 介质
       │                  │                  │                │
       │            write 返回成功            │                │
       │      (数据已进内核,对进程自身可见)  │                │
       │                    fsync()/fdatasync() 返回           │
       │                    (数据已到设备)                     │
       └────────── 顺序:write < fsync < 掉电安全 ──────────────┘
  ★ fsync 返回也不保证到达盘片持久介质:需硬件写缓存配合 FUA /
    写屏障,或在设备层禁用写缓存,才能保证掉电不丢。

O_DIRECT 的取舍:它绕过 page cache,让读写直接在用户缓冲区与设备间进行(偏移、长度、缓冲区地址通常需按逻辑块大小对齐,常见 512 B 或 4 KB)。收益是消除页缓存的拷贝与内存占用,适合自管缓存的数据库(它们已有 buffer pool,再让内核缓存一份是纯粹的浪费)。代价是失去预读、失去写合并、失去内核 I/O 调度优化,并且与 buffered I/O 混用同一文件时没有一致性保证。

重要

“write 返回成功数据就安全了"是会被面试官直接打回的说法。标准答法分三档:write 只保证写进了内核 page cache(进程视角可见,掉电即丢);fsync 保证把脏页刷到存储设备;设备是否写进持久介质还取决于硬件写缓存策略。因此数据库提交、消息队列刷盘必须显式 fsync,并接受每次 fsync 的毫秒级延迟——这也是组提交(group commit)存在的理由,见第 21 章 21.2 节。

7.8 零拷贝的三种实现与 DMA 参与的数据流

零拷贝(zero-copy)的准确含义是消除 CPU 参与的数据拷贝(DMA 拷贝不算 CPU 拷贝),而不是"一次拷贝都没有”。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
【方案一】传统 read + write:4 次拷贝(2 DMA + 2 CPU)+ 4 次上下文切换
  ①DMA 磁盘→page cache  ②CPU page cache→用户缓冲区  ③read 返回(切回用户态)
  ④CPU 用户缓冲区→socket 发送缓冲区  ⑤DMA socket 缓冲区→网卡  ⑥write 返回
  ★ 用户缓冲区只是"中转站",数据完整走了一遍却又原样送出。
【方案二】mmap + write:3 次拷贝(1 CPU + 2 DMA)、2 次上下文切换
  ①mmap 建立映射(一次性)  ②CPU page cache→socket 发送缓冲区  ③DMA socket→网卡
  ★ 收益:省掉"页缓存→用户缓冲区"那一跳(两者共享同一份物理页);残留:向
    socket 写,内核仍要拷进 socket 缓冲区再交给 DMA —— 这一跳还在。
【方案三】sendfile:无 DMA 收集 3 次拷贝,有 DMA 收集(SG-DMA)2 次
  (a) 不支持 scatter-gather:①DMA 磁盘→page cache ②CPU page cache→socket
      发送缓冲区 ③DMA socket→网卡 ⟹ 3 次拷贝(1 CPU + 2 DMA),数据不出内核
  (b) 支持 scatter-gather DMA(现代网卡普遍支持):
      ①DMA 磁盘→page cache ②★CPU 只拷"描述符"(页地址+长度)到 socket 缓冲区,
        数据本身一个字节都不动 ③SG-DMA 按描述符从 page cache 直接→网卡
      ⟹ 2 次拷贝(0 CPU 数据拷贝 + 2 DMA)★ 这才是真正的"零 CPU 拷贝"
      ★ 这就是 sendfile 传静态大文件时 CPU 占用极低的原因,也是 Nginx 的
        sendfile 配置项背后的机制。

splice 与管道:sendfile 要求目标必须是 socket。splice 用管道作为中转,在两个 fd 之间移动数据:从源 fd(如文件)splice 到管道时,内核把 page cache 中页的引用挂到管道缓冲区;再从管道 splice 到目标 fd(如 socket)时把同一批页的引用交给 socket。全程不经过用户态、不做数据拷贝,只搬运页引用与描述符;配合 vmsplice 还能把用户态页接入这条链。代价是管道容量(默认 64 KB,可用 F_SETPIPE_SZ 调整)成为吞吐瓶颈之一。

表 7-7 四种数据传输方式的开销对比

方式CPU 数据拷贝DMA 拷贝上下文切换数据经用户态
read + write224是
mmap + write122元数据经,数据页共享
sendfile(无 SG-DMA)122否,文件→socket
sendfile + SG-DMA022否,文件→socket
splice(经管道)0(只搬页引用)22否,任意 fd→fd

提示

面试话术模板:先说"零拷贝指的是零 CPU 拷贝,DMA 拷贝无法避免",再按"拷贝次数 / 上下文切换次数 / DMA 参与次数"把表 7-7 默画出来,最后补一句"sendfile 的 3 次与 2 次之分取决于网卡是否支持 scatter-gather DMA"。能主动说出"SG-DMA 让 CPU 只搬描述符不搬数据",是这一题的分水岭式加分点。

7.9 io_uring:把系统调用也省掉

前面所有方案都还有一个共同成本:每一次 I/O 操作至少要一次系统调用。在现代硬件上,系统调用本身约 100 ns ~ 1 μs 不再是噪声——当单次 NVMe 随机读只需约 100 μs、而每秒要发起上百万次 I/O 时,系统调用与上下文切换成了主要开销之一。io_uring 的核心思想是用两块共享内存环形缓冲区替代"每次操作一次系统调用":

1
2
3
4
5
6
io_uring 的两个环形队列(用户态与内核态 mmap 同一块内存)
  用户态:SQ 提交队列 [SQE][SQE][SQE][ ]  CQ 完成队列 [CQE][CQE][ ][ ]
          tail↑写入 SQE、移动 tail       从 head 读 CQE
  内核态:从 SQ 取 SQE 异步执行 I/O,完成后写 CQE(用户态全程无系统调用)
  ★ 需内核介入时才用 io_uring_enter;开启 SQPOLL 后由内核线程轮询 SQ,
    连 io_uring_enter 都可以省掉。

表 7-8 io_uring 与 epoll 的差异

维度epollio_uring
解决的问题就绪通知(告诉你"可以读了")发起与完成(直接替你做这次读)
编程模型事件驱动、就绪后自己发起系统调用提交请求 → 异步完成,两阶段队列
每次 I/O 的系统调用至少 1 次(read/write)+ epoll_wait0(仅需等待或唤醒内核时用 io_uring_enter)
缓冲/内存注册无对应机制io_uring_register 可预注册缓冲区与文件
是否支持 buffered I/O只复用 page cache 的 read/write原生支持 buffered 与 O_DIRECT
I/O 顺序控制无 I/O 顺序概念可用 IOSQE_IO_LINK/DRAIN 指定依赖顺序
主要代价与适用无额外代价;适合 fd 多、事件稀疏的网络 I/O编程模型复杂、有内核版本要求;适合高 IOPS 存储 I/O

注意

面试官会追问的下一层:io_uring 会取代 epoll 吗? 不会,两者是不同层次的东西。epoll 解决"大量连接的少量事件如何高效通知",io_uring 解决"如何把 I/O 的发起与完成都异步化、并消除系统调用"。在"百万连接但每秒只有少量活跃"的代理场景里,epoll 的稀疏事件模型依然合适;而"单个高速 NVMe 上每秒百万 IOPS"的存储场景,io_uring 的批量提交优势才真正体现。多数服务框架目前仍以 epoll 为主。

本章小结

关键结论一句话表述常见错误
fd 是进程内表的下标不是全局标识符,可被复用把 fd 当作"全局唯一句柄"或文件本身
偏移在 struct file 上open 两次各有一个 f_pos;dup/fork 共享同一个答"每个进程有自己的读写位置",解释不了父子串位
O_APPEND 的追写是原子的定位到末尾与写在 inode 上互斥完成认为"先 lseek 再 write“等价于 O_APPEND
dcache 让路径解析不必碰磁盘名字→inode 绑定被缓存,可回收但非泄漏把 slabtop 里 dentry 占用高当内存泄漏
硬链接不能跨文件系统inode 号只在单个文件系统的 inode 表内唯一;也不能指向目录(会造环、破坏路径解析唯一性),两者都不靠"拷贝代价"解释答"跨文件系统拷贝代价大"或"历史遗留限制”
epoll 快在三处而非"1024 上限"省掉每次拷贝、内核扫描、用户态定位扫描把性能差异归因于"select 只能 1024 个 fd"
epoll 的本质是回调注册ep_poll_callback 把就绪 epitem 挂入就绪链表认为内核仍轮询所有 fd,只是改成了批量轮询
ET 必须非阻塞否则最后一次 read 无法用 EAGAIN 结束,线程卡死事件循环答"ET 更快",说不出非阻塞是逻辑必要条件
read 返回 0 与 EAGAIN 是两件事0 是 EOF(对端关闭),EAGAIN 是"暂时没数据"把 EAGAIN 当错误、把 0 当"没数据"而空转
零拷贝 = 零 CPU 拷贝DMA 拷贝无法避免;sendfile 靠 SG-DMA 做到 0 CPU 拷贝;write 成功不等于落盘,fsync 才到设备答"零拷贝就是一次拷贝都没有",或答"write 返回就说明数据安全了"

自测题

Q1(推理)父进程打开 log.txt 后 fork,父子都拿到 fd 3。父进程先 write 3 字节,子进程紧接着 read 3 字节,会读到什么?如果父进程在 fork 前用 dup 把 fd 3 和 fd 4 都留给子进程,结论会变吗?要让子进程从头读,有哪些做法?

答案与解析

第一问:子进程读到的是文件第 4~6 字节,不是父进程刚写的那 3 字节。原因是 fork 复制 fd 表时表项是指针,父子指向同一个 struct file,因此共享同一个 f_pos。 第二问:结论不变。dup 的意义是"多个 fd 号引用同一个打开实例",不是"复制出一个独立的读写位置"。 第三问:①读前 lseek(fd, 0, SEEK_SET) 显式重置;②子进程自己 open 一次,得到独立的 f_pos;③用 pread(fd, buf, n, offset),它不修改也不依赖 f_pos;④改用 mmap 按页访问。 错在哪:把 f_pos 当成进程私有状态,答"各进程有自己的读写位置所以各自从头读"。面试官会怎么追问:①"多线程用同一个 fd 做 read 会怎样?"——f_pos 是共享的可变状态,“定位+读"非原子,会读到交错或重复数据,必须用 pread 或各自 open。②”fork 之后父进程 close(fd),子进程还能读吗?"——能,close 只把 f_count 减一,只要还有一个引用 struct file 就活着。

Q2(推导)某服务用 select 处理 2000 个连接,CPU 大量消耗在内核态。请从 select 的实现推导原因,并说明换成 epoll 后这三处开销各自如何变化。另外说明:为什么 poll 去掉了 1024 上限却依然慢?

答案与解析

三处开销:①用户态→内核的 fd_set 拷贝是 O(n),2000 个 fd 对应 250 字节位图,每次调用双向拷贝;②内核线性扫描位图是 O(n),即使只有一个连接就绪也要扫完 2000 位;③返回后用户态再线性扫描 0 ~ maxfd 是 O(n)。换 epoll 后,①拷贝消失(epoll_ctl 一次性注册);②内核扫描消失(改为回调驱动 O(k));③用户态定位扫描消失(直接返回就绪的 epitem 数组)。总代价从 O(n) 降为 O(k)。 poll 为什么依然慢:它只替换了数据结构(数组替代位图),没有改变"每次调用都要拷贝整个集合、内核都要遍历整个集合"这两个根本机制。1024 只是 select 位图类型的副作用;若把重点放在"select 只能 1024 个 fd 所以慢",会被"poll 无上限也一样慢"直接问倒。 错在哪:只答"epoll 用了红黑树所以快"。红黑树服务于 epoll_ctl 的 O(log n) 查找,本身并不带来 epoll_wait 的收益;真正带来收益的是就绪链表与回调。面试官会怎么追问:①"epoll 在什么场景下不比 select 快?"——fd 极少(几十个)且几乎全部活跃时,O(k) ≈ O(n),而 epoll 还需承担对象管理开销。②"epoll_wait 是真的 O(1) 吗?"——准确说法是"与注册总量无关、与就绪数成正比",即 O(k)。

Q3(设计)你需要在 ET 模式下实现一个高吞吐的 TCP 回显服务。请说明必须设置哪些 fd 标志、事件处理循环为什么必须写到 EAGAIN、发送缓冲区满时怎么处理,以及遗漏 EPOLLHUP/EPOLLERR 会造成什么后果。

答案与解析

标志:监听 socket 与每个连接 socket 都必须 O_NONBLOCK,且在 epoll_ctl(ADD) 之前设置(否则 accept 与第一次 read 就可能阻塞)。用 accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC) 一次完成,避免"accept 后忘记设非阻塞"这一经典竞态;注册时事件掩码加 EPOLLET。 为什么循环到 EAGAIN:ET 只在就绪状态发生跃迁时通知一次。若只 read 一次就返回,缓冲区里剩下的数据不会再有新的边沿事件,会永久滞留直到有新数据到达才被下一个边沿带出——表现为"延迟高、吞吐低、时延抖动"。必须循环 read 直到返回 -1 且 errno == EAGAIN,这是内核给出的"确实读干净了"的唯一权威信号。 发送缓冲区满:write 可能返回 -1/EAGAIN。必须把剩余数据存入应用层写缓冲队列,用 epoll_ctl(MOD) 给该 fd 加上 EPOLLOUT,在 EPOLLOUT 回调里继续写;队列清空后再 MOD 摘掉(否则可写状态会让 epoll_wait 一直返回该 fd,形成忙转)。 遗漏 EPOLLHUP/EPOLLERR:这两个事件即使没有在 events 里申请也会被返回。忽略它们会导致对端已关闭或连接出错的 fd 永远不会被 close,连接对象与缓冲区持续累积,最终以 fd 耗尽(EMFILE)或内存耗尽收场。 错在哪:常见错误是"read 返回 0 就 continue 继续读"。返回 0 是 EOF,是对端关闭了连接,必须 close,继续读会得到无限多个 0,形成 CPU 100% 的忙循环。面试官会怎么追问:①"怎么判断 EAGAIN 还是真错误?"——EAGAIN/EWOULDBLOCK 是"暂时没数据",EINTR 要重试,其余才是错误。②"ET 模式下 accept 有什么坑?"——同样必须循环 accept 到 EAGAIN,否则瞬时大量建连时一次边沿只能被一次 accept 消费,剩余连接滞留在 accept 队列里直到下一次新连接到达。

Q4(计算)把 1 GB 文件通过 socket 发给客户端。分别按"传统 read+write""mmap+write""sendfile(支持 SG-DMA)“计算:CPU 参与的数据拷贝总量、DMA 拷贝总量、上下文切换次数。并说明为什么 sendfile 在大文件场景下 CPU 占用优势最大。

答案与解析

以 8 KB 缓冲区分块传输为例,1 GB 需要约 1 GB / 8 KB = 131072 次循环。 传统 read + write:每 8 KB 做 2 次 CPU 拷贝与 2 次 DMA。CPU 数据拷贝总量 = 1 GB × 2 = 2 GB;DMA = 2 GB;上下文切换 = 每次循环 4 次,共约 131072 × 4 ≈ 52 万次。 mmap + write:省掉"页缓存→用户缓冲区"这一跳。CPU 数据拷贝 = 1 GB × 1 = 1 GB;DMA = 2 GB;上下文切换 = 一次 mmap + 131072 × 2 ≈ 26 万次。 sendfile(支持 SG-DMA):CPU 数据拷贝 = 0(CPU 只把页地址与长度这类描述符写入 socket 缓冲区,约 26 万个描述符 × 16 B ≈ 4 MB,相对 1 GB 可忽略);DMA = 2 GB;上下文切换为一到两次 sendfile 调用。 为什么大文件优势最大:三条链路的绝对拷贝量都随文件线性增长,但 sendfile 在 CPU 侧的斜率为 0,传统方式为 2,文件越大差值与绝对值越大;它还省掉与文件等大的用户缓冲区、不把热数据挤出 CPU 缓存,也不需要用户态分块循环。 错在哪:①把"零拷贝"答成"一次拷贝都没有”——DMA 的两次拷贝始终存在,零拷贝指的是 CPU 不参与数据拷贝;②忽略 SG-DMA 前提而直接答"sendfile 是 2 次拷贝"——不支持 scatter-gather 时 sendfile 是 3 次拷贝。面试官会怎么追问:“目标不是 socket 而是另一个普通文件,还能用 sendfile 吗?"——sendfile 要求目标支持相应 f_op,普通文件之间通常用 copy_file_range,也可经 splice 加管道完成。

Q5(辨析)服务出现 EMFILE: Too many open files。请给出完整的排查顺序,说明如何区分"配置不足"与"fd 泄漏”,并解释"df 显示磁盘满但 du 统计不出占用"与 fd 之间的关系。

答案与解析

排查顺序:①先确认撞的是哪一层——EMFILE 是本进程超 RLIMIT_NOFILE,ENFILE 是全系统超 fs.file-max;②看当前值 ulimit -n、ulimit -Hn、/proc/sys/fs/file-nr;③看趋势——周期性采集 ls /proc/[pid]/fd | wc -l;④看构成——ls -l /proc/[pid]/fd 判断指向 socket、普通文件、管道还是 deleted 文件。 配置不足 vs 泄漏的判据:看时间维度上的稳定性。配置不足表现为 fd 数在稳态水平上下波动、随时可复现;泄漏表现为 fd 数单调上升、从不回落,或在特定操作后阶梯式上升。前者调大 ulimit -n(并检查 systemd 的 LimitNOFILE、容器运行时的配置,这些会覆盖 shell 设置);后者必须修代码。 df 与 du 不一致的关联:进程打开一个文件后,若该文件被 unlink(或被日志轮转工具 mv 走),目录项消失了,但 struct file 与 inode 仍被 fd 持有,数据块因此无法释放。此时 df 统计的是文件系统层面的已分配块(含这些"无名字"的块),du 遍历目录树却看不到它们,于是出现"df 说满了、du 找不到大文件"。定位方法正是 fd 视角:lsof +L1 或 ls -l /proc/[pid]/fd 里出现 ... (deleted) 的条目;修复是让持有者关闭 fd。 错在哪:①一上来就答"调大 ulimit -n"——掩盖泄漏,把问题推迟成更严重的 OOM;②混淆 EMFILE 与 ENFILE。面试官会怎么追问:①"Java 服务怎么定位 fd 泄漏?"——lsof -p <pid> 看增长类型;指向 jar 文件需查类加载器与 JarFile/URLClassLoader 是否关闭;指向 socket 需查 HTTP 客户端响应体与连接池配置,见第 18 章 18.2 节。②"/proc/[pid]/fd 里的 anon_inode 是什么?"——匿名 inode,epoll 实例、eventfd、timerfd、inotify 都表现为它,因此 fd 增长也可能是这些对象泄漏,未必是 socket 或文件。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应小节
一个 int 类型的文件描述符在内核里到底代表什么?把它画出来。fd 的三层结构陷阱:只答"文件句柄"或"数组下标"就停住,画不出 struct file 与 inode 的分层,也说不清 f_pos 在哪一层。加分:画出进程 fd 表 → struct file(含 f_pos/f_count/f_op)→ inode 表,并指出 f_pos 在中间层7.1
父进程打开文件后 fork,父子进程的读写位置是共享的吗?为什么?共享语义与"父写子读串位"陷阱:答"每个进程有独立的文件偏移"——那父子就不会串位,与自己举的例子矛盾。加分:指出 fork 复制的是指针而非 struct file 本身,并给出 pread/各自 open 的解法7.1.1 / 7.1.2
dup 和再 open 一次同一个文件,有什么本质区别?struct file 是否被共享陷阱:答"没区别,都是拿到一个 fd"。加分:指出 dup 共享 f_pos 与 f_flags,因此无法单独把 dup 出来的 fd 改成非阻塞;两次 open 则完全独立7.1.1 / 7.1.2
为什么硬链接不能跨文件系统,也不能指向目录?链接的本质与目录树不变量陷阱:答"跨文件系统拷贝数据代价大"或"历史遗留限制",被"那 .. 为什么可以"直接问倒。加分:答"inode 号只在单个文件系统 inode 表内唯一"与"目录硬链接会造环、破坏路径解析唯一性与单一父目录不变量"7.3.1
select 和 epoll 的差别到底在哪?为什么 poll 没有 1024 限制还是慢?多路复用的三处开销陷阱:把原因归结为"select 只能 1024 个 fd",被"poll 无上限却依然慢"打回。加分:拆成"每次 O(n) 拷贝、内核 O(n) 扫描、用户态 O(n) 定位"三层,并说明 epoll 分别如何消除7.4
epoll 为什么能只返回就绪的 fd?它内部是用什么数据结构实现的?eventpoll 的红黑树 + 就绪链表 + 回调陷阱:答"内核轮询所有 fd 找就绪的"或"用了红黑树所以快"——前者是 select 的做法,后者混淆了红黑树(服务于 epoll_ctl)与就绪链表(服务于 epoll_wait)。加分:说出 ep_poll_callback 在 fd 就绪时把 epitem 挂入 rdllist7.5
边缘触发(ET)为什么一定要把 fd 设成非阻塞?ET 的语义与死锁链条陷阱:答"因为非阻塞更快"或"这是最佳实践",说不出逻辑必然性。加分:说明"最后一次 read 只能靠 EAGAIN 结束,阻塞 read 会让线程卡死、整个事件循环停摆",并指出 read 返回 0 是 EOF 而非"没数据"7.6
write 系统调用返回成功,是不是说明数据已经安全落盘了?page cache 与持久化语义陷阱:答"是的,返回成功就写到磁盘了"。加分:分三档答"write 只到 page cache → fsync 到设备 → 设备写缓存策略决定是否真的持久",并指出这是数据库必须显式 fsync 与组提交存在的原因7.7
零拷贝有哪几种实现?sendfile 到底是 2 次拷贝还是 3 次?数据流路径与 DMA 参与次数陷阱:答"零拷贝就是一次拷贝都没有",或笼统答"sendfile 是 3 次拷贝"却不知道 2 次的那个前提。加分:明确"零拷贝指零 CPU 拷贝、DMA 拷贝必然存在",并指出 3 次与 2 次之分取决于网卡是否支持 scatter-gather DMA7.8
io_uring 相比 epoll 解决了什么问题?它能取代 epoll 吗?就绪通知 vs 异步发起与完成陷阱:答"io_uring 是更快的 epoll",把两个不同层次的东西当同类比较。加分:说明 epoll 解决"就绪通知"、io_uring 解决"发起与完成",并指出共享内存环形队列(SQ/CQ)如何消除系统调用7.9

延伸阅读

  • Linux 手册页:man 2 epoll(LT/ET 语义、EPOLLET 的准确表述)、man 2 select(FD_SETSIZE 的定义与限制)、man 2 poll、man 2 sendfile、man 2 splice、man 2 io_uring_setup、man 2 open(O_APPEND 的原子性保证、O_DIRECT 的对齐要求)、man 5 proc(/proc/[pid]/fd 与 fdinfo)。
  • Linux 内核源码:fs/eventpoll.c(eventpoll、epitem、ep_poll_callback 的完整实现,是 7.5 节的一手依据)、fs/file_table.c(struct file 与 fd 表分配)、fs/dcache.c 与 include/linux/fs.h(VFS 四类对象定义)。
  • Linux 内核文档:Documentation/filesystems/vfs.rst(VFS 四类对象与操作表)、Documentation/filesystems/path-lookup.rst(dcache 与路径解析)、DMA-API 与 scatter-gather 部分(7.8 节 SG-DMA 的依据)。
  • 框架层实现:OpenJDK 的 sun.nio.ch.EPollSelectorImpl 与 Netty 的 EpollEventLoop,对照本章 7.6 节理解框架如何封装 ET 与非阻塞。
  • Remzi & Andrea Arpaci-Dusseau, Operating Systems: Three Easy Pieces:第 39 章"Files and Directories"、第 40 章"File System Implementation",结合本章 7.3 节理解 dcache 存在的必要性。