这个问题问到了并发编程的根基,也是理解无锁编程(Lock-Free)和内存模型(Memory Model)的前提。为了让你直观理解,我们先从“为什么冲突”说起,再深入到硬件是如何“劝架”的。
1. 为什么多核 CPU 同时改一个内存会冲突?
先看一个C++经典场景:两个线程分别在不同核心上执行 global_var++(读-改-写操作)。
冲突的本质不是“同时写入”,而是“基于过期数据的写入”。
为了提速,每个CPU核心都有自己的L1/L2缓存。核心操作数据时,先在缓存里改,之后再同步回主存。
- 时刻1:核心A和核心B都读取
global_var = 10到各自的缓存。 - 时刻2:核心A计算
10+1=11,将11写回主存。 - 时刻3:核心B依然拿着过期的10,计算
10+1=11,将11写回主存。
结果:明明加了两次,结果却是11(应该是12)。这就是经典的缓存一致性问题。如果不加锁,最终结果取决于谁最后写(丢失了A的写入)。
2. 硬件如何“锁住”?—— 总线锁(Lock Asserted)
在最早期的多核CPU(或者访问未缓存的内存地址)中,硬件使用粗暴但有效的办法。
原理:CPU在执行特定指令时,会在其前端总线(FSB)上拉高一个名为 LOCK# 的引脚信号(即断言Lock Asserted)。
演示图(概念):
[ 核心A ] ----( 请求锁总线 )---> [ 总线仲裁器 ]
[ 核心B ] ----( 等待... )------> [ 总线仲裁器 ]
|
v
+--------------+
| LOCK# 信号 | <--- 只有持有者能访问主存
+--------------+
|
[ 核心A ] ----> 执行 read-modify-write (原子性) ----> 释放 LOCK#
[ 核心B ] <---- 获得总线使用权 <---- 继续执行
特点:
- 效果:强制阻塞其他核心访问主存(RAM),直到当前指令执行完毕。
- 代价:极其昂贵。因为锁住的是整个总线,意味着其他核心即使操作无关内存地址也要等待。这会导致系统吞吐量急剧下降。在现代CPU中,除非内存地址未对齐,否则几乎不用总线锁。
3. 现代高效的方案 —— 缓存锁(Cache Locking)
现代CPU(x86/ARM)为了效率,99%的情况使用缓存一致性协议(如MESI) 来实现“缓存锁”,并不物理锁住总线。
核心思想:将“锁”下放到缓存行(Cache Line, 通常64字节)的交互状态中。
硬件运作流程(以x86的 lock addl 指令为例):
- 核心A 执行原子加法,向总线发送“读取并修改”请求。
- 监听(Snooping):核心A的缓存控制器监听总线,发现核心B也缓存了这块数据。
- 失效(Invalidate):核心A在总线上广播一条“Invalidate”消息。
- 核心B响应:核心B将其缓存行标记为
I(Invalid,失效),并回复确认(Ack)。 - 核心A独占:此时核心A的缓存行处于
E(Exclusive)或M(Modified)状态。 - 执行修改:核心A在自己的缓存内部完成加1操作,不锁总线。
- 延迟写入:修改后的数据暂存在核心A的缓存中,允许核心B稍后读取时再同步。
演示图(MESI协议交互):
核心A (想写) 核心B (有旧数据)
+--------+ +--------+
| L1 缓存 | | L1 缓存 |
| 数据=10 | | 数据=10 |
+--------+ +--------+
| |
|--- 1. RFO (读取并失效) ----->|
| |
|<--- 2. 失效确认 (Ack) -------|
| (核心B缓存行标记为I) |
| |
+--------+ +--------+
| 数据=11 | (独占/修改状态) | 数据=10(无效I) |
+--------+ +--------+
| |
|--- 3. 修改本地缓存(无总线锁) -|
结论:数据只在缓存间通过消息传递同步,物理总线没有被锁住,其他核心可以正常读写其他内存地址。这才是高性能并发的硬件基石。
4. 什么时候“缓存锁”会退化为“总线锁”?
根据Intel手册,以下情况CPU会放弃缓存锁,强行拉高 LOCK#:
- 内存地址未对齐(跨越了缓存行边界,比如4字节变量不在4字节整数倍地址上)。
- 操作的数据不可缓存(例如映射的MMIO硬件寄存器)。
- 超过缓存行大小(比如原子操作16字节,但缓存行只有8字节)。
5. C++ 代码中的映射 (Demo)
在C++中,我们通过原子类型(std::atomic)来触发硬件锁。编译器底层会生成带有 lock 前缀的汇编指令。
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
// 高性能底层开发:保证缓存行对齐,避免伪共享(False Sharing)
struct alignas(64) AlignedCounter {
std::atomic<int> value{0};
};
AlignedCounter counter;
void worker(int iterations) {
for (int i = 0; i < iterations; ++i) {
// 对应硬件:x86下触发 lock addl [counter], 1
// 底层使用缓存锁(MESI),不锁总线
counter.value.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
const int THREADS = 4;
const int ITERS = 1000000;
std::vector<std::thread> threads;
for (int i = 0; i < THREADS; ++i) {
threads.emplace_back(worker, ITERS);
}
for (auto& t : threads) {
t.join();
}
std::cout << "最终结果: " << counter.value.load() << std::endl;
// 输出: 4000000 (完美无冲突)
return 0;
}
底层汇编视角(你将来写高性能代码需要看这个):
; counter.value.fetch_add 对应的底层指令
lock addl $0x1, (%rax)
; lock 前缀告诉CPU:这里需要总线锁/缓存锁保证原子性
总结提炼(面试/笔试精华)
| 特性 | 总线锁 (Lock Asserted) | 缓存锁 (Cache Locking) |
|---|---|---|
| 作用域 | 锁住整个系统总线(内存访问链路) | 锁住单个缓存行(Cache Line) |
| 实现机制 | 硬件引脚 LOCK# 拉高 | MESI协议 + 总线监听(Snooping) |
| 性能 | 极差(阻止所有核心访存) | 极高(仅阻止冲突的缓存行同步) |
| 触发条件 | 内存地址未对齐 / 跨缓存行 | 默认情况(正常对齐的原子操作) |
| C++关联 | 几乎不暴露 | std::atomic 默认行为 |