拓冰建站拓冰建站
首页 / 资讯中心 / 正文

DMA完成通知机制:从MSI-X、完成队列到高性能轮询

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_SOFTIRQNAPI 轮询也在软中断上下文里跑。这个拆分解释了一个常见现象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 和 CQSQ 条目 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 cacheDMA_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 --nameseqread --filename/dev/nvme0n1 --rwrandread --bs4k \ --iodepth32 --ioengineio_uring --direct1 --runtime30 # 轮询模式看延迟和 IOPS 的变化 fio --nameseqread_poll --filename/dev/nvme0n1 --rwrandread --bs4k \ --iodepth32 --ioengineio_uring -- hipri --direct1 --runtime30实测下来轮询模式在 iodepth 较低的随机读场景下P99 延迟的改善比平均延迟更明显因为省掉了中断到达时间的不确定性。但要注意轮询模式下如果 iodepth 太低CPU 大部分时间在空转吞吐反而可能不如中断模式——轮询是把 CPU 时间换成延迟确定性只有队列足够深、请求足够密的时候才划算。DPDK 的 PMDPoll Mode Driver是同样的思路用在网卡上。一个 PMD 线程绑在一个核上循环调用rte_eth_rx_burst()批量收包。中断在这里完全不存在收包、处理、发包全在同一批循环里完成。4.2 RDMA 与 GPUDirectCPU 退化成记账员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_pollNAPI 支持在有中断的情况下先短暂轮询一下再进休眠。net.core.busy_poll和busy_read两个 sysctl 控制这个行为单位是微秒。对于每隔几微秒就来一个包的负载短暂轮询能避免进出中断的成本但对于几毫秒才来一个包的负载纯粹是浪费。NAPI 的预算net.core.netdev_budget控制一次软中断最多处理多少个包。调大能减少软中断的调度次数但会让单个软中断跑得更久影响其他任务的响应。epoll 的 busy pollepoll_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 ImmediateQoS 等级也不一样。5.5 常见误判清单把这类问题里最容易出现的误判整理成表可以直接当 checklist 用。现象常见误判实际原因验证方法CPU si 高但带宽正常以为是应用线程问题中断合并参数被改小watch -d 看 /proc/interrupts 增量数据偶尔错几个字节以为内存有问题缺内存屏障或 cache 维护检查 dma_rmb 和 dma_sync 调用性能比预期低一档以为设备能力不够MSI-X 未启用退到 INTxdmesg 里找 MSI-X 相关提示P99 延迟远高于 P50以为设备抖动中断路由导致的不确定性对比轮询模式下的延迟分布隔一段时间卡一下以为后台任务干扰CQ 满导致的提交阻塞看队列深度和 doorbell 回收时机多队列网卡只用一个核以为 RSS 没生效中断亲和性没配或者网卡只申请了一个向量数 /proc/interrupts 里的队列名轮询模式反而更慢以为轮询没用队列深度不足CPU 空转拉高 iodepth 再对比最后分享一个我在实际项目里养成的习惯任何涉及设备完成通知的代码写完先别急着压测性能先跑一个慢速正确性测试——人为把传输速度降到很低跑几十万次看有没有数据错乱。因为内存屏障和 cache 问题在高速下反而不容易稳定复现缓存状态一直在变低速下更容易暴露。我见过好几个 bug 都是这么找出来的性能压测跑几千次都正常降到低速跑一晚上就露馅了。这个习惯花的时间不多但省下来的排查时间是以天计的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门