2、多核处理器基础:SMP与AMP架构对比、缓存一致性、内存屏障
好,咱们进入正题。多核处理器,说白了就是一块芯片上塞了好几个CPU核心。听起来简单,但怎么让这些核心协同工作,这里面的门道可不少。我个人习惯把多核架构分成两大类:SMP和AMP。搞懂这俩,后面聊调度和负载均衡才有个根基。
2.1 SMP与AMP架构对比
SMP(对称多处理),这是最常见的一种。所有核心地位平等,共享同一份内存空间,跑同一个操作系统。你想想看,一个任务可以随时被调度到任何一个核心上执行,对应用来说,它根本不知道底层有几个核在干活。
QNX本身就是为SMP设计的。我记得在调试一个多核系统时,发现某个中断总是被分配到core0上,导致core0负载偏高。后来查了QNC的启动配置,调整了中断亲和性,才把负载均衡开。
AMP(非对称多处理),这个就有点意思了。每个核心可能跑不同的操作系统,或者同一个OS但各自独立运行。核心之间不共享内存,或者共享得很有限。AMP常用于硬实时场景,比如一个核跑QNX处理控制逻辑,另一个核跑Linux做人机交互。
我曾在车载项目中用过AMP方案。当时一个核心专门处理CAN总线数据,另一个核心跑上层应用。好处是隔离性好,一个核挂了不影响另一个。但坏处也明显——核间通信太麻烦,得自己实现消息传递。
来,看个对比表格,更直观:
| 特性 | SMP | AMP |
|---|---|---|
| 操作系统 | 单一OS实例 | 多个OS实例或裸机 |
| 内存共享 | 完全共享 | 独立或有限共享 |
| 任务调度 | 全局调度器 | 每个核独立调度 |
| 可靠性 | 一个核崩溃可能影响全局 | 故障隔离性好 |
| 开发难度 | 相对简单 | 核间通信复杂 |
| 典型场景 | 通用计算、服务器 | 嵌入式、硬实时 |
2.2 缓存一致性
好,接下来聊缓存一致性。这个问题,说白了就是:同一个内存地址,在不同核心的缓存里,内容不一样了怎么办?
为什么会这样?现代CPU都有多级缓存。Core0读了一个变量x=5,把它缓存到了L1里。然后Core1也读了x,也缓存了。接着Core0把x改成了10。这时候Core1的缓存里还是5。你想想看,如果Core1继续用这个5,程序就乱套了。
解决这个问题的机制,叫缓存一致性协议。最常见的是MESI协议(Modified, Exclusive, Shared, Invalid)。每个缓存行有四种状态:
- M(修改):只有本核心持有,且数据被修改过,与主存不一致
- E(独占):只有本核心持有,数据与主存一致
- S(共享):多个核心持有,数据与主存一致
- I(无效):数据已失效,需要重新从主存加载
当Core0修改了x,它会发送一个“失效”消息给其他核心。Core1收到后,把对应的缓存行标记为I。下次Core1再读x,发现缓存失效,就去主存重新加载。嗯,这里要注意,这个“失效”消息是通过总线广播的,如果核心很多,总线带宽就成了瓶颈。
我在项目中遇到过一个问题:一个多核系统,性能始终上不去。用perf工具一查,发现缓存一致性协议触发的总线流量占了总带宽的30%以上。后来通过调整数据布局,把频繁共享的变量放到单独的缓存行里,才把性能拉回来。
2.3 内存屏障
内存屏障,也叫内存栅栏。这玩意儿比缓存一致性更底层,也更让人头疼。简单说,内存屏障是用来控制指令执行顺序的。
现代CPU为了提升性能,会乱序执行指令。比如你写了三条指令:
store A = 1
store B = 2
load C
CPU可能先执行load C,再执行store A和store B。因为load C的地址在缓存里,而store A需要写回主存,慢。这种重排对单核程序没问题,因为CPU保证最终结果一致。但多核环境下,问题就来了。
Core0执行了:
store flag = 1
store data = 100
Core1执行了:
load flag
if (flag == 1) load data
你期望的是:Core0先写flag,再写data。Core1读到flag=1后,data一定是100。但实际可能不是这样——CPU可能把store data重排到了store flag前面。Core1读到flag=1时,data还是旧值。
这时候就需要内存屏障了。在store flag之前插入一个写屏障(write barrier),保证屏障前的写操作一定在屏障后的写操作之前完成。
在QNX上,常用的内存屏障宏有:
MEMORY_BARRIER():全屏障,保证所有读写操作有序READ_MEMORY_BARRIER():读屏障,保证读操作有序WRITE_MEMORY_BARRIER():写屏障,保证写操作有序
举个例子:
// Core0
data = 100;
WRITE_MEMORY_BARRIER(); // 保证data写完后,才写flag
flag = 1;
// Core1
while (flag == 0);
READ_MEMORY_BARRIER(); // 保证flag读完后,才读data
result = data;
这样就能保证Core1读到flag=1时,data一定是100。
atomic_add()、atomic_cmpxchg())。QNX的<atomic.h>里提供了完整的原子操作API。
最后说一句,内存屏障的性能开销不小。我见过有人为了图省事,在每个共享变量访问前后都加屏障,结果性能直接腰斩。正确的做法是:只在需要保证顺序的地方加屏障,能用原子操作就别用屏障,能用锁就别自己折腾。
好了,这一节就到这里。下一节我们聊聊QNX的调度器是怎么在多核上分配任务的,那才是真正的重头戏。