6、内存管理:SMP下的内存分配器、TLB一致性、Cache一致性、NUMA感知

好,咱们进入第六章。内存管理,这话题在单核时代就已经够复杂了。到了SMP多核环境下,简直就是「地狱模式」。你想想看,多个核同时跑,都在抢内存,都在改页表,都在往Cache里塞数据。搞不好就数据错乱,系统崩溃。

我个人习惯,做SMP系统时,内存管理这块一定是优先设计的。为什么?因为一旦内存模型出了问题,后面所有的驱动、应用、中间件,全都会跟着遭殃。我在项目中遇到过好几次,系统跑着跑着就随机死机,查了半个月,最后发现是Cache一致性问题。嗯,那滋味,不想再体验第二次。

这一章,咱们就掰开揉碎,把SMP下的内存管理讲清楚。主要分四块:内存分配器、TLB一致性、Cache一致性、NUMA感知。

6.1 SMP下的内存分配器

先说说内存分配器。单核时代,我们用的rt_malloc、rt_free,背后通常是一个全局的堆管理结构。比如经典的dlmalloc、ptmalloc。但在SMP下,多个核同时调用malloc,就会去抢那个全局锁。抢锁的代价有多大?我给你们算笔账。

场景 单核分配耗时 4核竞争耗时 性能下降
小对象(64字节) ~50ns ~500ns 10x
大对象(4KB) ~200ns ~2μs 10x

看到了吧?竞争激烈时,性能直接掉一个数量级。这还只是4核,要是16核、32核呢?根本没法用。

那怎么办?RT-Thread SMP的做法是:每个CPU核心维护一个本地内存池。说白了,就是给每个核分一块「自留地」。核0用核0的池子,核1用核1的。这样分配内存时,根本不需要加锁。

核心思路:用空间换时间。每个核私有内存池,分配无锁。只有当本地池耗尽时,才去全局池「进货」。

具体实现上,RT-Thread采用了两级分配策略

  • 一级(本地):每个核的per-CPU page池。分配小对象时,直接从本地page中切。无锁操作,极快。
  • 二级(全局):一个全局的伙伴系统(Buddy System)。当本地池空了,就从全局池批量申请一整块(比如2MB),然后切到本地池里。

这里有个细节要注意:批量大小怎么定? 我建议,批量大小要大于一个核的「工作集」大小。我曾经在一个项目里,批量设得太小,结果核0刚「进货」完,核1也来进货,两个核频繁抢全局锁,性能反而更差了。后来我把批量从64KB调到了2MB,问题解决。

小技巧:批量大小建议设置为L2 Cache大小的1/4到1/2。比如L2 Cache是512KB,那批量就设128KB~256KB。这样既能减少全局锁竞争,又不会浪费太多内存。

6.2 TLB一致性

好,内存分配搞定了,接下来是TLB。TLB是啥?说白了就是页表的「快照缓存」。CPU每次访问内存,都要查页表把虚拟地址转成物理地址。如果每次都去内存里查页表,那太慢了。所以CPU把最近用过的页表项缓存到TLB里。

问题来了:在SMP下,核0改了页表(比如释放了一个页面),核1的TLB里还存着旧的页表项。核1再去访问那个虚拟地址,TLB命中,直接用了旧的物理地址。结果呢?访问了已经被释放的内存,数据错乱。

这就是TLB一致性问题。怎么解决?

RT-Thread SMP的做法是:跨核TLB刷写(TLB Shootdown)。流程如下:

  1. 核0修改页表。
  2. 核0发送一个IPI(核间中断)给所有其他核。
  3. 其他核收到IPI后,立即执行TLB刷写指令(比如ARM的TLBIALL,x86的INVLPG)。
  4. 其他核刷完TLB后,回复确认。
  5. 核0收到所有确认后,继续执行。

这个过程,我称之为「TLB广播」。听起来简单,但实现起来坑很多。

注意:TLB Shootdown期间,所有核都要停下来刷TLB。如果系统里核很多(比如64核),这个开销非常大。我曾经在一个项目里,频繁的TLB Shootdown导致系统吞吐量下降了30%。

