内存模型与缓存及流水线¶
内存模型参考:
https://research.swtch.com/hwmm
https://research.swtch.com/plmm
高级并行编程参考:
https://mirrors.edge.kernel.org/pub/linux/kernel/people/paulmck/perfbook/perfbook.html
内存乱序及内存模型的解决
内存乱序的根源
1. 存储缓冲区(Store Buffer):隐藏写延迟
原理:CPU执行store指令时,若目标缓存行不在本地缓存,需等待内存加载;为避免CPU stall,CPU将store数据先存入“store buffer”,后续再异步写入缓存/内存——store指令可快速完成,但“数据写入内存的顺序”可能晚于“代码中store的顺序”。
结论:store buffer导致“写操作的提交顺序≠代码顺序”,即“写乱序(Store Reordering)”。
2. 无效队列(Invalidate Queue):隐藏缓存一致性延迟
原理:CPU修改共享变量时,需先通过“缓存一致性协议(如MESI)”向其他CPU发送“缓存行无效(Invalidate)”请求;为避免等待所有CPU确认无效,CPU将“无效请求”存入“无效队列”并异步处理——其他CPU可能延迟收到无效请求,导致“读操作看到过时数据”。
// 线程A(CPU 0) // 线程B(CPU 1)
a = 1;
while (a == 0);
b = 1;
// 线程C(CPU 0)
while (b == 0);
assert(a == 1); // 可能失败?
结论:无效队列导致“读操作看到的无效通知顺序≠请求发送顺序”,即“读乱序(Load Reordering)”。
3. 乱序执行(Out-of-Order Execution):最大化CPU资源利用率
原理:现代CPU为充分利用流水线与功能单元(如ALU、FPU),会打乱指令执行顺序——只要“逻辑依赖满足”(如c = a + b需等a和b计算完成),无关指令可乱序执行。
对内存访问的影响:内存访问指令(load/store)与计算指令无依赖时,可能被乱序执行——例如a = 1; b = c + d;中,b = c + d可能先执行(若c和d已在寄存器中),但a=1的store仍在store buffer中,导致其他线程先看到b的新值,后看到a的新值。
4. 推测执行(Speculative Execution):提前执行不确定分支
原理:CPU遇到分支(如if (x > 0))时,会推测分支方向并提前执行后续指令;若推测错误,丢弃推测执行的结果(包括内存访问)。
对内存排序的隐性影响:推测执行的内存访问可能“临时修改缓存状态”,虽最终会回滚,但可能被其他CPU通过“侧信道”感知(如Spectre漏洞利用此特性);通过“内存序约束”确保“推测执行的内存访问不影响程序正确性”。
内存模型
定义内存模型,明确“哪些内存访问顺序是硬件必须保证的,哪些是软件可依赖的”,解决“硬件乱序导致软件正确性问题”。
“硬件内存模型”与“语言内存模型”共同构成并行程序的内存访问规则基础。
1. 硬件内存模型
(1)x86/x86-64:强内存模型(Total Store Order, TSO)
规则:
- 禁止“写→读”乱序(StoreLoad Reordering):即“先执行的store,不会被后执行的load越过”——例如
a=1; b=load(c);中,b=load(c)不会先于a=1的store提交到内存; - 禁止“写→写”乱序(StoreStore Reordering):即“先执行的store,其他线程必先看到”——例如
a=1; b=1;中,其他线程不会先看到b=1再看到a=1; - 禁止“读→写”乱序(LoadStore Reordering):即“先执行的load,不会被后执行的store越过”——例如
a=load(c); b=1;中,b=1不会先于a=load(c)提交; - 允许“读→读”乱序(LoadLoad Reordering):即“先执行的load,可能后看到结果”——例如
a=load(c); b=load(d);中,若c的缓存行未命中而d的缓存行命中,b可能先获取值,a后获取值。
例外:x86的non-temporal store(如_mm_stream_store_si128)与“未对齐内存访问”可能打破TSO规则,需额外加内存屏障。
意义:x86的强内存模型降低了软件复杂度,多数情况下无需手动处理“写→读/写→写/读→写”乱序,但需注意“读→读”乱序与特殊指令的例外。
(2)ARM/PowerPC:弱内存模型(Partial Store Order, PSO / Weak Order)
规则: 允许“写→读”“写→写”“读→写”“读→读”四类乱序(仅禁止“同一地址的访问乱序”);
意义:弱内存模型的硬件性能更高(可更自由地优化内存访问),但软件需通过“显式内存屏障”或“带内存序的原子操作”约束乱序,否则极易出现正确性问题。
2. 语言内存模型:C11/C++11的统一抽象
不同硬件内存模型差异大,C11/C++11通过“语言内存模型”提供统一抽象,屏蔽硬件细节,允许软件按需选择“内存序强度”。
| 内存序枚举 | 核心约束 | 适用场景 |
|---|---|---|
memory_order_relaxed |
仅保证“同一原子变量的访问原子性”,不约束任何乱序(允许所有四类乱序) | 无依赖的统计计数(如网络包计数),仅需原子性,无需顺序保证 |
memory_order_consume |
仅约束“当前load操作与后续依赖于该load结果的操作”的顺序(数据依赖) | 指针加载(如p = atomic_load(&ptr, consume); *p = 1;),仅需保证指针解引用在加载后 |
memory_order_acquire |
约束“当前load操作之后的所有内存访问”不被重排到load之前(读屏障语义) | 锁获取(如pthread_mutex_lock),保证临界区代码看到load之前的所有写操作 |
memory_order_release |
约束“当前store操作之前的所有内存访问”不被重排到store之后(写屏障语义) | 锁释放(如pthread_mutex_unlock),保证store之前的所有写操作被其他线程看到 |
memory_order_acq_rel |
同时具备acquire(读屏障)与release(写屏障)语义,适用于RMW操作 |
原子读写修改(如atomic_fetch_add),需同时保证读和写的顺序约束 |
memory_order_seq_cst |
最强约束:所有线程看到的内存访问顺序一致(全局总序),默认内存序 | 需全局一致性的场景(如分布式锁的状态同步),性能最低但正确性最易保证 |
3. 内存序与硬件屏障的映射关系
软件指定的“内存序”需通过“硬件内存屏障”实现,软件内存序在不同硬件上的“屏障映射”,是“内存序的底层代价”:
| 内存序 | x86/x86-64(TSO) | ARM(弱内存模型) | 核心说明 |
|---|---|---|---|
relaxed |
无屏障 | 无屏障 | 仅依赖硬件原子性,无额外开销 |
consume |
无屏障(TSO禁止关键乱序) | dmb ishld(数据依赖屏障) |
仅约束数据依赖,开销低于acquire |
acquire |
无屏障(TSO禁止读→后续访问乱序) | dmb ish(读屏障) |
约束读操作后的所有访问,开销中等 |
release |
无屏障(TSO禁止写→前序访问乱序) | dmb ish(写屏障) |
约束写操作前的所有访问,开销中等 |
acq_rel |
mfence(全屏障) |
dmb ish(全屏障) |
同时约束读和写,开销较高 |
seq_cst |
mfence + 原子操作带lock前缀 |
dmb sy(系统级全屏障) |
最强约束,开销最高,确保全局总序 |
4.内存屏障与编译器屏障
(1)内存屏障
MFENCE
串行化所有内存操作(读和写)
确保MFENCE之前的所有内存操作(load和store)在MFENCE之后的所有内存操作之前完成
SFENCE
仅串行化写操作(store)
确保SFENCE之前的写操作在SFENCE之后的写操作之前完成
处理弱排序内存类型(如WC内存)、非临时存储指令(MOVNT)、写组合缓冲区等
LFENCE
仅串行化读操作(load)
确保LFENCE之前的读操作在LFENCE之后的读操作之前完成
控制依赖于读操作结果的执行顺序,防止推测执行导致的信息泄露(如Spectre漏洞缓解)
(2)编译器屏障
C11/C++11标准
GCC/Clang
MSVC
(3)区别
编译器屏障:编译器屏障不能强制 CPU 层面执行同步操作,仅仅是防止编译器重排代码。
内存屏障:内存屏障同时防止了编译器重排
4. 选择“最弱够用”的内存序
- 优先判断变量是否独立:若变量无逻辑依赖(如独立计数器),直接用
relaxed; - 判断是否需要“同步关系”:若需“线程A的写操作被线程B看到”(如指针发布、锁),用
acquire/release; - 仅在必要时用强内存序:若需“全局一致性”(如分布式系统的状态同步),才用
seq_cst,且需评估性能代价; - 避免“默认使用seq_cst”:C++原子操作默认
seq_cst,但90%的场景无需此强约束,手动指定弱内存序可大幅提升性能。
缓存一致性协议讲解参考:
https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESIHelp.htm
缓存一致性协议带来的问题及解决:伪共享、真共享
在多线程编程中,伪共享(False Sharing)是指多个线程同时访问位于同一个缓存行(Cache Line)但实际上没有数据依赖关系的不同变量,导致缓存行频繁失效和不必要的内存同步开销。通过CPU缓存行对齐(Cache Line Alignment)可以有效地减少伪共享问题,以下是具体的方法:
#include <array>
#include <atomic>
#include <iostream>
#include <thread>
// 定义缓存行大小
constexpr size_t CACHE_LINE_SIZE = 64;
// 使用结构体填充实现缓存行对齐
struct AlignedData {
std::atomic<long long> value;
char padding[CACHE_LINE_SIZE - sizeof(std::atomic<long long>)];
};
// 共享数据结构,每个元素都是一个对齐后的结构体
std::array<AlignedData, 2> shared_data;
// 线程函数,每个线程修改自己的数据
void thread_function(int index) {
for (int i = 0; i < 10000000; ++i) {
shared_data[index].value++;
}
}
int main() {
// 创建两个线程
std::thread thread1(thread_function, 0);
std::thread thread2(thread_function, 1);
// 等待线程结束
thread1.join();
thread2.join();
// 输出结果
std::cout << "Thread 0 value: " << shared_data[0].value << std::endl;
std::cout << "Thread 1 value: " << shared_data[1].value << std::endl;
return 0;
}
- 使用编译器特定和C++语言特性中的对齐属性:
除了手动填充,一些编译器还提供了特定的属性来实现缓存行对齐,在GCC和Clang中,可以使用
__attribute__((aligned(CACHE_LINE_SIZE)))来指定结构体的对齐方式:
struct __attribute__((aligned(CACHE_LINE_SIZE))) AlignedData {
std::atomic<long long> value;
};
// C++自己的方式
template <typename T>
struct alignas(CACHE_LINE_SIZE) CacheLineAligned : public T {
using T::T;
};
// 这种方式更加简洁,并且可以确保结构体在内存中的对齐符合缓存行的大小要求。
真共享问题可以通过 std::atomic(性能不敏感)和 thread_local 解决问题。
缓存隔离
Intel 缓存分配技术(CAT)可将 L3 缓存划分为容量可配置的 COS 区域,能将关键低延迟交易线程绑定到专属 COS 区域,避免其缓存行被后台任务或其他核心污染,从而减少缓存争用导致的延迟抖动。
sudo apt update
sudo apt install -y intel-cmt-cat
# 检查CPU是否支持CAT和MBA
cat /proc/cpuinfo | grep -E 'cat_l3|mba'
# 使用pqos工具全面检测系统RDT功能
pqos -d
# 查看当前系统默认配置
pqos -s
# 创建COS定义
# -e 'llc:COS_ID=BITMASK' (L3 Cache Allocation)
# -e 'mba:COS_ID=BANDWIDTH' (Memory Bandwidth Allocation)
# COS1: 分配L3缓存的高8位 (例如总共12位掩码时,0xff0代表大部分缓存)
# 同时分配80%的内存带宽
sudo pqos -e 'llc:1=0xff0;mba:1=80'
# COS2: 分配L3缓存的低4位 (0x00f)
# 同时分配50%的内存带宽 (如果带宽不足,会受到限制)
sudo pqos -e 'llc:2=0x0f;mba:2=50'
# 验证配置是否生效
pqos -s
# 将核心 2 和 3 绑定到 COS1
# 格式: pqos -a 'llc:COS_ID=core_list;mba:COS_ID=core_list'
sudo pqos -a 'llc:1=2,3;mba:1=2,3'
# (可选)可以将其他核心绑定到普通任务COS2
# sudo pqos -a 'llc:2=4-7;mba:2=4-7'
sudo apt install -y stress
# -c 1: 产生1个占用CPU的进程
# -m 1: 产生1个占用内存的进程
# --vm-bytes 512M: 分配512MB内存
# taskset -c 2: 将进程绑定到核心2
taskset -c 2 stress -c 1 -m 1 --vm-bytes 512M &
STRESS_PID=$! # 获取后台进程的PID,方便后续停止
echo "Stress test started with PID: $STRESS_PID"
# 监控与COS1关联的核心2和3
# -i 1: 每1秒刷新一次
# -m llc,mbl,mbr: 监控指标包括LLC(缓存占用), MBL(本地内存带宽), MBR(远程内存带宽)
sudo pqos -i 1 -m 'llc,mbl,mbr:2,3'
# 停止压力测试进程
kill $STRESS_PID
# 重置所有RDT配置到系统默认状态
sudo pqos -R
cpu pipeline参考:
https://weedge.github.io/perf-book-cn/zh/chapters/3-CPU-Microarchitecture/3-2_Pipelining_cn.html
流水线停顿规避与ILP利用
现代CPU通过流水线将指令执行拆分为“取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)”等阶段,理想情况下每时钟周期可完成一条指令。实际指令间依赖导致流水线停顿(Stall),等待前一阶段结果,CPU周期浪费。
流水线停顿的两类指令间依赖:
- 数据依赖停顿:后一条指令需前一条指令的执行结果(如
a = b + c; d = a + e中,d依赖a的结果),导致d的“执行阶段”需等待a的“写回阶段”。 - 控制依赖停顿:分支指令(如
if-else)的目标地址需等待执行阶段确定,导致后续指令无法提前取指,产生停顿,分支预测错误时停顿更久。
CPU pipeline适配的“三优先”原则,确保流水线无停顿、ILP最大化:
- 依赖链优先拆分:长依赖链拆分为短链(如多笔订单的“价格×数量”并行计算),避免串行停顿;
- 数据优先入寄存器:高频访问的核心数据(价格、成交量、费率)预加载至寄存器,消除访存依赖;
- 指令类型优先均衡:穿插整数、浮点、逻辑指令,让CPU多执行单元并行工作,不浪费ILP潜力。
复杂结构、系统全局的依赖链优化拆分使用拓扑排序等算法解决依赖。