认识CC++ volatile

38次阅读

共计 12151 个字符,预计需要花费 31 分钟才能阅读完成。

1. 令人困惑的 volatile
volatile 字面意思是“不稳定的、易失的”,不少编程语言中存在 volatile 关键字,也有共同之处,如“表示程序执行期间数据可能会被外部操作修改”,如被外设修改或者被其他线程修改等。这只是字面上给我们的一般性认识,然而具体到不同的编程语言中 volatile 的语义可能相差甚远。
很多人以为自己精通 CC++,但是被问起 volatile 的时候却无法清晰、果断地表明态度,那只能说明还是处在“从入门到精通”的路上,如果了解一门语言常见特性的使用、能够写健壮高效的程序就算精通的话,那实在是太藐视“大师”的存在了。从一个 volatile 关键字折射出了对 CC++ 标准、编译器、操作系统、处理器、MMU 各个方面的掌握程度。
几十年的发展,很多开发者因为自己的偏见、误解,或者对某些语言特性(如 Java 中的 volatile 语义)的根深蒂固的认识,赋予了 CC++ volatile 本不属于它的能力,自己却浑然不知自己犯了多大的一个错误。
我曾经以为 CC++ 中 volatile 可以保证保证线程可见性,因为 Java 中是这样的,直到后来阅读 Linux 内核看到 Linus Torvards 的一篇文档,他强调了 volatile 可能带来的坏处“任何使用 volatile 的地方,都可能潜藏了一个 bug”,我为他的“危言耸听”感到吃惊,所以我当时搜索了不少资料来求证 CC++ volatile 的能力,事后我认为 CC++ volatile 不能保证线程可见性。但是后来部门内一次分享,分享中提到了 volatile 来保证线程可见性,我当时心存疑虑,事后验证时犯了一个错误导致我错误地认为 volatile 可以保证线程可见性。直到我最近翻阅以前的笔记,翻到了几年前对 volatile 的疑虑……我决定深入研究下这个问题,以便能顺利入眠。
2. 从规范认识 volatile
以常见的编程语言 C、C++、Java 为例,它们都有一个关键字 volatile,但是对 volatile 的定义却并非完全相同。

Java 中对 volatile 的定义:
8.3.1.4. volatile FieldsThe Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.
The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.
A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4).

Java 清晰地表达了这样一个观点,Java 内存模型中会保证 volatile 变量的线程可见性,接触过 Java 并发编程的开发者应该都清楚,这是一个不争的事实。

CC++ 中对 volatile 的定义:
6.7.3 Type qualifiersvolatile: No cacheing through this lvalue: each operation in the abstract semantics must be performed (that is, no cacheing assumptions may be made, since the location is not guaranteed to contain any previous value). In the absence of this qualifier, the contents of the designated location may be assumed to be unchanged except for possible aliasing.

C99 中也清晰地表名了 volatile 的语义,不要做 cache 之类的优化。这里的 cache 指的是 software cacheing,即编译器生成指令将内存数据缓存到 cpu 寄存器,后续访问内存变量使用寄存器中的值;需要与之作出区分的是 hardware cacheing,即 cpu 访问内存时将内存数据缓存到 cpu cache,硬件操作完全对上层应用程序透明。大家请将这两个点铭记在心,要想搞清楚 CC++ volatile 必须要先理解这里 cache 的区别。
C99 清晰吗?上述解释看上去很清晰,但是要想彻底理解 volatile 的语义,绝非上述一句话就可以讲得清的,C99 中定义了 abstract machine 以及 sequence points,与 volatile 相关的描述有多处,篇幅原因这里就不一一列举了,其中与 volatile 相关的 abstract machine 行为描述共同确定了 volatile 的语义。

3. 对 volatile 持何观点
为了引起大家对 CC++ volatile 的重视并及时表明观点,先贴一个页面“Is-Volatile-Useful-with-Threads”,网站中简明扼要的告知大家,“Friends don’t let friends use volatile for inter-thread communication in C and C++”。But why?

