19、多核与并发编程:CPU拓扑、处理器亲和性、多核同步、NUMA感知、DMA与缓存一致性

各位同学,今天我们来聊聊驱动开发里最硬核的一块——多核与并发。说实话,单核时代写驱动,你只要管好中断和锁,基本就稳了。但到了多核、多Socket、NUMA架构下,事情就变得复杂多了。我早年在一个服务器项目里,就因为没处理好缓存一致性,导致DMA数据在核间“串位”,查了整整三天才定位到问题。嗯,从那以后,我对这块就格外上心。

这一章,我会把CPU拓扑、亲和性、同步机制、NUMA感知,以及DMA与缓存一致性这几个核心话题串起来讲。你想想看,它们其实是一条线上的蚂蚱——都是为了解决“多个执行单元如何高效、正确地共享数据”这个问题。

核心观点:多核编程的本质,不是让代码跑得更快,而是让数据在正确的时间出现在正确的位置。

多核与并发编程 CPU拓扑 处理器亲和性 多核同步 NUMA感知 DMA与缓存一致性 核心/线程/缓存 APIC/拓扑关系 SetThreadAffinity IRQ亲和性 自旋锁/互斥锁 读写锁/原子操作 内存分配策略 节点本地性 Cache Flush/Invalidate DMA一致性缓冲区

CPU拓扑:搞清楚你的“邻居”是谁

写驱动之前,你得先搞清楚CPU是怎么组织的。现代CPU不是简单的“几个核凑一起”,它有层次结构。我习惯用 KeQueryLogicalProcessorRelationship 这个API来获取拓扑信息。它会告诉你:哪些核共享L2缓存,哪些核在同一个物理包(Socket)里。

为什么要关心这个?举个例子:如果你在核0上分配了一块内存,然后在核3上访问它,而核0和核3不在同一个L3缓存域里,那每次访问都可能触发缓存未命中。性能直接打折扣。

我的经验:在NUMA系统上,我通常会先调用 KeQueryNodeActiveAffinity 获取当前节点的处理器掩码,然后把关键线程绑定到同一个节点上。这样能减少跨节点内存访问的延迟。

处理器亲和性:把线程“钉”在正确的核上

说白了,处理器亲和性就是告诉操作系统:“我这个线程,只在这几个核上跑,别给我乱调度。”在驱动里,我们用 KeSetSystemAffinityThreadKeRevertToUserAffinityThread 这对函数来实现。

我记得有一次做网络驱动优化,发现吞吐量上不去。后来用性能计数器一查,发现中断处理线程在核间频繁迁移,导致缓存反复失效。我把中断亲和性设到固定的核上,吞吐量直接提升了30%。

// 设置当前线程到指定CPU组和掩码
KAFFINITY Affinity = 1 << 2;  // 绑定到核2
GROUP_AFFINITY GroupAffinity = {0};
GroupAffinity.Mask = Affinity;
GroupAffinity.Group = 0;

KeSetSystemGroupAffinityThread(&GroupAffinity, NULL);

// 执行关键代码...

KeRevertToUserGroupAffinityThread(PreviousAffinity, NULL);

注意:设置亲和性后,一定要记得恢复!否则你的线程会一直“霸占”着那个核,影响系统其他部分的调度。我曾经见过一个驱动因为忘记恢复亲和性,导致系统响应变慢,用户投诉说鼠标都卡顿了。

多核同步:锁的学问比你想的深

多核同步,说白了就是“别让两个核同时改同一个东西”。但具体用什么锁,得看场景。

  • 自旋锁(Spin Lock):适合短时间的临界区。在等待锁的时候,CPU会空转。我一般只在中断处理或DPC里用自旋锁,因为不能睡眠。
  • 互斥锁(Mutex):适合长时间的等待。如果临界区可能执行几十微秒以上,用互斥锁更合适,因为它会让出CPU。
  • 读写锁(ERESOURCE):读多写少的场景。比如一个配置表,大部分时间是读,偶尔才更新。用读写锁能大幅提升并发性能。
  • 原子操作:最简单的同步手段。比如 InterlockedIncrement,适合计数器这种场景。

我个人的习惯是:能用原子操作就不用锁,能用读写锁就不用互斥锁。但有一条铁律——锁的粒度要尽量小。你想想看,如果你把整个驱动都锁住,那跟单核有什么区别?

避坑指南:我曾经在一个项目中,用自旋锁保护了一个需要分配内存的操作。结果在内存紧张时,分配内存导致页面错误,而自旋锁持有者不能睡眠,直接蓝屏。教训就是:自旋锁保护的代码里,绝对不能有可能会阻塞的操作。

NUMA感知:别让你的数据“跨区”旅行

NUMA(非统一内存访问)架构下,每个CPU节点有自己的本地内存。访问本地内存很快,访问远程内存就慢很多。如果你的驱动不考虑NUMA,性能可能会差一个数量级。

在Windows驱动中,我们可以用 MmAllocateContiguousMemorySpecifyCacheNode 来指定从哪个NUMA节点分配内存。配合 KeQueryNodeActiveAffinity 获取节点信息,就能做到“数据离CPU近一点”。

操作 本地内存访问 远程内存访问
延迟(典型值) ~100ns ~300ns
带宽 ~50GB/s ~20GB/s

你看这个差距,三倍的延迟差异。如果你的驱动频繁访问远程内存,性能损失是实打实的。

DMA与缓存一致性:硬件帮你擦屁股

DMA(直接内存访问)是设备绕过CPU直接读写内存的技术。但这里有个坑:CPU有缓存,DMA直接写内存,缓存里的数据就变成“脏”的了。反过来,如果CPU改了缓存但还没写回内存,DMA读到的就是旧数据。

解决这个问题有两种思路:

  1. 使用非缓存内存(Non-Cached):分配内存时指定 NonCached 属性。这样CPU和DMA都直接操作内存,没有缓存一致性问题。但性能会下降,因为CPU每次访问都要走内存。
  2. 手动维护一致性:使用 KeFlushIoBuffersMmFlushTb 来刷新缓存。在DMA操作前,把CPU缓存中的数据写回内存;在DMA操作后,把缓存标记为无效,让CPU重新从内存读取。

我个人更推荐第二种方式,因为它兼顾了性能和正确性。但要注意,刷新缓存是一个开销很大的操作,不要频繁调用。

// DMA读操作前的缓存处理
VOID PrepareDmaRead(PDMA_ADAPTER Adapter, PVOID Buffer, ULONG Length)
{
    // 刷新CPU缓存,确保DMA看到最新数据
    KeFlushIoBuffers(Adapter, Buffer, Length, FALSE);
    
    // 启动DMA读...
}

// DMA读操作完成后的缓存处理
VOID CompleteDmaRead(PDMA_ADAPTER Adapter, PVOID Buffer, ULONG Length)
{
    // 使缓存失效,让CPU重新从内存读取
    KeFlushIoBuffers(Adapter, Buffer, Length, TRUE);
    
    // 现在可以安全地访问Buffer了...
}

我的小技巧:在x64平台上,硬件缓存一致性协议(如MESI)已经能处理大部分情况。但如果你用到了非标准的内存属性(比如WC - Write Combined),那就得手动处理了。我建议在驱动初始化时,用 MmGetSystemAddressForMdlSafe 获取MDL映射,然后检查缓存属性,做到心中有数。

好了,这一章的内容就到这里。多核编程没有银弹,理解硬件拓扑、用好同步原语、注意缓存一致性,这三板斧砍下去,大部分并发问题都能解决。剩下的,就是靠调试和积累了。


公众号:蓝海资料掘金营,微信 deep3321