实际上,互斥(Mutual Exclusion)只解决了“谁能改”的问题,而可见性(Visibility)解决了“改了之后别人能不能看见”的问题。
如果只考虑互斥而忽略可见性,即便你的代码加了锁,依然可能读到“过时”的数据。
1. 核心矛盾:硬件架构与性能优化
要理解可见性,必须先看现代计算机的硬件结构。为了抹平 CPU 运算速度与内存(RAM)读写速度之间的巨大鸿沟,CPU 引入了多级缓存(L1, L2, L3)。
缓存一致性问题
在多核 CPU 中,每个核心都有自己的私有寄存器和 L1/L2 缓存:
- 线程 A 在核心 1 运行,将变量
x从内存读入自己的缓存,修改为1。 - 线程 B 在核心 2 运行,它缓存里的
x可能还是旧值0。 - 如果线程 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): 像是房间的墙壁是透明的,或者有一套通知机制,确保一个工人干完活离开后,下一个进来的工人(甚至在外面观察的工人)能立刻看到房间里的最新状态。
没有可见性保证的互斥,就像是在漆黑的房间里接力写字——虽然每次只有一个人写,但后面的人根本看不见前面的人写了什么。