isocpp 专门挂了这么个页面来强调 volatile 在不同编程语言中的差异,可见它是一个多么难缠的问题。即便是有这么个页面,要彻底搞清楚 volatile,也不是说读完上面列出的几个技术博客就能解决,那也太轻描淡写了,所以我搜索、整理、讨论,希望能将学到的内容总结下来供其他开发者参考,我也不想再因为这个问题而困扰。
结合 CC++ volatile qualifier 以及 abstract machine 中对 volatile 相关 sequence points 的描述,可以确定 volatile 的语义:

不可优化性:不要做任何软件 cache 之类的优化,即多次访问内存对象时,编译器不能优化为 cache 内存对象到寄存器、后续访问内存对象转为访问寄存器 [6.7.3 Type qualifiers – volatile];
顺序性:对 volatile 变量的多次读写操作,编译器不能以预测数据不变为借口优化掉读写操作,并且要保证前面的读写操作先于后面的读写操作完成 [5.1.2.3 Program execution];
易变性:从不可优化性、顺序性语义要求,不难体会出其隐含着数据“易变性”,这也是 volatile 字面上的意思,也是不少开发者学习 volatile 时最熟知的语义;

CC++ 规范没有显示要求 volatile 支持线程可见性,gcc 也没有在标准允许的空间内做什么“发挥”去安插什么保证线程可见性的处理器指令(Java 中 volatile 会使用 lock 指令使其他处理器 cache 失效强制读内存保证线程可见性)。而关于 CPU cache 一致性协议,x86 原先采用 MESI 协议,后改用效率更高的 MESIF,都是强一致性协议,在 x86 这等支持强一致的 CPU 上,CC++ 中结合 volatile 是可以“获得”线程可见性的,在非强一致 CPU 上则不然。
但是 CC++ volatile 确实是有价值的,很多地方都要使用它,而且不少场景下似乎没有比它更简单的替代方法,下面首先列举 CC++ volatile 的通用适用场景,方便大家认识 volatile,然后我们再研究为什么 CC++ volatile 不能保证线程可见性。CC++ 标准中确实没有说 volatile 要支持线程可见性,大家可以选择就此打住,但是我怀疑的是 gcc 在标准允许的空间内是怎么做的?操作系统、MMU、处理器是怎么做的?“标准中没有显示列出”,这样的理由还不足以让我停下探索的脚步。
4. CC++ need volatile
CC++ volatile 语义“不可优化型”、“顺序性”、“易变性”,如何直观感受它的价值呢?看 C99 中给出的适用场景吧。

setjmp、longjmp 用于实现函数内、函数间跳转(goto 只能在函数内跳转),C Spec 规定 longjmp 之后希望跳到的栈帧中的局部变量的值是最新值,而不是 setjmp 时的值,考虑编译器可能作出一些优化,将 auto 变量 cache 到寄存器中,假如 setjmp 保存硬件上下文的时候恰巧保存了存有该局部变量值的寄存器信息,等 longjmp 回来的时候就用了旧值。这违背了 C Spec 的规定,所以这个时候可以使用 volatile 来避免编译器优化,满足 C Spec!
signal handler 用于处理进程捕获到的信号,与 setjmp、longjmp 类似,进程捕获、处理信号时需要保存当前上下文再去处理信号,信号处理完成再恢复上下文继续执行。信号处理函数中也可能会修改某些共享变量,假如共享变量在收到信号时加载到了寄存器,并且保存硬件上下文时也保存起来了,那么信号处理函数执行完毕返回(可能会修改该变量)恢复上下文后,访问到的还是旧值。因此将信号处理函数中要修改的共享变量声明为 volatile 是必要的。

