2、共享内存基础:共享内存模型、内存映射与地址空间、原子操作与内存屏障
好,咱们正式开始聊核间通信。说实话,共享内存是这里面最基础、也是最容易出问题的一块。我见过不少团队,上来就搞复杂的消息队列,结果底层共享内存的坑一个没躲过去。我个人习惯,先把共享内存的底裤扒干净,再谈别的。
2.1 共享内存模型:到底“共享”了什么?
共享内存,说白了就是让两个或多个处理器核,能直接读写同一块物理内存。你想想看,核A写了一个变量,核B马上就能读到。听起来很简单对吧?但这里有个关键问题——你读到的,真的是核A写进去的那个值吗?
我在项目中遇到过,两个核通过共享内存交换数据,核A写了一个标志位,核B轮询这个标志位。结果核B等了半天没反应,最后发现是缓存一致性问题。嗯,这里要注意,共享内存模型不仅仅是“内存”,还涉及缓存、写缓冲、甚至编译器的重排。
常见的共享内存模型有三种:
- 强一致性模型:所有核看到的内存访问顺序完全一致。听起来完美,但性能代价很高。x86 架构就偏向强一致性。
- 弱一致性模型:只有通过特定的同步指令(比如内存屏障),才能保证顺序。ARM 和 PowerPC 都是典型的弱一致性。
- 释放一致性模型:更精细的控制,把同步操作分为“获取”和“释放”。你写数据前做一个释放操作,别人读数据前做一个获取操作。
核心观点:不要假设你的硬件是强一致的。除非你明确知道自己在做什么,否则一定要用同步原语。
2.2 内存映射与地址空间:虚拟地址怎么找到物理地址?
每个核都有自己的 MMU(内存管理单元),它把虚拟地址翻译成物理地址。但共享内存要求多个核访问同一块物理内存。怎么做?
有两种常见做法:
- 直接映射到相同的物理地址:每个核的页表里,都有一项指向同一个物理页框。这是最直接的方式。
- 通过 IO 内存映射:有些硬件提供了共享的 SRAM 或 DDR 区域,直接映射到固定的物理地址。
我建议你在设计初期就把共享内存的物理地址固定下来。别用动态分配,否则调试起来你会想哭的。我曾经在一个项目中,两个核各自用 malloc 分配共享内存,结果物理地址对不上,数据全乱套了。
这里有个典型的映射示例:
/* 假设共享内存物理地址为 0x40000000,大小 1MB */
#define SHARED_MEM_BASE 0x40000000
#define SHARED_MEM_SIZE (1 * 1024 * 1024)
/* 在核0上映射 */
void *map_shared_memory_core0(void) {
return mmap(NULL, SHARED_MEM_SIZE,
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_FIXED,
fd, SHARED_MEM_BASE);
}
/* 在核1上映射到相同的物理地址 */
void *map_shared_memory_core1(void) {
return mmap(NULL, SHARED_MEM_SIZE,
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_FIXED,
fd, SHARED_MEM_BASE);
}
小技巧:用 MAP_FIXED 时要小心,它会覆盖已有的映射。我一般先检查一下目标地址是否已被占用。
2.3 原子操作:为什么不能直接赋值?
你可能会想,共享一个 int 变量,直接 flag = 1 不就行了?嗯,没那么简单。
一个简单的赋值操作,在底层可能是多条指令:读取、修改、写回。如果两个核同时执行,就会发生竞态。比如核A读到了0,核B也读到了0,然后各自加1,结果两个核都写回1,但期望值是2。
原子操作就是保证“读-改-写”这三步一气呵成,中间不会被其他核打断。常见的原子操作有:
- 原子加/减:
atomic_add(&counter, 1) - 原子比较并交换(CAS):
atomic_cmpxchg(&ptr, old, new) - 原子测试并设置:
atomic_test_and_set_bit(&flags, 3)
在 C11 标准里,有 stdatomic.h 可以用。我个人习惯用这个,可移植性好。比如:
#include <stdatomic.h>
atomic_int shared_counter = ATOMIC_VAR_INIT(0);
void increment_counter(void) {
atomic_fetch_add(&shared_counter, 1);
}
int read_counter(void) {
return atomic_load(&shared_counter);
}
警告:别用 volatile 代替原子操作。volatile 只告诉编译器不要优化,但无法保证硬件级别的原子性。我见过有人用 volatile 做自旋锁,结果在多核上跑得稀里哗啦。
2.4 内存屏障:看不见的“栅栏”
好,现在你用了原子操作,数据不会错乱了。但性能呢?你想想看,现代 CPU 为了提速,会乱序执行指令。核A先写 data,再写 flag。但 CPU 可能先把 flag 写出去,data 还在写缓冲里排队。核B看到 flag 变了,去读 data,结果读到的是旧值。
这就是内存屏障要解决的问题。它像一道栅栏,强制前面的内存操作必须先完成,后面的操作才能开始。
内存屏障分几种:
| 类型 | 作用 | 典型场景 |
|---|---|---|
| 读屏障 (rmb) | 保证屏障前的读操作先完成 | 读数据前,先确认标志位 |
| 写屏障 (wmb) | 保证屏障前的写操作先完成 | 写数据后,再写标志位 |
| 全屏障 (mb) | 读写都保证顺序 | 通用同步点 |
在 Linux 内核里,有 smp_mb()、smp_wmb()、smp_rmb() 这些宏。它们只在多核场景下生效,单核时会被优化掉。比如:
/* 核A:生产者 */
data = 42;
smp_wmb(); /* 保证 data 写完,再写 flag */
flag = 1;
/* 核B:消费者 */
while (flag != 1) {
cpu_relax(); /* 自旋等待 */
}
smp_rmb(); /* 保证 flag 读完后,再读 data */
value = data;
避坑指南:我曾经在一个 ARM 平台上,忘了加写屏障,结果数据总是对不上。查了两天,最后发现是写缓冲搞的鬼。加上 dmb 指令后,问题立刻消失。嗯,从那以后我再也不敢省屏障了。
2.5 实战建议:从简单开始
如果你刚开始做多核共享内存,我建议你从最简单的模式开始:
- 一个生产者,一个消费者:用原子变量做标志位,配合内存屏障。
- 固定大小的环形缓冲区:用两个原子变量分别表示头和尾。
- 避免复杂的锁:自旋锁虽然简单,但容易死锁。能用无锁就用无锁。
等你把基础打牢了,再考虑多生产者、多消费者、甚至无锁队列。别一上来就搞高大上的东西,多核调试的坑,你踩过一次就知道了。
最后,记住一句话:共享内存不共享,缓存一致不一致。你写的代码,和 CPU 实际执行的,可能是两码事。用原子操作和内存屏障,就是告诉 CPU:“别耍小聪明,按我说的来。”