19、多核与并发编程:CPU拓扑、处理器亲和性、多核同步、NUMA感知、DMA与缓存一致性
各位同学,今天我们来聊聊驱动开发里最硬核的一块——多核与并发。说实话,单核时代写驱动,你只要管好中断和锁,基本就稳了。但到了多核、多Socket、NUMA架构下,事情就变得复杂多了。我早年在一个服务器项目里,就因为没处理好缓存一致性,导致DMA数据在核间“串位”,查了整整三天才定位到问题。嗯,从那以后,我对这块就格外上心。
这一章,我会把CPU拓扑、亲和性、同步机制、NUMA感知,以及DMA与缓存一致性这几个核心话题串起来讲。你想想看,它们其实是一条线上的蚂蚱——都是为了解决“多个执行单元如何高效、正确地共享数据”这个问题。
核心观点:多核编程的本质,不是让代码跑得更快,而是让数据在正确的时间出现在正确的位置。
CPU拓扑:搞清楚你的“邻居”是谁
写驱动之前,你得先搞清楚CPU是怎么组织的。现代CPU不是简单的“几个核凑一起”,它有层次结构。我习惯用 KeQueryLogicalProcessorRelationship 这个API来获取拓扑信息。它会告诉你:哪些核共享L2缓存,哪些核在同一个物理包(Socket)里。
为什么要关心这个?举个例子:如果你在核0上分配了一块内存,然后在核3上访问它,而核0和核3不在同一个L3缓存域里,那每次访问都可能触发缓存未命中。性能直接打折扣。
我的经验:在NUMA系统上,我通常会先调用 KeQueryNodeActiveAffinity 获取当前节点的处理器掩码,然后把关键线程绑定到同一个节点上。这样能减少跨节点内存访问的延迟。
处理器亲和性:把线程“钉”在正确的核上
说白了,处理器亲和性就是告诉操作系统:“我这个线程,只在这几个核上跑,别给我乱调度。”在驱动里,我们用 KeSetSystemAffinityThread 和 KeRevertToUserAffinityThread 这对函数来实现。
我记得有一次做网络驱动优化,发现吞吐量上不去。后来用性能计数器一查,发现中断处理线程在核间频繁迁移,导致缓存反复失效。我把中断亲和性设到固定的核上,吞吐量直接提升了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读到的就是旧数据。
解决这个问题有两种思路:
- 使用非缓存内存(Non-Cached):分配内存时指定
NonCached属性。这样CPU和DMA都直接操作内存,没有缓存一致性问题。但性能会下降,因为CPU每次访问都要走内存。 - 手动维护一致性:使用
KeFlushIoBuffers或MmFlushTb来刷新缓存。在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映射,然后检查缓存属性,做到心中有数。
好了,这一章的内容就到这里。多核编程没有银弹,理解硬件拓扑、用好同步原语、注意缓存一致性,这三板斧砍下去,大部分并发问题都能解决。剩下的,就是靠调试和积累了。