设备驱动、Memory-Mapped IO、DMA。我们先看一个示例,假如不使用 volatile,编译器会做什么。编译器生成代码可能会将内存变量 sum、i 放在寄存器中,循环执行过程中,编译器可能认为这个循环可以直接优化掉,sum 直接得到了最终的 a[0]+a[1]+…a[N] 的值,循环体执行次数大大减少。
sum = 0;
for (i=0; i<N; ++i)
sum += a[i];
这种优化对于面向硬件的程序开发(如设备驱动开发、内存映射 IO)来说有点过头了,而且会导致错误的行为。下面的代码使用了 volatile qualifer,其他与上述代码基本相同。如果不存在 volatile 修饰,编译器会认为最终 *ttyport 的值就是 a[N-1],前 N - 1 次赋值都是没必要的,所以直接优化成 *ttyport = a[N-1]。但是 ttyport 是外设的设备端口通过内存映射 IO 得到的虚拟内存地址,编译器发现存在 volatile 修饰,便不会对循环体中 *ttyport = a[i] 进行优化,循环体会执行 N 次赋值,且保证每次赋值操作都与前一次、后一次赋值存在严格的顺序性保证。
volatile short *ttyport;
for (i=0; i<N; ++i)
*ttyport = a[i];
可能大家会有疑问,volatile 只是避免编译器将内存变量存储到寄存器,对 cpu cache 却束手无策,谁能保证每次对 *ttyport 的写操作都确定写回内存了呢?这里就涉及到 cpu cache policy 问题了。
对于外设 IO 而言,有两种常用方式:

Memory-Mapped IO,简称 MMIO,将设备端口(寄存器)映射到进程地址空间。以 x86 为例,对映射内存区域的读写操作通过普通的 load、store 访存指令来完成,处理器通过内存类型范围寄存器(MTRR,Memory Type Range Regsiters)和页面属性表(PAT,Page Attribute Table)对不同的内存范围设置不同的 CPU cache policy,内核设置 MMIO 类型范围的 cpu cache 策略为 uncacheable,其他 RAM 类型范围的 cpu cache 策略为 write-back!即直接绕过 cpu cache 读写内存,但实际上并没有物理内存参与,而是将读写操作转发到外设,上述代码中 *ttyport = a[i] 这个赋值操作绕过 CPU cache 直达外设。

Port IO,此时外设端口(寄存器)采用独立编址,而非 Memory-Mapped IO 这种统一编址方式,需要通过专门的 cpu 指令来对设备端口进行读写,如 x86 上采用的是指令 in、out 来完成设备端口的读写。

而如果是 DMA(Direct Memory Access)操作模式的话,它绕过 cpu 直接对内存进行操作,期间不中断 cpu 执行,DMA 操作内存方式上与 cpu 类似,都会考虑 cpu cache 一致性问题。假如 DMA 对内存进行读写操作,总线上也会对事件进行广播,cpu cache 也会观测到并采取相应的动作。如 DMA 对内存进行写操作,cpu cache 也会将相同内存地址的 cache line 设置为 invalidate,后续读取时就可以重新从内存加载最新数据;假如 DMA 进行内存读操作,数据可能从其他 cpu cache 中直接获取而非从内存中。这种情况下 DMA 操作的内存区域,对应的内存变量也应该使用 volatile 修饰,避免编译器优化从寄存器中读到旧值。
以上示例摘自 C99 规范,通过上述示例、解释,可以体会到 volatile 的语义特点:“不可优化型、易变性、顺序性”。
下面这个示例摘自网络,也比较容易表现 volatile 的语义特点:
// 应为 volatile unsigned int *p = ….
unsigned int *p = GetMagicAddress();
unsigned int a, b;

a = *p;
b = *p;

