第 9 章 中断、系统调用与块层
内核与外部世界只有两个接口:硬件从外面打断它(中断),或它主动出去取活干(系统调用)。本章把这两条边界和其后的 I/O 栈一次拆到底。
学习目标
读完本章,你应当能够:
- 论证「轮询 vs 中断」的取舍判据,并解释 NAPI 与 NVMe 的
io_poll为什么会主动退回轮询。 - 区分硬中断、软中断、tasklet、工作队列、线程化中断的运行上下文与「能否睡眠」判据,说出
ksoftirqd存在的理由与软中断风暴的三种表现。 - 画出 x86-64 上一次系统调用从用户态准备寄存器到
sysretq返回的路径,逐项拆解开销来源与量级。 - 解释 vDSO 能让
clock_gettime不进内核、却不能让getpid也不进内核的原因。 - 说明信号的产生/阻塞/未决三态与
SIGCHLD丢失的根因,写出一个可重入安全的信号处理函数。 - 画出从
write到设备队列的块层 I/O 栈,按设备类型选择 I/O 调度器,并说清fsync到底保证了什么。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
| 用户态/内核态、特权级切换的基本概念 | 本章 9.5 节(系统调用主题的唯一归属章,其余章引用本章) |
| 进程调度与上下文切换开销的构成 | 见第 5 章 5.5 节 |
文件描述符、VFS、page cache、O_DIRECT 的位置 | 见第 7 章 7.4 节、7.6 节 |
| 缺页中断与按需分页(中断的一个特例) | 见第 8 章 8.3 节 |
9.1 中断存在的理由:从轮询到事件驱动
不用中断,CPU 想知道设备有没有新数据,只能反复读设备状态寄存器——这就是轮询(polling)。设备完成间隔为 T、轮询周期为 p 时,平均发现延迟是 p/2,CPU 花在无效询问上的比例与 p/T 同阶:压延迟就得减小 p,而减小 p 直接吃 CPU,「低延迟」与「不浪费 CPU」在轮询下不可兼得。中断把「谁决定何时处理」从 CPU 转交给设备,但轮询并未消失,因为它有两个中断给不了的性质:
| 维度 | 中断驱动 | 轮询驱动 |
|---|---|---|
| 发现延迟与抖动 | 接近设备完成时刻(加投递与处理,约 1~5 μs);抖动大,可被屏蔽或排队 | 由 p 决定,可做到亚微秒且抖动小可预测 |
| 每事件 CPU 成本 | 一次完整现场保存 + 中断处理;事件稀疏时趋近于零 | 与事件无关,恒为「询问成本 ÷ 每次问到的事件数」 |
| 开销形状 | 固定开销除以事件频率:频率越高,单事件摊得越薄 | 单事件成本几乎恒定,不随频率变化 |
| 缓存友好性 | 差:随时打断热点代码,扰动 i-cache 与分支预测器 | 好:轮询循环本身就是热点代码 |
| 适用区间 | 低频、突发、不可预期(键盘、零星报文、磁盘完成) | 高频、持续、可预期(NVMe 深队列、线速收发) |
判据不是「谁先进」,而是 中断固定开销 × 事件频率 与 轮询单次成本 × 询问次数 谁更小。Linux 网络栈的 NAPI 就是混合方案:第一个报文触发硬中断,随后关闭该队列中断并在软中断上下文按预算(net.core.netdev_budget,默认 300)轮询收包环,把「每包一次中断」降为「每批一次中断」;NVMe 在队列深度大、IOPS 高的场景也允许纯轮询(io_poll),代价是必须独占一个 CPU 不让出去。
重要
中断省 CPU 的前提是事件稀疏。事件密集时,「保存现场 + 进入内核 + 退出 + 恢复现场」是被反复支付的固定成本。这正是所有「绕开中断」方案(NAPI、io_poll、用户态协议栈、io_uring 的 SQPOLL)的共同逻辑。
9.2 上半部与下半部:中断处理为什么必须被切开
硬中断(hardware interrupt, hardirq)的处理函数运行在一个没有对应 task_struct 的特殊上下文里。这带来两条硬约束:处理期间该 CPU 上(视屏蔽位)该中断线被屏蔽,其他设备事件被推迟;且它不能睡眠(理由见 9.3)。于是处理函数一旦变长,全局响应性一起变差。
| |
「上半部必须短」不是风格要求,而是三条物理约束的推论:中断处理期间同线中断被屏蔽,处理越久越容易丢后续事件;上下文本身把能做的事限死;硬中断会打断任意代码包括持锁临界区,处理越久越容易与被打断者形成互等路径,放大锁竞争与抖动。
下半部(bottom half)不是一样东西,而是一族机制,区别只在运行上下文与由此决定的能否睡眠:
| 机制 | 运行上下文 | 能否睡眠 | 并发/重入 | 触发方式 | 当前定位 |
|---|---|---|---|---|---|
软中断 softirq | 中断返回路径或 ksoftirqd | 否 | 同一类型不并行,不同 CPU 可并行 | raise_softirq() | 静态枚举且数量少(HI/TIMER/NET_TX/NET_RX/BLOCK/TASKLET/SCHED/RCU),面向高频子系统 |
| tasklet | 跑在 TASKLET_SOFTIRQ 之上 | 否 | 同一个 tasklet 不会同时在多 CPU 运行 | tasklet_schedule() | 已弃用,新代码不允许再用;面试仍会问,因为它曾是默认答案 |
工作队列 workqueue | 内核线程(kworker/*),进程上下文 | 能 | 同一工作项重复排队只执行一次,并发度由 max_active 限制 | schedule_work() | 需要睡眠的延迟工作(访问 /proc、拿 mutex、可能触发回收的分配) |
线程化中断 threaded IRQ | 专用内核线程 irq/N-name | 能 | 由中断线程逐条处理 | request_threaded_irq() 第二段 | 现代驱动首选:上半部只应答与唤醒,重活放在可睡眠线程 |
判据只有一条:这段代码会不会睡眠。会睡眠(等锁、等 I/O、可能触发回收的分配)就必须进工作队列或线程化中断;不会睡眠但很重(遍历收包环、遍历定时器)用软中断;不会睡眠且极短(读状态位、应答)留在上半部。
警告
常见错误回答:「tasklet 和 workqueue 的区别是 tasklet 快、workqueue 慢」。真正的判据是运行上下文。另一个高频错误是把「软中断不能睡眠」理解成「软中断会阻塞其他 CPU」——软中断是 per-CPU 的,同一类型在自己 CPU 上串行,不同 CPU 可同时跑,这正是网络软中断要用 per-CPU 队列与 __percpu 变量的原因。
9.2.1 ksoftirqd 与软中断风暴
软中断的调度有两个相互矛盾的诉求:延迟低(有 pending 就立刻做)与饿不死别人(不能无限做)。内核先直接做一轮:__do_softirq() 最多执行 MAX_SOFTIRQ_RESTART(10)轮、总时长受 MAX_SOFTIRQ_TIME(约 2 ms)限制;预算用尽后若仍有 pending,就唤醒 per-CPU 的 ksoftirqd/N,把剩余工作交回可被调度器管理的进程上下文。因此 ksoftirqd 给出的保证是:软中断不会在中断返回路径上无限占用 CPU;它自己也是普通调度类任务,于是「软中断很多」从「卡死中断返回」变成「多一个忙线程」。软中断风暴的三种表现与处置:
| 现象 | 观测面 | 根因 |
|---|---|---|
si 列持续高、ksoftirqd/* 占满核 | /proc/softirqs 中 NET_RX 单项飞涨 | 收包速率超过协议栈处理能力(中断太密、大量小包、GRO 失效) |
wa 与 si 同时高、%util 满 | /proc/softirqs 的 BLOCK 项、iostat | 块层完成中断 → BLOCK 软中断 → 上层唤醒与内存回收互相催动 |
| 长尾延迟与丢包 | /proc/net/softnet_stat、netstat -s | 软中断预算用尽导致 backlog 溢出(softnet_stat 第二列非零即为此) |
处置按「减压 > 分流 > 限流」排序:先减少事件本身(中断合并、GRO/TSO、批处理预算),再把软中断按 RSS 多队列分摊到多核(9.4 节),最后才考虑调参数。单纯调大 net.core.netdev_budget 只是把工作从软中断搬到应用,不解决根因。
9.3 中断上下文为什么不能睡眠
这条结论是所有中断类追问的终点,必须能推出。内核里「睡眠」的完整含义是四件事:把当前任务从运行队列摘掉置为睡眠态;注册一个等待项记录「谁在什么条件下唤醒我」;调用调度器切换(保存现场、切地址空间、可能切栈);将来由另一个上下文通过等待项重新入队。硬中断与软中断上下文缺的是第 1 步的主体和第 4 步的对象:中断处理函数运行在当前任务的内核栈之上,没有自己的任务实体,也不是调度器队列成员。若它调用了会睡眠的函数,调度器切走之后栈上那份中断帧就悬在那里,且没有任何数据结构记录「这个中断没处理完」,也就没有谁有能力唤醒它。结果不是报错,而是该 CPU 上这个上下文永久消失(schedule while atomic 之类告警正是内核在尽力拦截)。由此推出三个禁区与替代方案:
| 禁区 | 为什么 | 正确替代 |
|---|---|---|
可能睡眠的锁(mutex、信号量) | 这类锁的 owner 字段需指向任务实体,拿不到时必须睡眠让出 | spinlock_t 配 irqsave 变体,或把工作移到工作队列 |
kmalloc(GFP_KERNEL) 类分配 | 分配可能触发内存回收,回收要等 I/O 完成而睡眠 | GFP_ATOMIC(可能失败,必须处理失败路径) |
| 触碰可能缺页的用户内存、等 I/O 完成 | 缺页与等待都是睡眠 | 先确保页已驻留,或把拷贝放到工作队列 |
判定接口是 in_interrupt(),等价于 in_irq() || in_softirq() || in_nmi()。要留意 in_softirq() 的含义是「软中断上下文或已禁用下半部」,所以把代码包在 local_bh_disable() 里也会让它为真——这解释了一些看似正常的路径为何命中「不能睡眠」的断言。与之互为镜像的是 in_atomic()(在中断里、或持自旋锁、或已关抢占)。
提示
记忆锚点:「能不能睡眠」的判据是「出了事谁来唤醒我」。硬中断、软中断、tasklet、持自旋锁的临界区都没有这个「谁」。反过来说,任何不需要睡眠的事(遍历队列、算校验和、更新统计)放下半部都安全;任何需要睡眠的事(分配、等盘、拿 mutex)都必须进内核线程。
9.4 多核下的中断:屏蔽、亲和性与多队列
与性能最相关的是路由:中断落在哪个核上,决定了后续处理是否要跨核搬运数据。若中断处理的核与消费数据的线程不是同一个核,数据会在核间来回搬,cache line 反复弹跳(见第 1 章 1.5 节的缓存一致性与 NUMA 代价),直接变成吞吐损失。观测面是 /proc/interrupts(每行一个中断、每列一个 CPU 的累计计数)、/proc/irq/N/smp_affinity(十六进制位掩码,如 echo 0c > /proc/irq/128/smp_affinity 把 128 号中断限到 CPU 2、3)与 smp_affinity_list(如 2-3),irqbalance 守护进程则按负载自动调整;/proc/softirqs 给出软中断的 per-CPU 计数,要与中断分布对照看是否均匀,而不是只看总量。
接收端缩放(receive side scaling, RSS)解决的是「一块网卡只发一个中断、单核扛不住线速」的问题:网卡按包头五元组算哈希,把结果映射到 N 个接收队列之一,每个队列绑定一个独立中断向量,再由 smp_affinity 或驱动配置把这些中断分摊到不同 CPU(ethtool -l eth0 看通道数,ethtool -L eth0 combined 8 调整,ethtool -n eth0 rx-flow-hash tcp4 看哈希字段)。它的两个边界必须说清:第一,同一条流永远落在同一个队列(哈希输入是流标识),所以单连接吞吐上限仍是单核处理能力,救单流要靠 RPS(软件层把收包分散到其他核,代价是跨核传递)或多连接分片;第二,哈希可被构造,攻击者能造出大量哈希相同的流把所有流量压到一个队列,这也是随机种子(ethtool -X ... hkey)与 RFS 存在的理由。
注意
面试官追问的下一层常是:「中断亲和性和进程 CPU 亲和性要一致吗?」方向不同但结论是要一致:中断亲和性决定处理中断的核,进程亲和性决定消费数据的核。常见做法是把网卡队列 i 的中断绑到 CPU i,再把处理该队列的线程绑到同一核,或让 RFS 按 socket 归属自动引导。
9.5 系统调用的完整路径
系统调用是主动的受控入口。x86-64 上这条路径由硬件约定与内核入口代码共同实现,第一层是寄存器约定:
| 用途 | x86-64 Linux 约定 | 说明 |
|---|---|---|
| 系统调用号 | rax | 数值见 arch/x86/entry/syscalls/syscall_64.tbl,随内核演进增补 |
| 参数 1~6 | rdi、rsi、rdx、r10、r8、r9 | 第 4 个参数是 r10 而非 rcx,因为 syscall 指令本身会破坏 rcx(存返回地址)与 r11(存 rflags) |
| 返回值 | rax | 失败时返回 -errno(负值区间 -1 ~ -4095);errno 是 libc 包装层的产物 |
| 入口地址 | MSR_LSTAR | 内核启动时写入 entry_SYSCALL_64 的地址,硬件在 syscall 时直接跳过去 |
| |
路径上有四个必须能指出的细节。其一,syscall 不查 IDT:与 int 0x80 相比,它专为「跳到固定内核入口」设计,省掉了查中断描述符表与完整特权级栈切换的流程,这是 x86-64 快于 32 位路径的硬件原因。其二,入口要做的工作远不止保存寄存器:还要处理用户/内核 GS 基址切换(SWAPGS)、校验返回地址、应用 Spectre 类缓解措施(entry trampoline、返回 thunk、必要的 LFENCE 与 IBRS 系列)并确定内核栈位置,这些附加工作是「同一台机器换个内核版本,系统调用就慢了」的直接原因,也是实测区间很宽的主因。其三,参数校验与拷贝是可变成本:copy_from_user/copy_to_user 要检查用户指针合法性(不能指向内核地址),且首次触碰用户页可能触发缺页而睡眠(见第 8 章 8.3 节),所以成本是「固定切换成本 + 与数据量相关的拷贝成本 + 概率性的缺页成本」。其四,错误约定在内核边界是负值:内核函数返回 -EINVAL 这类负错误码,test rax, rax; js error 是内核判断失败的标准写法,翻译成 errno 是 libc 的职责,被追问「内核里怎么判错」时答 errno 是错的。
9.6 系统调用的开销构成与省掉它的手段
| 开销项 | 具体内容 | 量级 |
|---|---|---|
| 特权级切换与入口代码 | syscall + SWAPGS + 保存/恢复现场 + 缓解措施 | 约 100 ~ 300 ns;开完整缓解措施可达 1 μs 量级 |
| 地址空间相关开销 | 页表隔离(KPTI)下需切 CR3,带来 TLB 与分支预测器扰动;不开则基本为 0 | 与 KPTI 是否生效强相关 |
| 参数校验与用户内存拷贝 | 指针范围检查 + copy_*_user | 约 1 ns/字节的带宽量级 |
| 概率性成本 | 首次触碰用户页或内核侧缓冲触发缺页 | 单次约 1~2 μs(第 8 章 8.3 节) |
| 内核侧数据结构操作 | 查表、加锁、更新统计、引用计数 | 几十纳秒到数微秒,取决于是否触发 I/O |
结论是一次系统调用约 100 ns ~ 1 μs:比一次函数调用(约 1 ns)贵约 1001000 倍,但比一次完整的进程上下文切换(约 15 μs)便宜,因为它没有切换任务,只切了特权级与地址空间视角。省掉它的手段分两类:
| 手段 | 省掉了什么 | 代价/边界 |
|---|---|---|
| vDSO | 整条内核路径:数据由内核映射到用户空间的只读页,用户态直接读 | 只对「内核持续维护的只读数据」可行(时间、getcpu) |
io_uring 的 SQPOLL | 提交环节的系统调用(提交队列由内核线程轮询) | 需要专门一个核在轮询,低负载时浪费 CPU |
批处理(writev、sendmmsg、io_uring 链式提交) | 摊薄固定成本:一次陷入处理 N 个操作 | 复杂度上升,需处理部分成功 |
用户态协议栈 / mmap 共享内存 / 减少调用次数 | 分别省掉内核网络路径、每次读写的拷贝与陷入、最直接的调用总数 | 独占核与网卡、需自行处理同步与可见性、引入缓冲延迟 |
9.6.1 vDSO 与 vSyscall
虚拟动态共享对象(virtual dynamic shared object, vDSO)是一段由内核映射进每个进程地址空间的小型 ELF 映像(地址通过辅助向量 AT_SYSINFO_EHDR 告知用户态)。它里面的代码是内核用一份「同时可在用户态与内核态运行」的实现编译出的只读代码与数据页,因此可以读内核维护的时间数据而不发生特权级切换。为什么时间可以:墙上时间与单调时间有一个内核持续更新的结构(vvar 页),用户态只需读两个字段再做定点乘加即可换算,无需求助内核逻辑。这就是 gettimeofday 不陷入内核的原因——走 vDSO 的 clock_gettime(CLOCK_MONOTONIC) 在几十纳秒量级,对应系统调用在数百纳秒量级;每请求取两次时间的服务因此能省下可观 CPU。
| |
两个时钟的选择是并发场景的常见陷阱:CLOCK_MONOTONIC 从某个不可指定的起点单调递增,不受系统时间被调整影响,是测间隔、实现超时、算 P99 的正确选择;CLOCK_REALTIME 是墙上时间,可能被 NTP 逐步校正或被 settimeofday 阶跃跳变,用它算两次之差可能得到负数。补充选项是 CLOCK_MONOTONIC_COARSE:直接读内核每个 tick(jiffies,典型 1~4 ms)更新的粗粒度值,精度换速度,适合「只要大致耗时」的高频统计。vDSO 的边界也是这道题的追问方向:
| 问题 | 原因 |
|---|---|
getpid 为什么不能靠 vDSO 省掉系统调用 | 进程号不是内核持续写、用户只读的数据:fork/clone 会改变返回值,而 vDSO 只能在固定地址放一份共享数据,无法为每个进程维护会变化的快照。glibc 的 getpid 因此始终是真正的系统调用(历史上曾缓存结果,后因 fork 语义冲突被移除) |
| 为什么只有很少几个函数进 vDSO | 必须同时满足:结果由内核持续维护、读取所需数据可放在共享只读页、换算不需要内核逻辑 |
| seccomp 与 vDSO 的冲突 | seccomp 观察的是系统调用,而 vDSO 快路径不产生系统调用,对过滤器不可见;依赖 SECCOMP_RET_TRAP/USER_NOTIF 的场景必须显式关闭快路径 |
| 时间命名空间下的失效 | 时钟偏移需按命名空间计算,共享页无法表达 per-namespace 偏移,内核会通知用户态退回系统调用 |
小心
不要答「vDSO 就是把系统调用搬到用户态执行」。vDSO 没有执行任何内核逻辑,它只是一段读共享数据的用户态代码;机制是「内核把结果预先写在只读页里,用户态直接读」,所以只能加速那些结果已经在内存里的调用。答成「搬到用户态」会立刻被追问「那 open 能不能也搬到用户态」。
9.7 中断与系统调用的开销差异
两者都跨越特权边界、都要保存现场,但成本结构不同,差异来自可预期性:
| 维度 | 系统调用 | 硬中断 |
|---|---|---|
| 触发者 | 用户代码自己的指令 | 设备/定时器等外部事件 |
| 是否可预期 | 可预期,调用点已知 | 不可预期,可落在任意指令边界 |
| 需保存的现场 | 只需 ABI 规定的 caller-saved 与少量状态(rcx/r11 由指令自动处理) | 需完整保存被任意打断处的状态(pt_regs 全量入栈),返回时精确恢复 |
| 是否打断持锁临界区 | 不会(同一线程顺序执行) | 会,可能在任何临界区内触发,因此需要 irqsave 式屏蔽 |
| 对缓存/流水线的影响 | 已知跳转目标,预测器有历史,扰动相对可控 | 随机打断热点代码,污染 i-cache 与分支预测器,还可能与下半部叠加 |
| 可选优化 | vDSO、批处理、io_uring 提交环 | 中断合并、NAPI 轮询、多队列分散、线程化中断 |
| 典型量级 | 约 100 ns ~ 1 μs | 单次处理(含上半部与下半部启动)常在 1~5 μs 量级,抖动大 |
一句话总结根因:系统调用的入口是「已知的、自愿的、可优化的」,中断的入口是「未知的、强制的、必须完整兜住的」。只保留现场这一项,中断就比系统调用贵得多。
9.8 信号:软件层的异步通知
信号(signal)是内核向进程投递的异步通知,与中断的类比是「硬件打断 CPU、信号打断进程」。但类比到此为止:
| 维度 | 硬件中断 | 信号 |
|---|---|---|
| 目标 | CPU(执行流,无任务概念) | 具体进程/线程(有 task_struct,处理时是用户态代码) |
| 处理上下文 | 内核态,中断上下文 | 用户态(内核只改返回时的用户栈与 rip,返回后去执行处理函数) |
| 处理函数限制 | 不能睡眠 | 可以睡眠,但受可重入约束(9.8.3) |
| 屏蔽机制 | 关中断 / 屏蔽中断线 | sigprocmask/pthread_sigmask 设置的阻塞集 |
| 排队语义 | 中断线被屏蔽期间事件会丢失或被记录 | 标准信号不排队:同类未决只保留一个 |
9.8.1 产生、阻塞、未决
三个概念互相正交,混为一谈是最常见的答错点:产生(generate)指 kill/raise 或内核事件(子进程退出、写管道失败、定时器到期)把信号加入未决集;阻塞(block)指 sigprocmask 设置的屏蔽集决定「暂时不接收」哪些信号,阻塞不丢弃信号;未决(pending)指信号已产生但尚未投递(deliver)。三者的关系只有四种组合:
| |
未决集的合并语义:未决集中已有某个标准信号时,再来一个同种信号只会置同一个未决位,所以「产生过 N 次」无法从状态还原。实时信号(SIGRTMIN~SIGRTMAX)按值排队,因此是可靠信号——「不可靠信号 vs 可靠信号」的准确含义就是不可靠 = 不排队、会合并。
9.8.2 SIGCHLD 为什么会丢
SIGCHLD 是标准信号,因此多个子进程同时退出只留下一个未决的 SIGCHLD。这与「每个子进程退出发一次通知」是完全不同的事实,后果是两个常见 bug:以为 SIGCHLD 计数等于退出的子进程数(计数永远偏小,僵尸堆积);在处理函数里只 wait() 一次(剩下的僵尸要等下一个子进程退出才可能被回收)。正确写法是循环回收,并保持可重入安全:
| |
9.8.3 可重入约束:为什么只能调异步信号安全函数
处理函数只能调用异步信号安全(async-signal-safe)的函数,这是 POSIX 的明确规定(signal-safety(7) 给出安全函数清单)。原因不是风格而是机制:处理函数可以打断任何代码,包括正在修改 malloc 空闲链表、正持 stdio 内部锁、正在更新 locale 全局状态的代码。故障形态只有三类:被中断的代码正持 malloc/stdio 的锁而处理函数再进同一把锁,导致同一线程自锁(非递归锁)而永久挂起;被中断的代码只完成链表的「摘下」还没「接上」,处理函数写同一结构导致链表断裂或双重释放;处理函数内的失败调用覆盖 errno,返回后上层用错误码走错分支。安全的工程手段有两种:只设置 volatile sig_atomic_t 标志或自增计数,由主循环检查;或在处理函数里往一个非阻塞管道的写端写一个字节,把通知转成 I/O 事件交给主循环(self-pipe trick)。需要复杂回收时,现代做法是用 signalfd 把信号变成可读文件描述符交给 epoll(见第 7 章 7.5 节)。
警告
高频错误回答:「信号处理函数里可以做任何事,只要小心点」。正确表述是只能调用异步信号安全的函数,POSIX 有明确清单;典型反例是 printf 与 malloc,被中断的代码可能正持它们的锁,必然存在死锁窗口。加分点是能说出 self-pipe/signalfd 这两种把信号「去异步化」的手段。
9.9 块层与 I/O 栈
从 write 返回到数据真正落在介质上,中间隔着一串职责明确的层。这张图是本章的第二个必画图:
| |
从这张图能推出三个常被追问的结论:write 成功不等于数据落地(buffered 路径只保证进了 page cache);一次 buffered I/O 至少两次数据搬运(用户缓冲 → page cache 是 copy_from_user,page cache → 设备是 DMA),这是零拷贝要省的对象(见第 7 章 7.6 节);大量 D 状态出现的位置就在这条链上(等 page lock、等 bio 完成),所以平均负载高而 CPU 低时要看 vmstat 的 b 列(见第 11 章 11.1 节)。
9.9.1 I/O 调度器:四种算法的适用面
多队列块层(blk-mq)里调度决策发生在软件队列层,硬件队列只负责执行。四种调度器的取舍由设备随机访问代价与队列深度决定:
| 调度器 | 核心机制 | 适用设备 | 不适用/代价 |
|---|---|---|---|
none(原 noop) | 只做合并,不重排,请求直接下发 | NVMe 等自带深队列与内部调度的设备;virtio-blk 等半虚拟化设备 | 机械盘上无重排,随机负载下寻道爆炸 |
mq-deadline | 每类(读/写)维持排序队列与 FIFO 队列,超时后把 FIFO 里的请求强制提升;读优先于写 | 单队列/低队列深度设备、对延迟上界敏感的数据库场景 | 不提供带宽比例公平;写饥饿只有超时兜底 |
kyber | 用令牌桶限制每个类别的在途请求数,把延迟本身作为控制目标 | 快速设备(NVMe)上希望多种负载下稳定读延迟 | 目标是延迟而非公平,不适合按比例分带宽的多租户 |
bfq | 按 cgroup/进程分配带宽预算,逐请求按预算调度 | 慢速设备(机械盘、USB 存储)上的多任务场景 | CPU 开销最大,在 10⁵ IOPS 级设备上会成为瓶颈 |
选择判据可以压缩成一句:设备自己能重排(深队列 + 内部调度)就选 none;需要延迟上界选 mq-deadline 或 kyber;需要带宽公平选 bfq。 查看与切换只需读写这几个 sysfs 文件:
| |
队列深度必须给算例。设单次随机读耗时 L = 100 μs(SSD),队列深度为 Q,则理论 IOPS = Q / L:Q = 1 时 IOPS = 1 / 100 μs = 10⁴,即 10 K IOPS,这就是「同步逐条下发」的天花板;Q = 32 时 IOPS = 32 / 100 μs = 3.2 × 10⁵,即 320 K IOPS。同一设备、同样单次延迟,只因在途请求数不同就差 32 倍——这是「应用必须并发下发 I/O」的量化理由,也是 fio 报 IOPS 必须同时报 iodepth 的原因。机械盘相反:L 由寻道主导(约 5~10 ms),重排能减少寻道次数,所以深队列 + 调度器重排才让它接近顺序访问,NCQ(SATA,队列深度 32)与 TCQ(并行 SCSI,深度更大)提供的就是这个能力。
重要
记住这条链路的分工:调度器负责顺序,队列深度负责并发,设备内部调度负责最终的物理优化。三者可叠加但不可互相替代:none 不代表没有优化(设备自己在做),bfq 再聪明也救不了 iodepth = 1 的应用。
9.9.2 buffered I/O 与 direct I/O
两条路径的差异不只是「有没有 page cache」,而是谁负责缓存、谁负责对齐、谁负责同步:
| 维度 | buffered I/O | direct I/O(O_DIRECT) |
|---|---|---|
| 数据落点 | page cache,再由回写写入设备 | 用户缓冲直接作为 DMA 目标,绕过 page cache |
| 数据搬运 | 用户 ↔ page cache 一次拷贝,页再交给设备 DMA | 用户缓冲直接给设备 |
| 对齐要求 | 无 | 偏移、长度、缓冲地址都须按逻辑块大小对齐(通常 512 B 或 4 KB),否则 EINVAL |
| 读写语义 | 读可能命中缓存;写完成后对同机进程立即可见 | 每次都到设备;不参与缓存一致性,多进程共享区域需自行处理 |
| 缓存污染 | 大文件顺序扫会挤掉真正的热点数据 | 无污染 |
| 小 I/O 表现 | 好(缓存合并、预读) | 差(每次都是真 I/O,无合并) |
| 适用场景 | 绝大多数业务读写 | 应用自己已有缓存(如 innodb_flush_method=O_DIRECT 避免双份缓存) |
O_DIRECT 的两个坑必须说出来:其一,写路径上内核可能因为正在回写该文件重叠区域而阻塞新请求,造成延迟尖峰(顺序一致性要求);其二,对齐要求让应用必须自己处理「不足一个块的尾巴」,通常做法是按块读写、尾部单独处理。因此「无脑上 O_DIRECT 更快」是错的:它只在「应用自己有缓存」或「数据确定不会被复用」时才划算。
9.9.3 fsync 到底保证了什么
fsync(fd) 的语义是:把该文件所有已修改数据与足够的元数据(使得数据能被读回来所需的元数据,如大小、块映射)下推到持久介质,并等待设备报告完成。三个层次必须分清:
| 调用 | 保证的范围 | 典型用途 |
|---|---|---|
write 返回 | 数据在 page cache(掉电即丢) | 追求吞吐、可容忍少量丢失 |
fdatasync | 数据 + 读回数据所必需的元数据(不含 mtime/atime) | 日志类文件 |
fsync | 数据 + 全部元数据;目录项变更还需对目录单独 fsync | 事务日志、重命名后需可见的写入 |
sync / syncfs | 全系统 / 整个文件系统 | 关机前、维护操作 |
必须同时说清 fsync 的三个边界:它管不到硬件写缓存——若设备易失性写缓存处于「已确认未落介质」的模式,fsync 返回成功仍可能掉电丢数据,要真正保证需开启写缓存刷写(FUA / 写屏障),代价是吞吐下降,这是「数据安全」与「IOPS」之间无法回避的取舍;它不保证父目录——新建文件后必须对目录也 fsync,否则掉电后可能「文件内容在,但名字不在」;它是同步点,会串行化——高并发下逐笔 fsync 会让吞吐被设备延迟锁死(IOPS = 1 / L,对 L = 100 μs 的设备约 10 K 次/秒),工程解法是组提交(group commit):把多个事务的日志合并成一次 fsync,用「一批的等待」换「一次设备往返」。这正是第 21 章 21.3 节 MySQL redo log 组提交要解决的问题,也是 innodb_flush_log_at_trx_commit 三档(1 = 每次提交都刷、2 = 写 OS 缓存每秒刷、0 = 交给后台线程)背后「不丢」与「不卡」的取舍。
注意
与第 21 章的分工:本章只讲 fsync 在内核侧保证了什么、代价在哪;「redo log 怎么写、组提交怎么合并事务、崩溃后怎么恢复」见第 21 章 21.2~21.3 节。被问「MySQL 怎么保证不丢数据」时,正确答法是先给本节的内核语义,再引到组提交与 innodb_flush_log_at_trx_commit。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 轮询 vs 中断 | 判据是「固定开销 × 频率」与「轮询成本」的比较;高频密集事件用轮询(NAPI、io_poll) | 以为中断永远优于轮询 |
| 上下半部与四种下半部 | 上半部只做应答与登记(几百纳秒~2 μs);下半部判据是运行上下文:软中断/tasklet 不可睡眠,workqueue/线程化中断可睡眠 | 答「下半部就是延后执行的线程」,或把 tasklet 与 workqueue 的区别说成「速度快慢」 |
ksoftirqd | 软中断预算(约 2 ms、10 轮)耗尽后转交内核线程,保证不饿死用户进程 | 以为它是软中断的执行者(它只是溢出承接者) |
| 中断上下文不能睡眠 | 没有任务实体,睡下去无人唤醒;用 in_interrupt() 判定 | 只背结论,说不出「谁唤醒我」这个判据 |
| RSS 与多队列 | 按流哈希分到 N 个队列再分摊中断;单条流仍只落一个队列 | 以为多队列能提升单连接吞吐 |
| 系统调用路径与开销量级 | 寄存器约定 → syscall(读 MSR_LSTAR)→ entry_SYSCALL_64 建 pt_regs → sys_call_table[nr] → sysretq;一次约 100 ns ~ 1 μs,贵在特权级/地址空间切换与缓解措施 | 说内核用 errno 判错、把第 4 个参数说成 rcx,或说「系统调用慢是因为要拷贝参数」 |
| vDSO | 内核把持续维护的只读数据映射进用户空间,clock_gettime 直接读 | 说「vDSO 把系统调用搬到用户态执行」;以为 getpid 也能走 vDSO |
| 是否更贵 | 系统调用入口已知、自愿;中断入口未知、强制,需完整保存现场并可能打断临界区 | 答「都要切内核态所以差不多」 |
| 信号三态与合并语义 | 产生/阻塞/未决互相独立,阻塞不等于丢弃;标准信号不排队,SIGCHLD 会被合并 | 把阻塞当忽略,或用计数器统计子进程数 |
| 信号处理函数 | 只能调用异步信号安全函数;printf/malloc 会自锁或损坏结构 | 答「小心点就行」 |
| 块层栈 | VFS → page cache → 文件系统 → 块层(合并/排队/调度/拆分)→ 驱动 → 设备队列 → 中断完成 | 把 I/O 调度器当成唯一的排队点 |
| 调度器与队列深度 | 设备能重排选 none;要延迟上界选 mq-deadline/kyber;要带宽公平选 bfq;IOPS = Q / L | 在 NVMe 上坚持 bfq,或报 IOPS 时不提队列深度 |
fsync 语义 | 保证数据 + 必要元数据下推并等设备确认;管不到硬件写缓存,也不管父目录 | 说「fsync 一定不丢数据」 |
自测题
Q1(判断)「中断一定比轮询高效,因为轮询要一直占用 CPU。」这句话对吗?请给出判据并说明 NAPI 属于哪一类。
答案与解析
不对。中断省的是「无事发生时的 CPU」,代价是每次事件都要支付固定开销(保存完整现场、进入/退出内核、扰动流水线与缓存),事件频率高到一定程度后摊到的固定开销会超过轮询成本。判据是比较「中断单次固定开销 ÷ 每次中断处理的事件数」与「轮询一次的成本 ÷ 每次问到的事件数」;NAPI 是混合方案:第一个报文用硬中断唤醒,随后关中断、在软中断上下文按预算轮询收包环,把「每包一次中断」变成「每批一次中断」。
错在哪:把「中断省 CPU」当成无条件结论。面试官会追问「那你为什么不在 NVMe 上关掉中断」——高 IOPS 场景下这确实是可选做法(io_poll),代价是必须独占一个 CPU 忙轮询。
Q2(推导)为什么软中断上下文不能睡眠?请从「睡眠」在内核中的完整含义出发推导,并说明 in_interrupt() 与 in_softirq() 的判定范围差异。
答案与解析
睡眠包含四件事:把当前任务从运行队列摘掉置为睡眠态;注册等待项记录「谁在什么条件下唤醒我」;调用调度器切换;将来由另一个上下文通过等待项重新入队。软中断上下文缺的是第 1 步的主体和第 4 步的对象——它运行在当前任务的内核栈之上(中断返回路径或 ksoftirqd 线程里),不是调度器队列成员;若在其中睡眠,调度器切走后没有数据结构记录「这个软中断还没做完」,也就没有谁能唤醒它,结果是该 CPU 上的执行流永久停滞(内核用 schedule while atomic 之类告警尽力拦截)。判定范围上,in_interrupt() 等价于「在硬中断 ∨ 在软中断 ∨ 在 NMI」,包含 in_softirq();而 in_softirq() 的含义是「在软中断上下文或已禁用下半部」,所以 local_bh_disable() 包起来的临界区也会让它为真,这是很多人在看似正常的代码里踩到告警的原因。
错在哪:只背「中断里不能睡眠」而说不出判据(「谁唤醒我」)。追问方向:「那持自旋锁时能睡眠吗?」不能,原因同构——关抢占后无人能推进解锁方,且自旋锁本就不该被长持有。
Q3(推导)为什么 gettimeofday 可以不进内核,而 getpid 不行?如果进程通过 seccomp 装了严格的系统调用过滤器,vDSO 会带来什么麻烦?
答案与解析
vDSO 的可行性条件是:结果由内核持续维护、读取所需数据可以放在共享只读页、换算不需要内核逻辑。时间满足全部三条——内核把当前时间写在 vvar 页中,用户态读两个字段再做定点乘加即可。getpid 不满足第一条:它不是持续演化的数据,而是「属于当前进程的瞬时值」,fork/clone 之后子进程的返回值不同,而 vDSO 只能在固定地址放一份共享数据,无法为每个进程维护会变化的快照(glibc 历史上曾缓存 getpid,后因 fork 语义冲突而移除)。seccomp 的麻烦在于过滤点与执行点错位:seccomp 观察的是系统调用,而 vDSO 快路径不产生系统调用,对过滤器完全不可见;若安全策略依赖「所有时间查询都必须被记录或检查」(SECCOMP_RET_TRAP、SECCOMP_RET_USER_NOTIF),应用必须显式关闭 vDSO 时间快路径。类似地,容器时间命名空间要求时钟偏移按命名空间计算,共享页无法表达 per-namespace 偏移,内核也会通知用户态退回系统调用。
错在哪:答「vDSO 就是把系统调用搬到用户态执行」——它不执行任何内核逻辑,只是读内核预先写好的数据。追问方向:「那 open 能不能搬到用户态?」不能,它需要内核执行文件系统逻辑与权限检查。
Q4(设计)一个服务在低峰期正常,高峰期 top 显示 si 很高、ksoftirqd/* 占满若干核、接口 P99 恶化。请给出排查顺序与处置方案,并说明为什么先做某一步。
答案与解析
排查按「确认是哪类软中断 → 确认是分布问题还是总量问题 → 确认是事件过多还是处理过慢」推进。先看 /proc/softirqs 里哪个类型在涨:NET_RX 单项飞涨说明问题在收包路径;BLOCK 在涨说明块层完成中断与上层唤醒/回收在互相催动。再看 /proc/interrupts 与 /proc/softirqs 的 per-CPU 分布:若某个队列的中断几乎全落在 CPU0 而其他核空闲,说明是分布问题而非能力问题。处置顺序为:(1) 减压(收益最大、风险最低)——检查并开启 GRO/TSO 与中断合并(ethtool -c),减少事件总数;(2) 分流——确认多队列与 RSS 生效(ethtool -l),用 smp_affinity 或 irqbalance 把队列中断分摊到多核,并让消费该队列的线程与中断同核,必要时用 RPS/RFS;(3) 限流/扩容——应用侧增加并发或减少小包,最后才考虑调大 net.core.netdev_budget 与 netdev_max_backlog。
顺序的理由:中断预算参数只是把工作从软中断挪到应用或直接丢弃,不改变「事件太多、处理太慢」这个根因;减少事件与分摊到多核才是真正降低单位时间中断数的手段。同时应查 /proc/net/softnet_stat 的丢包列与 netstat -s,确认是否已经发生丢弃。
错在哪:一上来就调大 netdev_budget 或直接扩容机器。面试官会追问「你怎么确认 RSS 没生效」——看 /proc/interrupts 中各队列是否在不同 CPU 上有数字,以及 ethtool -l 报的通道数是否与队列数一致。
Q5(计算 + 辨析)某 SSD 单次随机读延迟 L = 100 μs,应用以同步方式逐条下发。理论 IOPS 上限是多少?并发提升到 32 后是多少?再回答:fsync 返回成功后,数据是否一定不会因掉电丢失?
答案与解析
逐条下发时队列深度 Q = 1,IOPS = Q / L = 1 / (100 × 10⁻⁶ s) = 10⁴,即约 10 K IOPS;把在途请求提升到 Q = 32 后 IOPS = 32 / (100 × 10⁻⁶) = 3.2 × 10⁵,即约 320 K IOPS。同一设备、同样单次延迟,只因在途请求数不同就差 32 倍,这解释了为什么报 IOPS 必须同时报队列深度,也解释了为什么同步逐笔 fsync 的吞吐被设备延迟直接锁死。fsync 返回成功不保证掉电不丢:它保证的是内核把数据与读回所需的元数据下推到设备并等待设备报告完成,但如果设备的易失性写缓存处于「已确认未落介质」的状态,掉电仍会丢;要真正保证需启用写缓存刷写(FUA/写屏障),代价是吞吐显著下降。另外 fsync(fd) 不保证父目录的目录项持久——新建文件后还要 fsync 目录,否则可能出现「内容在、名字不在」。
错在哪:用 IOPS = 1 / 延迟 作答时忘了乘队列深度,或把 fsync 说成「数据一定落盘」。面试官会追问「那 MySQL 怎么在两者间取平衡」——答案是组提交:把多个事务的日志合并成一次 fsync,用批量等待换一次设备往返(见第 21 章 21.3 节)。
面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 为什么中断处理要分成上半部和下半部?上半部为什么必须短? | 中断架构 | 答「因为上半部不能睡眠所以活要挪走」只答对一半;加分点是给出三条物理约束:屏蔽同线中断、上下文把能做的事限死、会打断任意临界区 | 9.2 |
| 软中断、tasklet、工作队列有什么区别?什么时候用哪个? | 下半部机制 | 答「tasklet 快、workqueue 慢」是典型错误;判据是运行上下文与「能否睡眠」,且 tasklet 已在新内核中被弃用 | 9.2 |
ksoftirqd 是干什么的?为什么会有软中断风暴? | 软中断调度 | 答「它负责执行软中断」不准确——它只是超出预算后的承接者;答不出预算量级(约 2 ms、10 轮)与 per-CPU 语义 | 9.2.1 |
| 中断上下文为什么不能睡眠? | 内核上下文约束 | 只背「不能睡眠」而不给判据;正确答法是「没有任务实体,睡下去没人能唤醒我」,并能说清 in_interrupt() 的判定范围 | 9.3 |
| 网卡中断全压在 CPU0 上,你怎么查、怎么改? | 中断亲和性与 RSS | 只会说 irqbalance;加分点是指出 /proc/interrupts 看分布、smp_affinity 改绑定、RSS 按流哈希分队列,并说清「单条流只能落一个队列,多队列救不了单连接」 | 9.4 |
| 请完整说一次系统调用从用户态到内核再返回的路径。 | 系统调用路径 | 只说「陷入内核执行再返回」;必须能说出寄存器约定(rax 号、rdi/rsi/rdx/r10/r8/r9)、MSR_LSTAR、pt_regs、sys_call_table 索引、sysretq | 9.5 |
| 一次系统调用大概多贵?贵在哪里? | 开销量级 | 答「因为要在用户态和内核态之间拷贝参数所以慢」是错的;主要成本是特权级/地址空间切换与缓解措施,量级约 100 ns ~ 1 μs | 9.6 |
gettimeofday 为什么不进内核?getpid 为什么不行? | vDSO | 答「vDSO 把系统调用搬到用户态执行」会被立刻追问倒;正确机制是内核写共享只读页、用户态直接读,还需说明 seccomp 与时间命名空间下的失效 | 9.6.1 |
| 中断和系统调用谁更贵?为什么? | 开销对比 | 答「都是切内核态所以差不多」;根因在于中断是不可预期的强制入口,需完整保存现场并可能打断临界区 | 9.7 |
| 信号被阻塞了会不会丢?未决和阻塞有什么区别? | 信号状态机 | 把阻塞当忽略是最常见错误;还要能说清标准信号不排队、未决只保留一个,实时信号才按值排队 | 9.8.1 |
SIGCHLD 会不会丢?为什么用 wait 一次不够? | 信号合并语义 | 答「每个子进程退出都会发一次 SIGCHLD」是错的;必须用 waitpid(WNOHANG) 循环,并解释标准信号的合并语义 | 9.8.2 |
信号处理函数里为什么不能调 printf? | 可重入与异步信号安全 | 答「会慢」或「小心点就行」;正确解释是被打断的代码可能正持 stdio/malloc 的锁,导致同线程自锁或结构损坏 | 9.8.3 |
从 write 到数据落到磁盘,中间经过哪些层? | 块层 I/O 栈 | 只答「写缓存再刷盘」;要按 VFS → page cache → 文件系统 → 块层(合并/调度)→ 驱动 → 设备队列 → 中断完成逐层说清,并指出 write 返回不等于落盘 | 9.9 |
I/O 调度器怎么选?NVMe 上为什么常用 none? | 调度策略 | 无脑答「bfq 最公平所以最好」;正确判据是设备能否自行重排(深队列)、是否需要延迟上界、是否需要带宽公平 | 9.9.1 |
| 为什么报 IOPS 一定要带队列深度? | 并发与延迟关系 | 只会说「并发高 IOPS 就高」而给不出算例;加分点是算出 IOPS = Q / L,并说明 Q = 1 时 SSD 只有 10 K IOPS | 9.9.1 |
| buffered I/O 和 direct I/O 该怎么选? | I/O 路径取舍 | 答「O_DIRECT 更快所以生产都用它」;必须给出对齐要求、小 I/O 变差、与回写重叠时的阻塞,以及「应用自己有缓存时才划算」的判据 | 9.9.2 |
fsync 保证了什么?它管不到什么? | 持久化语义 | 答「fsync 一定不丢数据」;必须补上硬件写缓存这个缺口与父目录需单独 fsync,并能引到组提交(见第 21 章 21.3 节) | 9.9.3 |
延伸阅读
man 2 syscall、man 2 syscalls、man 7 signal、man 7 signal-safety、man 2 fsync、man 2 open(O_DIRECT的语义与对齐要求)、man 5 proc(/proc/interrupts、/proc/softirqs、/proc/irq/*/smp_affinity)。- Linux 内核源码文档:
Documentation/core-api/genericirq.rst(中断子系统与线程化中断)、Documentation/block/(bfq-iosched.rst、kyber-iosched.rst)、arch/x86/entry/entry_64.S(entry_SYSCALL_64与sysretq)、arch/x86/entry/syscalls/syscall_64.tbl(系统调用号唯一权威表)。 - Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 2(
SYSCALL/SYSRET/SWAPGS的硬件语义)、Volume 3(APIC 与 MSI-X 中断投递);NVM Express Base Specification(提交/完成队列、门铃机制与队列深度定义);fio官方文档(iodepth与随机/顺序口径);Bryant & O’Hallaron, Computer Systems: A Programmer’s Perspective(第 3 版第 8 章「Exceptional Control Flow」);Arpaci-Dusseau, Operating Systems: Three Easy Pieces(「Dialogs on Virtualization」「Hard Disk Drives」「Redundant Arrays of Inexpensive Disks」三节)。