文档目录

这个问题问到了并发编程的根基,也是理解无锁编程(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 指令为例):

  1. 核心A 执行原子加法,向总线发送“读取并修改”请求。
  2. 监听(Snooping):核心A的缓存控制器监听总线,发现核心B也缓存了这块数据。
  3. 失效(Invalidate):核心A在总线上广播一条“Invalidate”消息。
  4. 核心B响应:核心B将其缓存行标记为 I(Invalid,失效),并回复确认(Ack)。
  5. 核心A独占:此时核心A的缓存行处于 E(Exclusive)或 M(Modified)状态。
  6. 执行修改:核心A在自己的缓存内部完成加1操作,不锁总线。
  7. 延迟写入:修改后的数据暂存在核心A的缓存中,允许核心B稍后读取时再同步。

演示图(MESI协议交互):

        核心A (想写)                    核心B (有旧数据)
        +--------+                     +--------+
        | L1 缓存 |                     | L1 缓存 |
        | 数据=10 |                     | 数据=10 |
        +--------+                     +--------+
             |                              |
             |--- 1. RFO (读取并失效) ----->|
             |                              |
             |<--- 2. 失效确认 (Ack) -------|
             |       (核心B缓存行标记为I)    |
             |                              |
        +--------+                     +--------+
        | 数据=11 |  (独占/修改状态)     | 数据=10(无效I) |
        +--------+                     +--------+
             |                              |
             |--- 3. 修改本地缓存(无总线锁) -|

结论:数据只在缓存间通过消息传递同步,物理总线没有被锁住,其他核心可以正常读写其他内存地址。这才是高性能并发的硬件基石。


4. 什么时候“缓存锁”会退化为“总线锁”?

根据Intel手册,以下情况CPU会放弃缓存锁,强行拉高 LOCK#:

  1. 内存地址未对齐(跨越了缓存行边界,比如4字节变量不在4字节整数倍地址上)。
  2. 操作的数据不可缓存(例如映射的MMIO硬件寄存器)。
  3. 超过缓存行大小(比如原子操作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 默认行为