*p = a;
*p = b;
GetMagicAddress() 返回一个外设的内存映射 IO 地址,由于 unsigned int * p 指针没有 volatile 修饰,编译器认为 * p 中的内容不是“易变的”因此可能会作出如下优化。首先从 p 读取一个字节到寄存器,然后将其赋值给 a,然后认为 * p 内容不变,就直接将寄存器中内容再赋值给 b。写 * p 的时候认为 a == b,写两次没必要就只写了一次。
而如果通过 volatile 对 * p 进行修饰,则就是另一个结果了,编译器会认为 * p 中内容是易变的,每次读取操作都不会沿用上次加载到寄存器中的旧值,而内存映射 IO 内存区域对应的 cpu cache 模式又是被 uncacheable 的,所以会保证从内存读取到最新写入的数据,成功连续读取两个字节 a、b,也保证按顺序写入两个字节 a、b。
相信读到这里大家对 CC++ volatile 的适用场景有所了解了,它确实是有用的。那接下来我们针对开发者误解很严重的一个问题“volatile 能否支持线程可见性”再探索一番,不能!不能!不能!
5. CC++ thread visibility
5.1. 线程可见性问题
多线程编程中经常会通过修改共享变量的方式来通知另一个线程发生了某种状态的变化,希望线程能及时感知到这种变化,因此我们关心“线程可见性问题”。
在对称多处理器架构中(SMP),多处理器、核心通过总线共享相同的内存,但是各个处理器核心有自己的 cache,线程执行过程中,一般会将内存数据加载到 cache 中,也可能会加载到寄存器中,以便实现访问效率的提升,但这也带来了问题,比如我们提到的线程可见性问题。某个线程对共享变量做了修改,线程可能只是修改了寄存器中的值或者 cpu cache 中的值,修改并不会立即同步回内存。即便同步回内存,运行在其他处理器核心上的线程,访问该共享数据时也不会立即去内存中读取最新的数据,无法感知到共享数据的变化。
5.2. diff volatile in java、cc++
有些编程语言中定义了关键字 volatile,如 Java、C、C++ 等,对比下 Java volatile 和 CC++ volatile,差异简直是太大了,我们只讨论线程可见性相关的部分。
Java 中语言规范明确指出 volatile 保证内存可见性,JMM 存在“本地内存”的概念,线程对“主存”变量的访问都是先加载到本地内存,后续写操作再同步回主存。volatile 可以保证一个线程的写操作对其他线程立即可见,首先是保证 volatile 变量写操作必须要更新到主存,然后还要保证其他线程 volatile 变量读取必须从主存中读取。处理器中提供了 MFENCE 指令来创建一个屏障,可以保证 MFENCE 之前的操作对后续操作可见,用 MFENCE 可以实现 volatile,但是考虑到 AMD 处理器中耗时问题以及 Intel 处理器中流水线问题,JVM 从 MFENCE 修改成了 LOCK: ADD 0。
但是在 C、C++ 规范里面没有要求 volatile 具备线程可见性语义,只要求其保证“不可优化性、顺序性、易变性”。
5.3. how gcc handle volatile
这里做个简单的测试:
#include <stdio.h>
int main() {
// volatile int a = 0;
int a = 0;
while(1) {
a++;
printf(“%d\n”, a);
}
return 0;
}
不开优化的话,有没有 volatile gcc 生成的汇编指令基本是一致的,volatile 变量读写都是针对内存进行,而非寄存器。开 gcc -O2 优化时,不加 volatile 情况下读写操作通过寄存器,加了 volatile 则通过内存。
1)不加 volatile:gcc -g -O2 -o main main.c

这里重点看下对变量 a 的操作,xor %ebx,%ebx 将寄存器 %ebx 设为 0,也就是将变量 a = 0 存储到了 %ebx,nopl 不做任何操作,然后循环体里面每次读取 a 的值都是直接在 %ebx+1,加完之后也没有写回内存。假如有个共享变量是多个线程共享的,并且没有加 volatile,多个线程访问这个变量的时候就是用的物理线程跑的处理器核心寄存器中的数据,是无法保证内存可见性的。
2)加 volatile:gcc -g -O2 -o main main.c

这里变量 a 的值首先被设置到了 0xc(%rsp) 中,nopl 空操作,然后 a ++ 时是将内存中的值移动到了寄存器 %eax 中,然后执行 %eax+ 1 再写回内存 0xc(%rsp) 中,while 循环中每次循环执行都是先从内存里面取值,更新后再写回内存。但是这样就可以保证线程可见性了吗?No!
5.4. how cpu cache works
是否有这样的疑问?CC++ 中对 volatile 变量读写,发出的内存读写指令不会被 CPU 转换成读写 CPU cache 吗?这个属于硬件层面内容,对上层透明,编译器生成的汇编指令也无法反映实际执行情况!因此,只看上述反汇编示例是不能确定 CC++ volatile 支持线程可见性的,当然也不能排除这种可能性?
Stack Overflow 上 Dietmar Kühl 提到,‘volatile’阻止了对变量的优化,例如对于频繁访问的变量,会阻止编译器对其进行编译时优化,避免将其放入寄存器中(注意是寄存器而不是 cpu 的 cache)。编译器优化内存访问时,会生成将内存数据缓存到寄存器、后续访问内存操作转换为访问寄存器,这称为“software cacheing”;而 CPU 实际执行时硬件层面将内存数据缓存到 CPU cache 中,这称为“hardware cacheing”,是对上层完全透明的。现在已经确定 CC++ volatile 不会再作出“将内存数据缓存到 CPU 寄存器”这样的优化,那上述 CPU hardware caching 技术就成了我们下一个怀疑的对象。
保证 CPU cache 一致性的方法,主要包括 write-through(写直达)或者 write-back(写回),write-back 并不是当 cache 中数据更新时立即写回,而是在稍后的某个时机再写回。写直达会严重降低 cpu 吞吐量,所以现如今的主流处理器中通常采用写回法,而写回法又包括了 write-invalidate 和 write-update 两种方式,可先跳过。

