4-1.【OC】【锁】为什么“线程安全”本质是内存可见性问题,而不仅是互斥?

2 阅读3分钟

实际上,互斥(Mutual Exclusion)只解决了“谁能改”的问题,而可见性(Visibility)解决了“改了之后别人能不能看见”的问题。

如果只考虑互斥而忽略可见性,即便你的代码加了锁,依然可能读到“过时”的数据。


1. 核心矛盾:硬件架构与性能优化

要理解可见性,必须先看现代计算机的硬件结构。为了抹平 CPU 运算速度与内存(RAM)读写速度之间的巨大鸿沟,CPU 引入了多级缓存(L1, L2, L3)。

缓存一致性问题

在多核 CPU 中,每个核心都有自己的私有寄存器和 L1/L2 缓存:

  1. 线程 A 在核心 1 运行,将变量 x 从内存读入自己的缓存,修改为 1
  2. 线程 B 在核心 2 运行,它缓存里的 x 可能还是旧值 0
  3. 如果线程 A 没有及时将修改刷新回主内存,或者线程 B 没有被通知去主内存重新读取,线程 B 就会一直基于旧值操作。

2. 线程安全的三要素

线程安全本质上由三个相互关联的特性组成。互斥(原子性)只是其中之一:

特性描述解决的问题常用工具
原子性 (Atomicity)操作不可分割,要么全成功要么全失败。竞态条件 (Race Condition)Mutex, Atomic
可见性 (Visibility)一个线程修改了共享变量,其他线程能立即看到。缓存过期、寄存器缓存volatile, Memory Barrier
有序性 (Ordering)程序执行的顺序按照代码的先后顺序。指令重排序 (Reordering)Memory Barrier

3. 为什么“仅有互斥”是不够的?

考虑一个经典的错误:双重检查锁定(Double-Checked Locking)

在一些语言(如 C++ 或旧版 Java)中,如果没有正确处理可见性和有序性,即便你加了锁,另一个线程在检查 instance == nil 时,可能会看到一个“半初始化”的对象。这是因为编译器为了优化性能,可能会先分配内存并将指针赋值给变量,然后再执行构造函数。

如果没有**内存屏障(Memory Barrier)**来保证可见性和有序性,另一个线程即便没进锁,也会因为读到了非空的指针而拿到一个错误的对象。


4. 软件层面的“可见性”:指令重排序

除了硬件缓存,编译器和处理器还会进行指令重排序

编译器可能会认为:“既然代码里 A 和 B 没有逻辑依赖,那我先执行 B 再执行 A 效率更高。”

在单线程下这没问题,但在多线程下,这种优化会导致逻辑错乱。**内存可见性协议(如 C++11 内存模型、Java 内存模型 JMM)**就是为了规定:在什么情况下,一个线程的写入必须对另一个线程可见。


5. 总结

  • 互斥(Mutex): 像是一把锁,保证同一时间只有一个工人在房间里干活。
  • 可见性(Visibility): 像是房间的墙壁是透明的,或者有一套通知机制,确保一个工人干完活离开后,下一个进来的工人(甚至在外面观察的工人)能立刻看到房间里的最新状态。

没有可见性保证的互斥,就像是在漆黑的房间里接力写字——虽然每次只有一个人写,但后面的人根本看不见前面的人写了什么。