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

如果你看过苹果在<libkern/OSAtomic.h>文件或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不会跨越屏障重排指令顺序。

需要注意的是,C语言本身并没有线程的概念,线程是操作系统提供的API,因此编译器不会假设全局变量随时会被其它线程修改。当然,编译器的智能化在不断提高,C语言本身也在进化。不过,我们还是不要依赖于编译器的聪明程度为好。

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容