write-back:

write-invalidate,当某个 core(如 core 1)的 cache 被修改为最新数据后,总线观测到更新,将写事件同步到其他 core(如 core n),将其他 core 对应相同内存地址的 cache entry 标记为 invalidate,后续 core n 继续读取相同内存地址数据时,发现已经 invalidate,会再次请求内存中最新数据。
write-update,当某个 core(如 core 1)的 cache 被修改为最新数据后,将写事件同步到其他 core,此时其他 core(如 core n)立即读取最新数据(如更新为 core 1 中数据)。

write-back(写回法)中非常有名的 cache 一致性算法 MESI,它是典型的强一致 CPU,intel 就凭借 MESI 优雅地实现了强一致 CPU,现在 intel 优化了下 MESI,得到了 MESIF,它有效减少了广播中 req/rsp 数量,减少了带宽占用,提高了处理器处理的吞吐量。关于 MESI,这里有个可视化的 MESI 交互演示程序可以帮助理解其工作原理,查看 MESI 可视化交互程序。
我们就先结合简单的 MESI 这个强一致性协议来试着理解下 x86 下为什么就可以保证强一致,结合多线程场景分析:

一个 volatile 共享变量被多个线程读取,假定这几个线程跑在不同的 cpu 核心上,每个核心有自己的 cache,线程 1 跑在 core1 上,线程 2 跑在 core2 上。
现在线程 1 准备修改变量值,这个时候会先修改 cache 中的值然后稍后某个时刻写回主存或者被其他 core 读取。cache 同步策略“write-back”,MESI 就是其中的一种。处理器所有的读写操作都能被总线观测到,snoop based cache coherency,当线程 2 准备读取这个变量时:
假定之前没读取过,发现自己的 cache 里面没有,就通过总线向内存请求,为了保证 cpu cache 高吞吐量,总线上所有的事务都能被其他 core 观测到,core1 发现 core2 要读取内存值,这个数据刚好在我的 cache 里面,但是处于 dirty 状态。core1 可能灰采取两种动作,一种是将 dirty 数据直接丢给 core2(至少是最新的),或者告知 core2 延迟 read,等我先写回主存,然后 core2 再尝试 read 内存。
假定之前读取过了,core1 对变量的修改也会被 core2 观测到,core1 应该将其 cache line 标记为 modified,将 core2 cache line 标记为 invalidate 使其失效,下次 core2 读取时从 core1 获取或内存获取(触发 core1 将 dirty 数据写回主存)。

