volatile和内存屏障
简介
volatile
关键字的目的是防止编译器对变量访问做任何优化,因为这些变量可能会以编译器无法确定的方式被修改。
声明为volatile
的变量不会被优化,因为它们的值随时可能被当前代码范围之外的代码修改。系统总是从内存读取变量的当前值,而不会使用寄存器中的值,即使上条指令刚操作过此数据。(volatile
的影响远不止是否使用寄存器值这么简单)
应用场景
当变量的值可能发生意外变化时,应该将其声明为volatile。实际上,只有三种情况:
- 内存映射外围设备寄存器
- 由中断处理程序修改的全局变量
- 被多个线程访问的共享变量
原理 编译器假设变量值变化的唯一方式是被代码修改。
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
(例如,OSAtomicAdd32
和OSAtomicAdd32Barrier
)。现在你知道了,名字中带有“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
推荐阅读
- JAVA(抽象类与接口的区别&重载与重写&内存泄漏)
- 急于表达——往往欲速则不达
- 第三节|第三节 快乐和幸福(12)
- 20170612时间和注意力开销记录
- 2.6|2.6 Photoshop操作步骤的撤消和重做 [Ps教程]
- 对称加密和非对称加密的区别
- 眼光要放高远
- 樱花雨
- 前任
- 2020-04-07vue中Axios的封装和API接口的管理