1. "我干完了"这句话,硬件到底是用什么方式说出来的
写驱动或者调 AI 推理服务的时候,最容易被忽略的一环,恰恰是数据搬完之后那句"我干完了"。DMA 把一块 buffer 从网卡搬到内存、从 SSD 搬到主机、从主机搬到 GPU 显存,这些都是硬件自己在总线上跑,CPU 全程不参与。问题在于:硬件跑完之后,CPU 凭什么知道?如果 CPU 不知道,它要么去读一块还没写好的内存,要么就一直卡在"要不要再等一会儿"的纠结里。
这其实是 DMA 机制里被讨论得最少、但踩坑最多的地方。大多数教材讲到 DMA 就停在"CPU 把控制权交给 DMA 控制器,传输完成后 DMA 控制器发中断",一句话带过。但真实系统里,这个"发中断"的动作有四五种完全不同的实现方式,性能差出几十倍,出错的方式也各不相同。你如果只记住"发中断"三个字,工程师在生产环境里会遇到的现象就是:P99 尾延迟莫名其妙地高、CPU 有一半时间耗在si(软中断)上、或者更糟——设备明明说完成了,读出来的数据却是上一次的旧值。
我把这个话题放在 AI Infra 的语境下讲,是因为这几年高性能场景的一个明显趋势是:越追求极致性能的系统,越倾向于不用中断来通知 CPU。NVMe 的 SSD 在 SPDK 里被轮询、RDMA 网卡在用户态被轮询、GPU 的完成通知直接绕开 CPU。这看起来是反直觉的——中断明明是为了把 CPU 从等待中解放出来而发明的,为什么高端场景反而把它扔了?
答案藏在"完成通知"的三个层次里,把这三层拆清楚,后面所有的选型都是自然而然的结果。
1.1 一道被大多数人跳过的题:完成通知的三个层次
我认为理解 DMA 完成通知,必须把问题拆成三层,很多人翻车是因为把三层混成一层来想。
第一层是数据在哪。DMA 的目标地址由谁写、写完了没有。这一层是纯硬件和总线的事,PCIe 上的 Memory Write TLP 发出去之后,从设备的角度看就是"发出去了",但真正落到内存、穿过各种 switch 和 root complex,是有时间的。这个时间在微秒量级,比 CPU 的一个时钟周期长得多。
第二层是完成信号在哪。设备需要在某个地方留下一个痕迹,让 CPU 能读到。这个痕迹可能是一个寄存器 bit(比如 DMA 控制器的 TC 位)、可能是描述符里的一个 own/done 位、可能是 NVMe 完成队列里的 phase 位、也可能是 VirtIO 的 used ring index。所有"轮询"都在读这个东西,所有"中断"也都是在写这个东西之后才触发。
第三层是CPU 什么时候该信它。这是最容易被忽略的一层。设备写了完成标志,不代表 CPU 立刻能读到正确的数据:编译器可能把读操作重排、CPU 可能从自己的缓存里读到旧值、乱序执行的处理器可能先读到标志再读到数据。所以从"标志可见"到"数据可用",中间需要内存屏障、需要 cache 维护、需要正确的读顺序。
我见过太多人卡在第三层:代码逻辑看起来完全正确,while (!(desc->done));循环也退出了,但memcpy出来的数据偶尔错几个字节,概率极低,跑几万次才出一次。这种 bug 最难查,因为它和时序、和 CPU 型号、和内核版本都有关系。把三层分开看,这类问题的定位思路就清晰了:先确认标志位协议对不对,再确认屏障加没加,最后才怀疑硬件。
1.2 四种通知手段的横向对照
把常见的完成通知方式列一张表,量级差异一目了然。这里的延迟数字都是"取决于平台"的量级参考,不同主板、不同 PCIe 拓扑、不同内核版本会差不少,不要当成绝对值去对标。
| 通知方式 | 典型感知延迟 | CPU 开销特征 | 主要适用场景 | 典型翻车点 |
|---|---|---|---|---|
| 忙等轮询寄存器 | 几十到几百纳秒 | CPU 全程占用,一个核被吃满 | 用户态高性能存储、低延迟交易 | 没加屏障,读到的标志和数据不同步 |
| MSI/MSI-X 中断 | 1–5 微秒 | 每次中断有上下文切换和调度成本 | 通用内核驱动、中低吞吐 | 中断风暴、亲和性配错 |
| 传统 INTx 线中断 | 5–30 微秒 | 需要回读设备寄存器确认来源 | 老设备、兼容场景 | 共享中断线导致误判来源 |
| 完成队列加门铃 | 取决于谁在查 | 查的人承担全部开销 | NVMe、RDMA、GPU 直连 | 队列深度和轮询节奏没匹配 |
这张表里最值得琢磨的是最后一行。"完成队列加门铃"本身不规定由谁来读队列——可以中断驱动,也可以轮询。门铃(doorbell)是反方向的通知:CPU 写完请求之后敲一下设备的门,告诉它"有活干了";完成方向则是设备写队列,由 CPU 去收。这两件事经常被搞混。
所以在 NVMe 这个具体例子里,异步通知的完整闭环是这样的:主机把命令写进提交队列(SQ),然后往设备的 SQ Tail Doorbell 寄存器写一个值,这是主机通知设备;设备执行完,把完成条目写进完成队列(CQ),如果开了中断就发一个 MSI-X,如果没开中断就等主机自己来读 CQ Head Doorbell,这是设备通知主机。两条方向都用门铃和队列,但门铃的语义正好相反。搞清楚这一点,看 NVMe 规范里那一堆 doorbell 寄存器就不会晕。
1.3 为什么小项目里没人纠结,大系统里却要抠到纳秒
在 STM32 这种 MCU 上写串口 DMA,大家基本都是"开个传输完成中断,中断里置个 flag,主循环看 flag 处理数据",跑得挺稳,因为这些场景的吞吐低、延迟要求宽松、中断频率也就每秒几千次,怎么搞都行。这也是为什么网上搜"串口 DMA 接收数据代码"能搜出一大堆看起来差不多的实现,但真正在工业现场跑起来就会暴露差异——这个后面在第 5 章我会专门拿 MCU 的案例做对照,因为它是理解"完成的定义"最直观的入口。
到了服务器侧,画风完全变了。一块 100G 网卡在满速收包时,如果每个包都发一次中断,中断频率会达到每秒千万级,CPU 什么都干不了,全在进出中断。一块 NVMe SSD 在 4K 随机读场景下,IOPS 可以到百万级,同样的问题。这时候"用什么方式通知"就从实现细节变成了决定系统上限的核心设计。
更关键的是,AI Infra 场景对尾延迟(P99、P999)极度敏感。一个推理请求走完整条链路,可能经过网卡收包、CPU 组 batch、GPU 计算、结果回传。中断带来的不只是平均延迟,还有抖动:中断可能被延迟处理、可能被路由到正在忙的核上、可能撞上内核的其他软中断。平均延迟多 2 微秒无所谓,但 P99 上多 200 微秒,用户就能感知到卡顿。这就是为什么高性能系统宁可用一个核死等,也不愿意被中断打扰——用一个核的确定性,换掉整个链路的抖动。
2. 中断这条路:从设备拉线一直到内核回调
中断是通用场景下最合理的方案,它解决的问题很实在:CPU 不用一直盯着设备。但它内部的机制比"设备发一个信号"复杂得多,理解这条链路上的每一段延迟,是后面做优化的前提。
2.1 INTx 的先天缺陷:共享、需要回读、延迟高
传统的中断方式叫 INTx,走的是 PCI 配置空间里的 Interrupt Pin/Interrupt Line,本质上是设备通过物理引脚拉高电平,经过中断控制器(现代平台上就是 IOAPIC/LAPIC 这套)路由到某个 CPU 核。这个机制有三个绕不过去的缺陷。
第一个是共享。PCI 总线上的中断线数量有限,多条线被多个设备共用是常态。当一个中断到来时,内核的中断处理程序必须遍历这条线上所有注册的设备,逐个读它们的状态寄存器,看看到底是谁触发的。这个"逐个回读"的动作很贵——每次读都是一次 MMIO,在 PCIe 上就是一次配置或内存读事务,往返延迟上百纳秒到微秒。设备越多,这个线性扫描越慢。
第二个是需要显式应答。设备拉高中断线之后,CPU 处理完必须去写设备的某个寄存器把它清掉(ack),否则中断会一直保持有效,触发重复中断。这个写操作同样是一次 MMIO。
第三个是电平触发下的竞态。电平触发意味着"只要线还高着,中断就一直有效"。如果设备在 CPU 应答之前又完成了一次传输,这两个事件会合并成一次中断,导致驱动漏掉一次完成事件。老驱动里那句经典的"必须要读到状态寄存器为 0 为止",就是在处理这个竞态。
所以 INTx 的延迟量级在几十微秒,而且随着设备数量增加而恶化。它在现代服务器上基本只作为兼容手段存在,一旦设备的 MSI-X 没能正确启用,性能会明显掉一个台阶——这是一个非常隐蔽的问题,我在第 5 章会讲怎么发现它。
2.2 MSI/MSI-X:把中断变成一次 PCIe 内存写
MSI 的核心思路非常漂亮:别拉线了,直接往一个特定的内存地址写一个特定的值。
在 x86 平台上,这个地址指向 LAPIC 的地址空间(形如0xFEE00000加上目标 APIC ID 左移若干位),写入的数据里编码了向量号。设备发起一次 PCIe Memory Write TLP,写进这个地址,CPU 的中断控制器就把它识别为一次中断,跳转到对应的向量处理函数。整个过程没有任何物理引脚参与,就是一次标准的总线写事务。
MSI-X 在此基础上做了三件事:把地址和数据放进设备 BAR 空间里的一张表(MSI-X Table),每个中断向量对应表里的一行,最多支持 2048 个向量;同时配套一张 Pending Bit Array 用于处理丢失问题;还允许每个向量单独屏蔽。
这三个改进带来了几个实打实的好处:
- 不需要回读确认来源。因为向量号直接对应到具体的设备和队列,中断处理程序一进去就知道是谁,不用扫描整条中断线。这一步省下的就是最贵的 MMIO 往返。
- 多队列可以各自独立中断。一块多队列网卡有 64 个 RX 队列,就能申请 64 个 MSI-X 向量,每个队列的中断可以路由到不同的 CPU 核,实现天然的并行。这是 RSS 能发挥作用的前提。
- 可以定向到指定核。因为地址里编码了目标 APIC ID,可以精确指定哪个核处理哪个队列的中断。
提示:MSI-X 的向量表在设备初始化时由驱动写入,写入后需要正确设置 Mask 位。如果驱动漏了这一步,或者固件把 MSI-X 表标成了屏蔽状态,内核会回退到 INTx。这个回退通常是静默的,只在
dmesg里留一行提示,非常容易漏看。
延迟上,MSI-X 大概在 1–5 微秒量级,比 INTx 快了一个数量级。但要注意,这个数字里包含了从设备发起写事务、经过 PCIe 交换机和 root complex、到 CPU 中断控制器、再到内核开始执行处理函数的时间。中断本身不慢,慢的是它后面那一整套软件流程。
2.3 上半部与下半部:为什么硬中断里只能"签收快递"
中断处理函数执行时,CPU 处于一个受限状态:当前核上的其他中断被屏蔽、不能被调度出去、不能睡眠、不能拿可能导致睡眠的锁。所以内核把中断处理拆成了两半。
上半部(hardirq handler)只做最紧急的事:应答设备、把数据从设备侧搬到内存(或者只是记录一个指针)、标记一下"有活要干",然后立刻返回。这个"标记一下"通常是把一个软中断(softirq)或者 tasklet 挂到待处理队列上。类比一下,上半部就是快递员按门铃,你开门签个字,把包裹放门口,然后回去干自己的事;真正的拆箱、整理、归档是下半部的事。
下半部(softirq、tasklet、工作队列)在中断返回时或者内核调度点执行,这时候中断已经打开,可以干更多活,比如协议栈处理、数据拷贝、回调上层。网络收包走的就是NET_RX_SOFTIRQ,NAPI 轮询也在软中断上下文里跑。
这个拆分解释了一个常见现象:top里看到的si(软中断)占比很高,往往说明下半部在工作,是正常现象;而hi(硬中断)占比高,通常说明中断频率本身太高,或者上半部干了不该干的事。这两个指标的含义完全不同,很多人排查问题时看错方向。
NAPI 的引入也在这个逻辑上。100G 网卡满速收包时,如果每个包都触发一次中断,光中断开销就撑不住。NAPI 的做法是:第一个包来了触发中断,中断里关掉这个队列的中断,切换到轮询模式,在一个软中断里批量收包,直到收完或者达到预算(通常 64 个包),再重新打开中断。这样把"每包一次中断"变成了"一批一次中断",中断频率下降几个数量级。这个"中断转轮询再转回中断"的思路,其实就是后面高性能场景全程轮询的雏形。
2.4 中断合并:ethtool 那几行参数背后的延迟与 CPU 权衡
网卡很早就意识到中断太频繁的问题,于是有了中断合并(interrupt coalescing):设备在完成若干次传输之后,不立刻发中断,而是等一小段时间或者累积到一定数量再发。Linux 上用ethtool -c就能看到和调整这些参数。
# 查看当前网卡的中断合并设置 ethtool -c eth0 # 典型输出中的关键字段: # rx-usecs: 收到包后延迟多少微秒才发中断 # rx-frames: 累积到多少个包就发中断 # tx-usecs / tx-frames: 发送方向的对应参数 # adaptive-rx / adaptive-tx: 是否由固件自适应调整这几个参数里的权衡非常直接:
| 参数方向 | 调小 | 调大 |
|---|---|---|
| rx-usecs / rx-frames | 延迟低、中断多、CPU 开销高 | 延迟高、中断少、CPU 开销低 |
| tx-usecs / tx-frames | 发送完成通知快 | 批量发送时吞吐更好 |
这里有个坑值得单独说:发送方向的中断合并会直接影响 send() 返回的时机。有些应用写完 socket 就认为数据发出去了,实际上数据可能还在网卡的发送队列里等合并计时器。如果应用依赖发送完成来释放缓冲区,合并时间设得太大会导致缓冲区回收延迟,进而出现"应用侧看着带宽没打满,但网卡侧其实是满的"这种诡异现象。
adaptive-rx打开之后,固件会根据中断处理耗时自动调整合并参数,看起来省事,但代价是行为不可预测——调优时为了拿到稳定的数据,我一般先把自适应关掉,把参数钉死,跑出一组基准数据,再决定要不要开回去。
3. 描述符环与完成队列:不算中断的那套"暗号"
中断是"设备主动喊 CPU",但设备和 CPU 之间还有一套更基础的通信方式:共享内存里的描述符环和完成队列。这套机制即使完全不发中断也存在,因为它承担着"数据在哪、完成了几个、下一个填哪"这些信息传递。理解它,是理解后面所有高性能方案的基础。
3.1 own 位与 phase 位:谁在写、什么时候写才算数
描述符环(descriptor ring)是个环形缓冲区,主机和设备共享。以网卡发送为例:主机把待发送的数据地址和长度写进一个描述符,然后把描述符的 own 位(或者叫 DD、done 位)置成"属于设备",再敲一下门铃告诉设备"有新的描述符了"。设备读到这个描述符,把数据发出去,然后回写描述符,把 own 位改回"属于主机",表示这个槽位用完了。
这个 own 位的语义是关键:它是一个方向明确的交接棒。主机写描述符内容,最后写 own 位;设备读 own 位判断这个槽位能不能用,用完再写回去。两边都不能"先写标志再写内容",因为那样另一方可能看到标志就以为内容已经就绪,读到垃圾数据。
NVMe 用的是另一种等价机制:完成队列里的phase 位(P 位)。主机初始化 CQ 时把所有条目的 phase 位设成 0;设备填完一个完成条目后,把 phase 位翻成 1;主机读到 phase 是 1 就认为这个条目有效,处理完之后,等整个队列绕一圈回来,phase 位的期望值翻转成 0。这样设备不需要每次清零,主机也不需要额外的标志位,一个 bit 就完成了"这个槽位是新的还是旧的"的判断。这是个非常巧妙的设计,省掉了每个条目 1 字节以上的状态开销。
VirtIO 用的又是另一套:分离的 avail ring 和 used ring,各自带一个单调递增的 idx。驱动往 avail ring 填完,用写屏障保证 idx 更新在之前可见,然后敲 doorbell;设备处理完往 used ring 填,更新 used idx,再发中断。驱动读 used idx 之前要先读屏障。这套设计的妙处是 idx 只增不减,绕回时靠模运算,不存在"清零竞态"。
三种机制名字不同,但内核思想完全一致:用一个单调的、方向明确的状态位来标记所有权交接,并且保证状态位的写入顺序在所有内容写入之后。
3.2 内存屏障与可见性:写了标志不等于 CPU 能看见
这是整个话题里最容易出 bug 的地方,也是我认为最值得展开的一节。
考虑这样一段典型代码:设备往内存写了数据,然后写了描述符的完成位。CPU 侧循环读完成位,看到置位之后去读数据。看起来天衣无缝,但实际执行时可能出现三种重排:
第一种是编译器重排。C 语言的内存模型里,如果编译器没有看到两个操作之间有依赖关系,它可以调整顺序。while (!done);这个循环,编译器完全可能把循环后面的数据读操作提到循环前面去,因为它认为这两者无关。这就是为什么内核代码里到处是READ_ONCE()、WRITE_ONCE()和barrier()—— 它们的作用就是告诉编译器"别动这里的顺序"。
第二种是CPU 乱序执行。现代处理器为了填满流水线,会推测性地执行后面的指令。如果读操作没有屏障约束,CPU 可能先发起数据读,再发起标志读。对 x86 来说这种情况相对少(x86 的 TSO 模型比较强,读读之间不会重排),但在 ARM、RISC-V 这些弱内存序平台上,硬件完全可能这么干。所以内核提供了dma_rmb()/dma_wmb()这类原语,用来约束 DMA 方向的数据访问顺序。
第三种是缓存可见性。在一致性域(coherent domain)内的 DMA,硬件的缓存是一致的,设备写的数据会经过缓存一致性协议让 CPU 的缓存失效或者更新。但在非一致性平台上(不少 ARM SoC 的外设 DMA 通道属于这种情况),设备直接写物理内存,CPU 的缓存里可能还留着这块内存的旧副本。这时候光加屏障没用,必须做 cache 维护操作。这是后面 3.4 节要讲的。
把这三者写成一段可以抄的伪代码:
/* 主机侧等待完成并读取数据 */ for (;;) { /* READ_ONCE 防止编译器把下面的读提前 */ if (READ_ONCE(desc->status) & DMA_DONE) break; cpu_relax(); /* 轮询时让出流水线,避免过度占用 */ } /* 保证在读取数据之前,完成状态已经被观察到 */ dma_rmb(); /* 现在读数据才是安全的 */ memcpy(buf, desc->data, desc->len);cpu_relax()这个看起来不起眼的调用也值得说一句。在 x86 上它编译成pause指令,作用是给超线程的兄弟核让出执行资源,同时降低轮询循环的功耗。少了它,一个死等循环能把整个核的功耗拉满,还会拖慢同一物理核上另一个线程。ARM 上对应的是yield。这类细节在教科书里不写,但在实际高性能代码里是标配。
3.3 NVMe 的 SQ/CQ/doorbell 三件套走一遍
把 NVMe 的完整流程走一遍,能把这套机制串起来。
主机的初始化阶段:在内存里分配 SQ 和 CQ,SQ 条目 64 字节,CQ 条目 16 字节,大小由队列深度决定;把 CQ 所有条目的 phase 位初始化为 0;把两个队列的物理地址和长度通过 Admin 命令告诉控制器;控制器返回后,主机把 SQ Tail Doorbell 和 CQ Head Doorbell 的地址记录下来。
下发一个读命令:主机在 SQ 里找一个空槽位,填好命令(操作码读、起始 LBA、长度、数据缓冲区地址等),把队列的 tail 指针加一,往 SQ Tail Doorbell 寄存器写新值。这一步是 MMIO 写,成本大约几百纳秒。
设备执行完毕:控制器把完成条目写进 CQ 的当前槽位,条目的 phase 位设成 1,然后发一个 MSI-X 中断(如果启用了中断的话)。
主机收尾:中断处理程序(或者轮询线程)读 CQ 当前槽位的 phase 位,如果等于期望值就说明有新条目,取出状态码,head 指针加一,如果没有更多条目,往 CQ Head Doorbell 写新值告诉控制器这个槽位可以复用了。
整个流程里有两个门铃写,都是 MMIO 操作,都是延迟敏感点。SQ Tail Doorbell 的写延迟直接影响 IO 下发速度,所以高性能驱动会做门铃合并:不是每下发一个命令就敲一次门,而是攒一批一起敲。CQ Head Doorbell 的写则是回收队列空间的必要条件,写晚了设备会因为 CQ 满而停止提交完成条目,进而导致 SQ 也堵住。
NVMe 规范里还给中断合并留了参数(Coalescing Disable、Aggregation Time、Threshold),逻辑和网卡的中断合并一样:攒够多少个完成事件、或者等多久,才发一次中断。默认配置往往偏保守,延迟敏感的负载需要把它调小甚至关掉。
3.4 非一致性平台上的 cache 维护:ARM 上最容易翻车的一环
x86 平台上做驱动很幸福,因为 DMA 是硬件保证一致的,设备写内存会自动让 CPU 的缓存失效,代码里基本不用管 cache 维护。但 ARM 上不一定,很多外设 DMA 通道属于非一致性设备,需要软件显式处理。
内核里对应的三类 API:
/* 传输前:把 CPU 写入的描述符刷到内存,让设备能读到 */ dma_wmb(); /* 或者对非一致性设备使用同步接口 */ dma_sync_single_for_device(dev, dma_addr, size, DMA_TO_DEVICE); /* 传输后:把 CPU 缓存里的旧数据失效,保证读到设备写的新数据 */ dma_sync_single_for_cpu(dev, dma_addr, size, DMA_FROM_DEVICE);这里最容易翻车的地方是流式 DMA 映射的同步方向。DMA_FROM_DEVICE表示"设备要写这块内存,CPU 马上要读",语义上需要 invalidate cache;DMA_TO_DEVICE表示"CPU 写好了,设备要读",需要 clean cache。方向搞反了,在 x86 上可能什么都看不出来(因为硬件一致),一到 ARM 上就出现数据错乱,而且往往是"偶尔错、复位后就好了"这种最难受的表现。
注意:非一致性 DMA 的 cache 维护必须覆盖完整的 cache line 范围。如果映射的缓冲区不是 cache line 对齐的,invalidate 的时候可能把相邻数据也冲掉。所以给 DMA 用的缓冲区,要么用
dma_alloc_coherent()申请,要么在申请普通内存时保证对齐,别图省事。
4. AI Infra 的反直觉选择:越是高性能,越不想被中断打扰
前面铺垫了这么多,现在可以回答开头那个反直觉的问题了。
4.1 用户态轮询:SPDK、io_uring IOPOLL、DPDK PMD
高性能存储和网络栈的共同做法是:把设备完全交给用户态,用专门的核去轮询完成队列,彻底绕开中断和内核协议栈。
SPDK 是最典型的例子。它把 NVMe 驱动整个搬到了用户态,配合 UIO 或 VFIO 把设备 BAR 空间映射到进程地址空间,应用直接读写 doorbell 寄存器、直接读 CQ。整个路径上没有系统调用、没有中断、没有上下文切换。代价是必须用轮询线程,一个核被占满。但省下来的开销非常可观:内核路径下一次 4K 随机读的延迟大概在十几到几十微秒,SPDK 用户态轮询能压到几微秒。
io_uring 提供了一条渐进的路径,它有两个和轮询相关的模式:
- IOPOLL:提交 IO 之后,通过
io_uring_enter主动去收割完成事件,内核不去等设备中断,而是直接轮询设备完成队列。适合 NVMe 这类能用轮询模式的设备。 - SQPOLL:内核起一个线程专门盯着 SQ,应用只管往 SQ 里填,不用每次提交都进系统调用。
# 用 fio 对比中断模式和轮询模式的差异 # 中断模式(默认) fio --name=seqread --filename=/dev/nvme0n1 --rw=randread --bs=4k \ --iodepth=32 --ioengine=io_uring --direct=1 --runtime=30 # 轮询模式,看延迟和 IOPS 的变化 fio --name=seqread_poll --filename=/dev/nvme0n1 --rw=randread --bs=4k \ --iodepth=32 --ioengine=io_uring -- hipri --direct=1 --runtime=30实测下来,轮询模式在 iodepth 较低的随机读场景下,P99 延迟的改善比平均延迟更明显,因为省掉了中断到达时间的不确定性。但要注意,轮询模式下如果 iodepth 太低,CPU 大部分时间在空转,吞吐反而可能不如中断模式——轮询是把 CPU 时间换成延迟确定性,只有队列足够深、请求足够密的时候才划算。
DPDK 的 PMD(Poll Mode Driver)是同样的思路用在网卡上。一个 PMD 线程绑在一个核上,循环调用rte_eth_rx_burst()批量收包。中断在这里完全不存在,收包、处理、发包全在同一批循环里完成。
4.2 RDMA 与 GPUDirect:CPU 退化成"记账员"
RDMA 把这件事推得更远。RDMA 的核心是"远程直接内存访问",网卡直接读写对端的内存,CPU 完全不参与数据搬运。完成通知靠的仍然是完成队列(CQ):应用提交一个 Work Request 到发送队列(SQ),网卡执行完往 CQ 写一个完成条目。应用可以用事件通道(Completion Channel)等待,也可以直接轮询 CQ。
在高频交易和 AI 训练场景里,标准做法是轮询 CQ。因为 CQ 在用户态可见(通过ibv_reg_mr映射),轮询一个 CQ 条目的成本比等一次事件通道通知低一个数量级。
GPUDirect RDMA 又往前走了一步:网卡直接把数据写进 GPU 的显存,不经过主机内存。这时候完成通知在 CUDA 侧用cudaStreamAddCallback或者直接把 RDMA CQ 和 CUDA stream 做同步。CPU 在这个过程中只做两件事:下发请求、收割完成。它从"搬运工"退化成了"记账员"。
有意思的是,GPU 内部也有自己的"完成通知"机制:kernel 执行完通过 CUDA event 或者 stream callback 通知主机,本质上还是"设备写完状态,主机轮询或等待"。而且 GPU 的完成通知同样有轮询模式——cudaEventQuery就是干这个的。整个 AI 计算链路上,从网卡到 GPU 到存储,"轮询完成状态"是贯穿始终的模式。
4.3 混合策略与自适应:中断轮询切换的临界点
纯轮询不是万能药,它的问题是低负载时浪费 CPU。真实的系统需要两种模式的切换,而切换的临界点是门手艺。
内核里几个相关的机制值得了解:
- busy_poll:NAPI 支持在有中断的情况下,先短暂轮询一下再进休眠。
net.core.busy_poll和busy_read两个 sysctl 控制这个行为,单位是微秒。对于"每隔几微秒就来一个包"的负载,短暂轮询能避免进出中断的成本;但对于"几毫秒才来一个包"的负载,纯粹是浪费。 - NAPI 的预算:
net.core.netdev_budget控制一次软中断最多处理多少个包。调大能减少软中断的调度次数,但会让单个软中断跑得更久,影响其他任务的响应。 - epoll 的 busy poll:
epoll_wait有个EPOLL_BUSY_LOOP标志,在等待事件时先忙等一小会儿。
我这个项目里踩过的坑是:一开始为了追求低延迟,把 busy_poll 开得很大,结果在请求稀疏的时段,CPU 空转把整机功耗拉高,而且因为轮询的核被占满,其他线程被挤到别的核上导致调度抖动。后来改成分段策略:请求密集时用轮询,空闲超过一个阈值就切回中断。这个阈值具体设多少,必须自己压测确定,因为不同的 CPU 型号、不同的中断延迟特征,临界点不一样。
4.4 DPU 与硬件调度器:把"通知"这件事整个搬走
最后说一下最近几年很热的方向:把完成通知这件事,从 CPU 手里彻底拿走。
DPU 或者智能网卡的基本思路是:主机 CPU 只处理业务逻辑,网络协议栈、存储虚拟化、任务调度这些事由卡上的处理器完成。从完成通知的角度看,这意味着原本要打到主机 CPU 的中断,现在打到了 DPU 上;主机 CPU 只在自己真正需要处理数据的时候被唤醒,或者干脆由 DPU 把结果准备好之后,一次性通知。
更激进的做法是硬件调度器(如 NVIDIA 的 GPUDirect Async 系列机制),设备之间直接互相通知:网卡收到数据后,直接往 GPU 的 doorbell 写一个值,GPU 自己去取数据,全程 CPU 只负责最初的配置。这时候"设备告诉 CPU 我干完了"这个问题被重新定义了——CPU 根本不需要知道,因为下一个处理环节是另一个设备。
这个趋势背后的逻辑很清楚:中断的价值在于"把 CPU 从等待中解放出来",但当 CPU 已经不再是数据通路上的一环时,通知它的意义就只剩下控制和错误处理了。
5. 手里有台机器怎么验证:可复现的观测与排错链路
原理讲完了,落地才是关键。这一章列的都是在真实环境里能直接跑的观测手段和排查顺序。
5.1 /proc/interrupts 与 /proc/softirqs:先看谁在叫
排查任何跟中断相关的问题,第一步永远是这两个文件。
# 连续两次采样,看中断增量。watch -d 会高亮变化的部分 watch -d -n 1 'cat /proc/interrupts | head -30' # 看软中断在各核上的分布 watch -d -n 1 'cat /proc/softirqs'/proc/interrupts的每一列对应一个逻辑核,每一行是一个中断源。要看几个东西:中断是不是集中在某一个核上(集中说明亲和性没配好)、每个中断号的名字对不对(能看到nvme0q1、eth0-TxRx-0这类多队列名字就说明 MSI-X 生效了,如果看到的是eth0而队列很多,可能就是退到了 INTx)、增量速度是否稳定(突发的尖刺往往对应某类特定请求)。
/proc/softirqs里要重点看NET_RX、NET_TX、BLOCK这几列。软中断分布极不均匀是个非常常见的性能问题,原因是软中断通常在触发它的那个核上执行,而中断又被 irqbalance 集中到了少数核上,形成了"一核有难、多核围观"。
5.2 中断风暴的完整排查链路
中断风暴是我遇到过最典型的一类问题:CPU 的si或者hi飙到 80% 以上,业务线程拿不到 CPU,延迟暴涨,但系统负载(load average)看起来又不高。因为中断和软中断消耗的时间很多不计入 load average,所以看 load 会误判。
完整的排查链路我一般是这么走的:
第一步,确认是硬中断还是软中断。top看hi和si两个数字,或者直接读/proc/stat的对应字段。hi高说明中断本身太频繁;si高说明下半部在处理大量数据。
第二步,定位到具体的中断源。用watch -d看/proc/interrupts哪一行增长最快。如果是网卡,看是哪个队列号;如果是存储,看是哪个 nvme 队列。
第三步,判断是流量问题还是配置问题。如果流量确实上来了,中断多属于正常,考虑开中断合并;如果流量没变但中断暴涨,那基本就是设备侧有异常,比如收到了大量错误包,或者某个连接在疯狂重传。
第四步,看是不是共享中断导致的误判。cat /proc/interrupts里如果同一行对应了多个设备名,说明中断线被共享了,IRQF_SHARED的驱动会各自检查一遍状态,开销成倍增加。
第五步,用 perf 看中断处理函数在干什么。
# 采样一段时间,看中断相关的调用栈 perf record -a -g -e irq:softirq_entry -- sleep 10 perf report我踩过的一个真实坑是:一块多队列网卡的中断合并参数被某个自动化脚本改成了极小的值,导致中断频率是正常水平的二十倍,si长期在 60% 以上。因为流量本身不高,谁都没往中断上想,查了好几天。后来用watch -d看/proc/interrupts才发现这个网卡的中断计数增速完全不合理。这个案例说明的教训是:常规指标(CPU 使用率、负载、带宽)都正常的时候,要去数中断。
5.3 亲和性与隔离:把中断钉在哪个核上
中断亲和性配置是个需要综合考虑的事。基本操作:
# 查看某个中断号的亲和性掩码 cat /proc/irq/68/smp_affinity_list # 把中断 68 绑定到 4-7 号核 echo 4-7 > /proc/irq/68/smp_affinity_list # 关掉自动均衡,避免配置被覆盖 systemctl stop irqbalance配置时有几个原则值得参考:
- 不要把所有中断都钉到 0 号核。0 号核通常承担了最多的内核活动,中断挤过去会互相干扰。
- 网络中断和它的用户态线程尽量同 NUMA 节点。跨 NUMA 访问内存的延迟翻倍,对高吞吐负载影响明显。
- 给关键业务线程留出独占核。用
isolcpus或者cgroup cpuset把核隔离出来,再用nohz_full减少时钟中断的干扰。被隔离的核上如果有中断过来,隔离的价值就打了折扣,所以还得配合smp_affinity把中断挪走。
提示:改动亲和性之前先记录原始配置。有些平台固件、容器运行时或者监控 agent 会在启动时重新设置亲和性,改完过一会儿又变回去了。这种情况下需要先找到是谁在改。
5.4 一组 MCU 对照实验:STM32 的 TC 中断与 IDLE 中断
回到开头说的 MCU 场景,这里有个特别能说明"完成定义"问题的经典案例:STM32 用 DMA 接收串口数据。
大多数入门代码是这么写的:配置 DMA 接收固定长度,开启 DMA 传输完成中断(TC),然后在DMAx_ChannelY_IRQHandler里读到TC标志,处理数据。这个方案在"对端每次发固定长度"的情况下没问题。但真实串口通信里,对端发来的数据长度是不定的——这时候 TC 中断就尴尬了:缓冲区设 100 字节,对端发了 20 字节然后停了,DMA 没满,TC 永远不会触发,数据卡在那里。
解决办法是同时开启串口空闲中断(IDLE)。串口的 IDLE 标志在接收线空闲一个字符时间后置位,含义是"这一帧数据发完了"。配合 DMA 使用就是:DMA 负责搬数据,IDLE 中断负责判断"这一帧结束了",因为在总线上空闲下来通常意味着发送方已经把一帧发完了。
/* 串口空闲中断处理,配合 DMA 接收不定长数据 */ void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_IDLE) { /* 清 IDLE 标志的标准做法:先读 SR 再读 DR */ volatile uint32_t tmp; tmp = USART1->SR; tmp = USART1->DR; (void)tmp; /* 停掉 DMA,算出这一帧实际收了多少字节 */ DMA1_Channel5->CCR &= ~DMA_CCR_EN; uint16_t received = RX_BUF_SIZE - DMA1_Channel5->CNDTR; /* 处理这一帧数据 */ process_frame(rx_buf, received); /* 重置 DMA 继续接收下一帧 */ DMA1_Channel5->CNDTR = RX_BUF_SIZE; DMA1_Channel5->CCR |= DMA_CCR_EN; } /* 传输完成中断的处理逻辑同理 */ }这段代码里有几个新手常踩的坑:清 IDLE 标志必须先读 SR 再读 DR,只读其中一个清不掉,会导致中断反复触发;重装 DMA 之前必须先关通道,否则计数器行为不确定;算长度用的是CNDTR的剩余值,不是固定值。
这个 MCU 案例和服务器上的 NVMe、网卡在道理上是完全一致的:"完成"这个概念是协议层定义的,不是硬件层定义的。DMA 控制器只知道"搬够 N 个字节了",它不知道应用层的一帧数据有多长。TC 中断告诉你的是"缓冲区满了",IDLE 中断告诉你的是"这一帧结束了",这两个"完成"的含义完全不同。选错了通知方式,代码就只能在特定条件下工作。
同样的道理在服务器侧也有很多体现:TCP 的recv返回提示"有数据可读",不代表收到了一个完整的应用层消息;RDMA 的完成事件告诉你"这个 WR 完成了",但语义是"消息已送达对方网卡"还是"对方应用已读取",取决于用的是 Send/Recv 还是 RDMA Write with Immediate,QoS 等级也不一样。
5.5 常见误判清单
把这类问题里最容易出现的误判整理成表,可以直接当 checklist 用。
| 现象 | 常见误判 | 实际原因 | 验证方法 |
|---|---|---|---|
| CPU si 高但带宽正常 | 以为是应用线程问题 | 中断合并参数被改小 | watch -d 看 /proc/interrupts 增量 |
| 数据偶尔错几个字节 | 以为内存有问题 | 缺内存屏障或 cache 维护 | 检查 dma_rmb 和 dma_sync 调用 |
| 性能比预期低一档 | 以为设备能力不够 | MSI-X 未启用退到 INTx | dmesg 里找 MSI-X 相关提示 |
| P99 延迟远高于 P50 | 以为设备抖动 | 中断路由导致的不确定性 | 对比轮询模式下的延迟分布 |
| 隔一段时间卡一下 | 以为后台任务干扰 | CQ 满导致的提交阻塞 | 看队列深度和 doorbell 回收时机 |
| 多队列网卡只用一个核 | 以为 RSS 没生效 | 中断亲和性没配或者网卡只申请了一个向量 | 数 /proc/interrupts 里的队列名 |
| 轮询模式反而更慢 | 以为轮询没用 | 队列深度不足,CPU 空转 | 拉高 iodepth 再对比 |
最后分享一个我在实际项目里养成的习惯:任何涉及设备完成通知的代码,写完先别急着压测性能,先跑一个"慢速正确性"测试——人为把传输速度降到很低,跑几十万次,看有没有数据错乱。因为内存屏障和 cache 问题在高速下反而不容易稳定复现(缓存状态一直在变),低速下更容易暴露。我见过好几个 bug 都是这么找出来的,性能压测跑几千次都正常,降到低速跑一晚上就露馅了。这个习惯花的时间不多,但省下来的排查时间是以天计的。