这么看来只要处理器的 cache 一致性算法支持,并且结合 volatile 避免寄存器相关优化,就能轻松保证线程可见行。但是不同的处理器设计不一样,我们只是以 MESI 协议来粗略了解了 x86 的处理方式,对于其他非强一致性 CPU,即便使用了 volatile 也不一定能保证线程可见性,但若是对 volatile 变量读写时安插了类似 MFENCE、LOCK 指令也是可以的?如何进一步判断呢?
还需要判断编译器(如 gcc)是否有对 volatile 来做特殊处理,如安插 MFENCE、LOCK 指令之类的。上面编写的反汇编测试示例中,gcc 生成的汇编没有看到 lock 相关的指令,但是因为我是在 x86 上测试的,而 x86 刚好是强一致 CPU,我也不确定是不是因为这个原因,gcc 直接图省事略掉了 lock 指令?所以现在要验证下,在其他非 x86 平台上,gcc -O2 优化时做了何种处理。如果安插了类似指令,问题就解决了,我们也可以得出结论,c、c++ 中 volatile 在 gcc 处理下可以保证线程可见性,反之则不能得到这样的结论!
我在网站 godbolt.org 交叉编译测试了一下上面 gcc 处理的代码,换了几个不同的硬件平台也没发现有生成特定的类似 MFENCE 或者 LOCK 相关的致使处理器 cache 失效后重新从内存加载的指令。
备注:在某些处理器架构下,gcc 确实有提供一些特殊的编译选项允许绕过 CPU cache 直接对内存进行读写,可参考 gcc man 手册“-mcache-volatile”、“-mcache-bypass”选项的描述。
想了解下 CC++ 中 volatile 的真实设计“意图”,然后,在 stack overflow 上我又找到了这样一个回答:https://stackoverflow.com/a/12878500,重点内容已加粗显示。
[Nicol Bolas](https://stackoverflow.com/use…:
What volatile tells the compiler is that it can’t optimize memory reads from that variable. However, CPU cores have different caches, and most memory writes do not immediately go out to main memory. They get stored in that core’s local cache, and may be written… eventually.**CPUs have ways to force cache lines out into memory and to synchronize memory access among different cores. These memory barriers allow two threads to communicate effectively. Merely reading from memory in one core that was written in another core isn’t enough; the core that wrote the memory needs to issue a barrier, and the core that’s reading it needs to have had that barrier complete before reading it to actually get the data.
volatile guarantees none of this. Volatile works with “hardware, mapped memory and stuff” because the hardware that writes that memory makes sure that the cache issue is taken care of. If CPU cores issued a memory barrier after every write, you can basically kiss any hope of performance goodbye. So C++11 has specific language saying when constructs are required to issue a barrier.

Dietmar Kühl 回答中提到:
The volatile keyword has nothing to do with concurrency in C++ at all! It is used to have the compiler prevented from making use of the previous value, i.e., the compiler will generate code accessing a volatile value every time is accessed in the code. The main purpose are things like memory mapped I/O. However, use of volatile has no affect on what the CPU does when reading normal memory: If the CPU has no reason to believe that the value changed in memory, e.g., because there is no synchronization directive, it can just use the value from its cache. To communicate between threads you need some synchronization, e.g., an std::atomic<T>, lock a std::mutex, etc.
最后看了标准委员会对 volatile 的讨论:http://www.open-std.org/jtc1/… 简而言之,就是 CC++ 中当然也想提供 java 中 volatile 一样的线程可见性、阻止指令重排序,但是考虑到现有代码已经那么多了,突然改变 volatile 的语义,可能会导致现有代码的诸多问题,所以必须要再权衡一下,到底值不值得为 volatile 增加上述语义,当前 C ++ 标准委员会建议不改变 volatile 语义,而是通过新的 std::atmoic 等来支持上述语义。
结合自己的实际操作、他人的回答以及 CC++ 相关标准的描述,我认为 CC++ volatile 确实不能保证线程可见性。但是由于历史的原因、其他语言的影响、开发者自己的误解,这些共同导致开发者赋予了 CC++ volatile 很多本不属于它的能力,甚至大错特错,就连 Linus Torvards 也在内核文档中描述 volatile 时说,建议尽量用 memory barrier 替换掉 volatile,他认为几乎所有可能出现 volatile 的地方都可能会潜藏着一个 bug,并提醒开发者一定小心谨慎。
6. 实践中如何操作

开发者应该尽量编写可移植的代码,像 x86 这种强一致 CPU,虽然结合 volatile 也可以保证线程可见性,但是既然提供了类似 memory barrier()、std::atomic 等更加靠谱的用法,为什么要编写这种兼顾 volatile、x86 特性的代码呢?
开发者应该编写可维护的代码,对于这种容易引起开发者误会的代码、特性,应该尽量少用,这虽然不能说成是语言设计上的缺陷,但是确实也不能算是一个优势。

凡事都没有绝对的,用不用 volatile、怎么用 volatile 需要开发者自己权衡,本文的目的主要是想总结 CC++ volatile 的“能”与“不能”以及背后的原因。由于个人认识的局限性,难免会出现错误,也请大家指正。

正文完
 0