第 7 章 文件描述符、VFS 与 I/O 多路复用
本章把"一个 int 如何代表一个打开的文件"与"百万连接如何被一个线程照看"连成一条自洽的链路。
学习目标
读完本章,你应当能够:
- 画出 fd 表 →
struct file→ inode 三层结构,并据此推导open/dup/fork之后 offset 是独立还是共享; - 说明 fd 的分配规则与耗尽后的故障现象,并用
/proc/[pid]/fd与/proc/[pid]/fdinfo定位泄漏; - 辨析 VFS 四类对象的职责边界,推导硬链接为什么不能跨文件系统、不能指向目录;
- 逐条对比
select/poll/epoll,并画出eventpoll的红黑树、就绪链表与回调路径; - 写出一段正确的 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 是进程内数组的下标,不是全局标识符。
| |
表 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
| |
两次 open 即使指向同一个 inode,也各自持有一个 struct file,因此两个 fd 可以独立地从头读同一个文件;它们只在最底层共享 inode 与 page cache,第二次读不走磁盘。
7.1.2 dup 与 fork:共享同一个 file、同一个 offset
| |
重要
三条语义必须一次说清: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。
| |
表 7-2 fd 耗尽的三个层次与典型根因
| 层次 | 上限来源 | 默认量级 | 撞上时的错误 | 典型根因 |
|---|---|---|---|---|
| 进程 | RLIMIT_NOFILE(软限制) | 1024 | EMFILE | 连接/文件未 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 |
| 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 硬链接与软链接的本质差异
| |
为什么硬链接不能跨文件系统? 因为硬链接的语义是"目录项里存一个 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 的改进
| |
三个硬伤都可以从类型定义推出来: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 与三者的完整对比
| |
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) |
重要
面试时必须把"epoll 比 select 快"拆成可验证的三层,否则会被追问到答不上来:①省掉每次调用的 O(n) 用户态↔内核拷贝;②省掉内核侧的 O(n) 扫描(改为事件回调,只有活跃 fd 产生工作);③省掉用户态返回后的 O(n) 定位扫描。至于 1024 上限,只是 select 位图类型的副作用,不是性能差异的主因——把重点放在这个数字上会被反例打回(poll 没有上限却依然慢)。
7.5 epoll 的内部实现
三个系统调用分别对应 eventpoll 对象的三块数据结构:
| |
三个系统调用的分工: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:实现差异与正确的代码写法
两种触发模式的差异,本质是**“回到用户态之前要不要把就绪状态清掉”**:
| |
| |
表 7-5 LT 与 ET 的选择判据
| 维度 | LT | ET |
|---|---|---|
| 通知条件 | 只要就绪(有数据可读 / 可写)就通知 | 仅就绪状态发生跃迁时通知一次 |
| 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 返回成功不等于落盘:
| |
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 拷贝),而不是"一次拷贝都没有”。
| |
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 |
提示
面试话术模板:先说"零拷贝指的是零 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 的核心思想是用两块共享内存环形缓冲区替代"每次操作一次系统调用":
| |
表 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 |
注意
面试官会追问的下一层: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 挂入 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 存在的必要性。