从单例模式谈起(二):volatile 关键字
目录
在前一部分(从单例模式谈起(一))我们讨论单例模式时,谈到了 Double-Checked Locking
问题。我们讲, volatile
关键字并不能解决Double-Checked Locking 问题。这一节我们讨论与此相关的问题。
现代编译器为了提高程序的效率而对代码做了很多优化。这些优化大部分是有益的,但是也为多线程编程带来问题。
寄存器缓存
我们来看如下代码:
1 2 3 4 5 6 7 8 9 10 11 |
x = 0; Thread1 { lock(); x++; unlock(); } Thread2 { lock(); x++; unlock(); } |
x++
的值有锁保护,所以它是独占的,x++ 的行为不会被并发破坏。那么 x 的值必然为 2。 然而现实中并非一定如此:编译器有可能为了提高 x 的访问速度而将 x 放入线程的某个寄存器中,而线程的寄存器是线程私有的。 如果 Thread1 先获得了锁,那么程序的执行过程有可能是这样的:
- [Thread1] 读取 x 到寄存器 R1. (R1 = x = 0)
- [Thread1] R1++. unlock. 由于之后可能还会访问 x, 所以 Thread1 不将 R1 写回 x . (x = 0)
- [Thread2] 读取 x 到寄存器 R2 (R2 = x = 0)
- [Thread2] R2++
- [Thread2] 将 R2 写回 x (x = R2 =1)
- [Thread1] 在某个时候将 R1 写回 x (x = R1 = 1)
由此可见,即使正确的加了锁,也不能保证多线程安全。
指令顺序调整
再看一个例子:
1 2 3 4 5 6 7 8 9 |
x = y = 0; Thread1 { x = 1; r1 = y; } Thread2 { y = 1; r2 = x; } |
理论上, r1, r2 中至少有一个为1, 而不可能同时为0。但实际中 r1=r2=0 的情况时有发生。这是因为编译器发殿出了 动态调度
, 为了提高效率有可能交换指令的顺序。也就是说,上面的代码可能是按下面的顺序来执行的:
1 2 3 4 5 6 7 8 9 |
x = y = 0; Thread1 { r1 = y; x = 1; } Thread2 { y = 1; r2 = x; } |
而我们前一节提到的 DoubleChecking 问题,也是由此产生的。
volatile
volatile
的引入是为了阻止过度优化,它可以做两件事:
- 阻止编译器为了提高效率而将 volatile 变量缓存到寄存器中却不写回
- 阻止编译器调整 volatile 变量的指令顺序
volatile 可以解决第一个问题,但无法解决第二个问题。因为除了编译器会动态调整指令顺序, CPU 也具有动态调整指令顺序的能力。而在 C++ 层面来阻止 CPU 动态调整指令是一件很困难的事。
很多CPU 会提供一条 barrier
指令,可以用来阻止CPU动态调整指令。但它不在我们今天讨论的范围之内。
总之单从编译器层面来说, volatile 可以在一定程序上保证线程安全,但是在程序的整个运行环境中,volatile 并不是万能的。
参考<程序员的自我修养>