volatile和内存屏障

简介 volatile关键字的目的是防止编译器对变量访问做任何优化,因为这些变量可能会以编译器无法确定的方式被修改。
声明为volatile的变量不会被优化,因为它们的值随时可能被当前代码范围之外的代码修改。系统总是从内存读取变量的当前值,而不会使用寄存器中的值,即使上条指令刚操作过此数据。(volatile的影响远不止是否使用寄存器值这么简单)
应用场景 当变量的值可能发生意外变化时,应该将其声明为volatile。实际上,只有三种情况:

  1. 内存映射外围设备寄存器
  2. 由中断处理程序修改的全局变量
  3. 被多个线程访问的共享变量
第一种情况,外围设备寄存器的值随时可能被外部改变,显然超出了代码的范围。第二种情况,中断处理程序的执行模式不同于普通程序,当中断到来时,当前线程挂起,执行中断处理程序,之后恢复代码的执行。可以认为,中断处理程序与当前程序是并行的,独立于正常代码执行序列之外。第三种情况比较常见,就是一般的并发编程。
原理 编译器假设变量值变化的唯一方式是被代码修改。
int a = 24;

现在编译器会认为 a的值一直是24,除非遇到修改a值的语句。如果后面有代码:
int b = a + 3;

编译器会认为,既然已经知道a的值是24,因此b的值肯定是27,所以不需要生成计算a + 3的指令。
如果a的值在两条语句中间被修改了,那么编译的结果就会出错。然而,a的值为什么会突然被修改呢?不会的。
如果a是一个栈变量,除非传递一个指向它的引用,否则它的值是不会改变的。例如:
doSomething(&a);

函数doSomething有一个指向a的指针,意味着a的值可能会被修改,此行代码之后a的值可能就不再是24了。如果这样写:
int a = 24; doSomething(&a); int b = a + 3;

编译器将不会优化掉a + 3的计算。谁知道doSomething之后a的值是多少呢?编译器显然不知道。
对于全局变量或者对象的实例变量,问题会更复杂一些。这些变量不在栈上,而是在堆中,这意味着不同的线程可以访问它们。
// Global Scope int a = 0; void function() { a = 24; b = a + 3; }

b会是27吗?很可能是的,不过其它线程也可能在两条语句之间修改了a的值,尽管这种可能性比较小。编译器会意识到这一点儿吗?不会的。因为C语言本身并不知道关于线程的任何东西——至少过去是这样的(最新的C标准终于知道了native线程,不过之前所有的线程功能都是由操作系统提供的API,而不是C语言本身的特性)。因此C编译器依然会认为b的值是27,并将计算优化掉,这会导致错误的结果。
这就是volatile的用武之地了。如果标记变量为volatile:
volatile int a = 0;

我们告诉编译器:a的值可能随时会突然改变。对于编译器来说,这意味着它不能假设a的值,哪怕1皮秒之前它还是那个值,并且看起来也没有代码修改它。每次访问a时,总是读取它的当前值。
过度使用volatile会阻碍许多编译器优化,可能会显著降低计算代码的速度,而且人们经常在不必要的情况下使用volatile。例如,编译器不会跨越内存屏障进行值假设。内存屏障是什么超出了本文的讨论范围,只需要知道典型的同步结构都是内存屏障,例如锁、互斥或信号量等。对于下面代码:
// Global Scope int a = 0; void function() { a = 24; pthread_mutex_lock(m); b = a + 3; pthread_mutex_unlock(m); }