怎么优化?我分享两个经验:

  • 批量刷写:不要改一个页表项就刷一次TLB。把多次修改攒起来,一次性广播刷写。RT-Thread里有一个「延迟TLB刷写」机制,就是干这个的。
  • 懒惰刷写:有些场景下,其实不需要立即刷写。比如核0释放了一个页面,但核1短期内不会访问那个虚拟地址。那就可以等核1真正访问时,发现页表项无效,触发缺页异常,再顺便刷掉TLB。这叫「懒惰TLB一致性」。

6.3 Cache一致性

TLB搞定了,还有Cache。Cache一致性,说白了就是:核0改了某个内存地址的数据,核1的Cache里还存着旧数据,怎么办?

现代CPU硬件层面已经提供了MESI协议(Modified, Exclusive, Shared, Invalid)来保证Cache一致性。但RT-Thread作为操作系统,还是得做一些配合工作。

我重点讲两个场景:

6.3.1 DMA与Cache一致性

DMA设备直接访问物理内存,不经过CPU Cache。如果CPU先写了一个缓冲区,然后启动DMA把数据发出去。但CPU写的数据还在Cache里,没写回内存。DMA读到的就是旧数据。

反过来,DMA往内存里写入了新数据,但CPU的Cache里还存着旧数据。CPU去读,读到的是Cache里的旧数据。

RT-Thread SMP的解决方案:

  • DMA发送前:调用rt_hw_cpu_dcache_ops(&addr, size, RT_HW_CACHE_FLUSH) 把Cache刷回内存。
  • DMA接收后:调用rt_hw_cpu_dcache_ops(&addr, size, RT_HW_CACHE_INVALIDATE) 把Cache里的旧数据作废。

关键点:一定要保证刷Cache的粒度是Cache Line对齐的。ARM的Cache Line通常是64字节,x86是64字节。如果不对齐,可能会刷到相邻的数据,造成意外。

6.3.2 自修改代码(Self-Modifying Code)

这个场景比较特殊,但嵌入式里经常遇到。比如JIT编译器、动态加载的代码段。核0修改了某段代码(比如打了一个补丁),但核1的指令Cache(I-Cache)里还存着旧指令。

RT-Thread的做法是:修改代码后,执行一次同步指令。ARM上是DSB + ISB,x86上是SERIALIZE指令。确保所有核的I-Cache都失效,重新从内存加载。

我的经验:自修改代码后,最好再等一个「安全窗口」。我曾经遇到过,核0刚刷完I-Cache,核1正好在执行那条被修改的指令,结果指令预取已经进了流水线。后来我加了一个小的延迟循环(比如10个NOP),确保流水线清空。

6.4 NUMA感知

最后,咱们聊聊NUMA。NUMA(Non-Uniform Memory Access)在嵌入式多核处理器里越来越常见。比如一些ARM服务器芯片、RISC-V多核SoC。

NUMA的特点是:每个核访问「本地内存」快,访问「远端内存」慢。 延迟差距可能达到2-3倍。

内存位置 访问延迟 带宽
本地内存(同一NUMA节点) ~100ns ~40GB/s
远端内存(跨NUMA节点) ~250ns ~20GB/s

RT-Thread SMP的NUMA感知策略,核心就一句话:谁分配,谁使用。

具体来说:

  • 内存分配时:优先从当前核所在的NUMA节点分配。如果当前节点内存不足,才去远端节点「借」。
  • 线程迁移时:如果线程从核0迁移到了核1(跨NUMA节点),那它之前分配的内存还在核0的节点上。访问时就会变慢。RT-Thread的做法是:检测到跨节点访问频繁时,自动把内存页迁移到当前节点。

这里有个权衡:内存迁移的代价。迁移一个4KB的页面,大概需要几微秒。如果线程只是临时在核1上跑一下,那迁移就不划算。RT-Thread里有一个「迁移阈值」:只有当线程在目标核上连续运行超过10ms,才触发迁移。

避坑指南:我曾经在一个项目里,把迁移阈值设得太低(1ms)。结果线程在两个NUMA节点间频繁迁移,内存也跟着来回搬,系统性能反而下降了20%。后来我把阈值调到了50ms,问题解决。记住:迁移是有成本的,不要过度优化。

好了,这一章的内容就到这里。内存管理在SMP下确实复杂,但核心思路其实就几条:本地化、批量操作、懒惰策略、NUMA感知。掌握了这些,你就能写出健壮的SMP内存管理代码。

下一章,咱们聊聊中断和异常处理。那又是另一个大坑。到时候见。