# 第 7 章 文件描述符、VFS 与 I/O 多路复用


> 本章把"一个 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` 是**进程内数组的下标**，不是全局标识符。

```text
一个 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` / `fdtable` | `fd → struct file *` 的映射 | `fork` 复制、`exec` 保留（除非 `FD_CLOEXEC`） |
| 打开文件表 | `struct file` | `f_pos`、`f_flags`、`f_count`、`f_op` | 每次 `open`/`accept`/`pipe`/`socket` 产生一个 |
| inode 表 | `struct inode` | 元数据、`i_nlink`、页缓存指针 | 首次访问该 inode 时读入并缓存 |
### 7.1.1 `open` 两次：两个 `file`，两个 offset

```c
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

```text
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 的位置"。
    这就是"父子进程各写一半日志会互相覆盖半行"的根因。
```

> [!IMPORTANT]
> 三条语义必须一次说清：**`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。

```bash
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`（软限制） | 1024 | `EMFILE` | 连接/文件未 `close`，异常路径漏关 |
| 系统 | `/proc/sys/fs/file-max` | 与内存相关，数十万到数百万 | `ENFILE` | 多进程同时持大量 fd |
| 内核内存 | `file` 结构体与 dentry 缓存 | —— | 分配失败、OOM | 打开海量不同路径文件，dentry 无法回收 |

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

> [!WARNING]
> "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` |
| inode | `inode` | 被引用期间缓存 | 磁盘 inode | 元数据、`i_nlink`、页缓存指针、`i_op`/`i_fop` |
| dentry | `dentry` | LRU 缓存 | 目录项 | "名字 → inode"的绑定，构成目录树 |
| file | `file` | 每次 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 硬链接与软链接的本质差异

```text
硬链接：两个目录项指向同一个 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`），且递归工具默认不跟随。

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

多路复用解决的问题可以一句话定义：**一个线程要照看 N 个 fd，既要不错过任何一个就绪事件，又不能阻塞在任何一个上**。
### 7.4.1 `select` 的三个硬伤与 `poll` 的改进

```c
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` 与三者的完整对比

```c
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` 的逐项对比**

| 维度 | `select` | `poll` | `epoll` |
|---|---|---|---|
| 数据结构 | `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 | 只有 LT | LT 与 ET 可选 |
| 合计代价 | `O(n)` 拷贝 + `O(n)` 扫两遍 | 同 `select`，但无 1024 限制 | `epoll_ctl` `O(log n)`，`epoll_wait` `O(k)` |

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

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

```text
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 节）。

> [!NOTE]
> 面试官会追问的下一层：**红黑树到底存了什么，为什么必须用它？** 树节点是 `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：实现差异与正确的代码写法

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

```text
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 等下一次边沿。
```

```c
// 为什么循环 + 判定 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 的选择判据**

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

> [!WARNING]
> 三个高频错误：①**"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` 返回成功不等于落盘**：

```text
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 混用同一文件时没有一致性保证**。

> [!IMPORTANT]
> "`write` 返回成功数据就安全了"是会被面试官直接打回的说法。标准答法分三档：**`write` 只保证写进了内核 page cache**（进程视角可见，掉电即丢）；**`fsync` 保证把脏页刷到存储设备**；**设备是否写进持久介质还取决于硬件写缓存策略**。因此数据库提交、消息队列刷盘必须显式 `fsync`，并接受每次 `fsync` 的毫秒级延迟——这也是组提交（group commit）存在的理由，见第 21 章 21.2 节。
## 7.8 零拷贝的三种实现与 DMA 参与的数据流

零拷贝（zero-copy）的准确含义是**消除 CPU 参与的数据拷贝**（DMA 拷贝不算 CPU 拷贝），而不是"一次拷贝都没有"。

```text
【方案一】传统 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` + `write` | 2 | 2 | 4 | 是 |
| `mmap` + `write` | 1 | 2 | 2 | 元数据经，数据页共享 |
| `sendfile`（无 SG-DMA） | 1 | 2 | 2 | 否，文件→socket |
| `sendfile` + SG-DMA | **0** | 2 | 2 | 否，文件→socket |
| `splice`（经管道） | 0（只搬页引用） | 2 | 2 | 否，任意 fd→fd |

> [!TIP]
> 面试话术模板：先说"零拷贝指的是**零 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` 的核心思想是**用两块共享内存环形缓冲区替代"每次操作一次系统调用"**：

```text
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` 的差异**

| 维度 | `epoll` | `io_uring` |
|---|---|---|
| 解决的问题 | **就绪通知**（告诉你"可以读了"） | **发起与完成**（直接替你做这次读） |
| 编程模型 | 事件驱动、就绪后自己发起系统调用 | 提交请求 → 异步完成，两阶段队列 |
| 每次 I/O 的系统调用 | 至少 1 次（`read`/`write`）+ `epoll_wait` | 0（仅需等待或唤醒内核时用 `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 |

> [!NOTE]
> 面试官会追问的下一层：**`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 都留给子进程，结论会变吗？要让子进程从头读，有哪些做法？

<details><summary>答案与解析</summary>

**第一问**：子进程读到的是文件第 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` 就活着。
</details>

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

<details><summary>答案与解析</summary>

**三处开销**：①**用户态→内核的 `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)`。
</details>

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

<details><summary>答案与解析</summary>

**标志**：监听 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` 队列里直到下一次新连接到达。
</details>

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

<details><summary>答案与解析</summary>

以 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` 加管道完成。
</details>

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

<details><summary>答案与解析</summary>

**排查顺序**：①先确认撞的是哪一层——`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 或文件。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
| 一个 `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` 挂入 `rdllist` | 7.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 DMA** | 7.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 存在的必要性。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch07/  

