4-4.【OC】【锁】指令重排会在哪些情况下破坏临界区语义?

4 阅读3分钟

在单线程世界里,编译器和 CPU 遵循 “不影响最终执行结果(As-if-serial)” 的原则进行优化。但在多线程环境下,这种“自作聪明”的优化(指令重排)极易导致临界区(Critical Section)的屏障失效。

指令重排通常在以下三种场景中破坏临界区语义:


1. 锁操作与普通指令的“越界”

临界区的核心语义是:所有在临界区内的操作,必须在“加锁”之后开始,在“解锁”之前完成。

然而,如果没有硬件级的内存屏障(Memory Barrier) ,编译器可能认为某些指令与锁变量无关,从而将其移出临界区。

  • Load-Store 越界: 编译器可能将原本在 unlock 之后的读取指令提前到临界区内,或者将临界区内的写入指令推迟到 unlock 之后。
  • 后果: 线程 A 还没释放锁,线程 B 就看到了 A 在临界区之外修改的一半数据;或者 A 释放了锁,但数据还没写入内存。

2. 对象的“半初始化”问题(Double-Checked Locking)

这是指令重排最经典的“背锅”场景。在实现单例模式时,常见的错误代码如下:

C++

if (instance == nullptr) {
    lock();
    if (instance == nullptr) {
        instance = new Singleton(); // 这一行包含三步:1.分配内存 2.调用构造函数 3.赋值指针
    }
    unlock();
}
  • 重排逻辑: CPU 为了优化,可能会执行顺序变为 1 -> 3 -> 2(先赋值指针,后调用构造函数)。
  • 破坏语义: 线程 A 刚执行完第 3 步(指针已非空但对象还是空的),线程 B 刚好执行到最外层的 if (instance == nullptr)。由于没有进入临界区,线程 B 判定不为空,直接拿走了一个尚未初始化完成的对象,导致程序崩溃。

3. 控制依赖与数据依赖的混淆

有些时候,编译器会进行推测执行(Speculative Execution) ,这会破坏基于标志位的临界区逻辑。

假设线程 A 负责初始化数据,线程 B 等待标志位:

线程 A (生产者)线程 B (消费者)
data = 42; (1)while (!ready); (3)
ready = true; (2)print(data); (4)
  • 重排风险: 如果编译器认为 (1) 和 (2) 没有依赖关系,可能先执行 (2) 再执行 (1)。
  • 后果: 线程 B 看到 ready 为真,跳出循环执行 (4),但此时 data 还没被 A 写入。临界区的“先后顺序”语义被彻底打破。

4. 如何防止重排破坏语义?

为了保护临界区,底层同步原语必须引入内存屏障(Memory Barrier)

  • Acquire Barrier(获取屏障): 放在 lock 之后。保证 lock 之后的所有指令都不会重排到 lock 之前。
  • Release Barrier(释放屏障): 放在 unlock 之前。保证 unlock 之前的所有指令都不会重排到 unlock 之后。

这就是为什么在 Swift 中使用 Atomic 或在 C++ 中使用 std::memory_order_release/acquire 的原因——它们不是简单的变量,而是告诉 CPU: “此处严禁超车”。


总结

指令重排之所以危险,是因为它让临界区的物理边界(代码行数)不再等同于逻辑边界(实际执行顺序)