pthread_mutex_lock是一个内存屏障(pthread_mutex_unlock也是),因此不需要将a声明为volatile,编译器不会跨越内存屏障假设a的值,永远不会。
Objective-C在所有方面都很像C,毕竟它只是一个带有运行时的扩展版的C。需要指出的一点是,atomic属性是内存屏障,因此不需要为属性声明volatile。如果需要在多个线程中访问属性,那么可以将属性声明为atomic的(如果不声明nonatomic,默认也是atomic)。如果不需要在多个线程访问,标记为nonatomic会使属性的访问更快,不过只有在频繁访问属性时才会表现出来(不是指一分钟访问10次那种,而是一秒钟需要访问数千次以上)。
Obj-C代码什么时候需要使用volatile呢?
@implementation SomeObject { volatile bool done; }- (void)someMethod { done = false; // Start some background task that performes an action // and when it is done with that action, it sets `done` to true. // ...// Wait till the background task is done while (!done) { // Run the runloop for 10 ms, then check again [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01] ]; } } @end

如果没有volatile,编译器可能会愚蠢地认为,done不会改变,因此简单地用true替换done,以致于形成一个死循环。
当Apple还在使用GCC 2.x时,如果上面的代码没有使用volatile,的确会导致死循环(仅在开启优化的release编译模式,debug模式并不会)。在现代编译器上没有验证过这一点,或许当前版本的clang更智能一些。不过我们显然不能指望编译器足够聪明来正确处理这一点。 同时也取决于启动后台任务的方式。如果dispatch一个block,编译器很容易知道done是否会被改变。如果向某处传递一个指向done的指针,编译器知道done的值可能会被修改,因此不会对其值进行任何假设。
Memory barriers
如果你看过苹果在文件或atomic 的手册页中提供的原子操作,那么或许你已经注意到,每个操作有两个版本:一个x和一个xBarrier(例如,OSAtomicAdd32OSAtomicAdd32Barrier)。现在你知道了,名字中带有“Barrier”的是一个内存屏障,而另一个不是。
内存屏障不仅适用于编译器,也适用于CPU(有些CPU指令被认为是内存屏障)。CPU需要知道这些屏障,因为CPU会对指令重新排序,以便流水线化乱序执行。例如:
a = x + 3; // (1) b = y * 5; // (2) c = a + b; // (3)

假设加法器的流水线正忙,而乘法器的流水线还有空闲,那么CPU可能会在(1)之前先执行(2),毕竟执行顺序并不会影响最终的运算结果。这可以防止管道停滞。当然,CPU也足够聪明地知道(3)的执行不能早于(1)或(2),因为(3)的结果依赖于(1)和(2)的结果。
流水线,简单来说就是一个CPU核心有多套运算器,每条指令分为几个阶段,多条指令并行执行。
? load instruction
? decode load instruction
? load data decode load instruction
? operation load data decode
? save data operation load data
? save data operation
? save data
然而,某些类型的顺序更改会破坏代码或程序员的意图。考虑如下代码:
x = y + z; // (1) a = 1; // (2)

加法器流水线正忙,因此为什么不在(1)之前先执行(2)呢?它们没有依赖关系,因此顺序无关紧要,对吧?看情况。假设有一个线程正在监听a的变化,当a变为1时,读取x的值,如果按序执行的话值应该是y + z。但如果CPU调整了执行顺序,x的值就还是此段代码执行之前的值,此时另一个线程获取到的值是不符合程序员期望的。
对于这种情况,顺序是很重要的,这就是为什么CPU也需要屏障:CPU不会跨越屏障重新排序指令。因此,指令(2)需要是一个屏障指令(或者在(1)和(2)之间有一个屏障指令,取决于具体的CPU)。
重新排序指令是现代CPU的特性,还有一个更老的问题是延迟内存写操作。如果CPU延迟对内存的写操作(对于一些CPU来说很常见,因为内存访问速度相对于CPU来说实在太慢了),它将确保所有延迟的写操作在跨越内存屏障之前被执行并完成,因此当其它线程访问时,所有的内存都处于正确的状态(知道“内存屏障”这个词的出处了吧)。
与内存屏障打交道的地方可能比我们意识到的要多很多(GCD - Grand Central Dispatch到处都是内存屏障,以及基于GCD的NSOperation/NSOperationQueue),这就是为什么我们只需要在非常少的、特殊的情况才真正需要使用volatile。可能你写了100个App都不需要用到一次。然而,如果我们需要编写大量低层的、多线程的代码,并期望达到最高的性能,那么或早或晚会遇到必须使用volatile才能确保功能正确的情况。如果此种情况不使用volatile,可能导致死循环或变量值不正确却无法解释的问题。如果遇到了这样的问题,尤其是只在release模式下才会出现,那么很可能是因为缺失了volatile或内存屏障。
总结 为了优化代码性能,编译器默认情况下会根据当前代码的上下文推断变量的值,以减少不必要的计算。在单线程、正常执行的情况下,不会有什么问题。但是在中断处理程序、多线程并发和内存映射I/O的情况,变量的值可能在当前代码范围之外突然被修改,这些情况超出了编译器的意识范围。因此,需要我们显式地告诉编译器,不要推断这些变量的值,因为它们随时可能被当前代码范围之外的代码或硬件修改。
另外,编译器不会跨越内存屏障推断变量的值。在实际编程中,很多内存屏障是隐性的,因为常见的同步工具已带有内存屏障功能,如锁、互斥和信号量等,iOS并行编程中最常用的GCD到处都是内存屏障,atomic属性也是一个内存屏障。
屏障不仅适用于编译器,也适用于CPU。绝大多数现代CPU都引入了流水线,乱序并行执行多条指令。当我们想要确保指令执行顺序时,也需要使用屏障指令。CPU不会跨越屏障重排指令顺序。
【volatile和内存屏障】需要注意的是,C语言本身并没有线程的概念,线程是操作系统提供的API,因此编译器不会假设全局变量随时会被其它线程修改。当然,编译器的智能化在不断提高,C语言本身也在进化。不过,我们还是不要依赖于编译器的聪明程度为好。
参考资料
  • Understanding “volatile” qualifier in C
  • Which scenes keyword “volatile” is needed to declare in objective-c?
  • How to Use C's volatile Keyword

    推荐阅读