大家都知道线程之间共享变量要用volatile关键字。但是,如果不用volatile来标识,会不会导致线程死循环?比如下面的伪代码:
线程1,线程2同时运行,线程2退出之后,线程1会不会有可能因为缓存等原因,一直死循环?
直接上代码:
在main函数里启动了一个线程thread1,thread1会等待一段时间后修改vvv = -1,然后当vvv > 0时,主线程会一直while循环等待。
理想的情况下是这样的:
主线程死循环等待,2秒之后thread1输出"sss",thread1退出,主线程退出。
保存为thread-study.c 文件,直接用gcc -o3 优化:
再执行 ./a.out,可以发现控制台输出“sss”之后,会一直等待,再查看cpu使用率,一个核跑满了,说明主线程在死循环。
貌似就像上面所的,主线程因为缓存的原因,导致读取的 vvv 变量一直是旧的,从而死循环了。
但是否真的如此?
经过测试,除了o0级别(即完全不优化)不死循环外,o1,o2,o3级别,都会死循环。
再查看下o3级别的汇编代码(用 gcc -s thread-study.c 生成),main函数部分是这样的:
为了便于查看,手动加了注释。
在l6标号那里,比较奇怪:
.l6:
jmp .l6
这里明显就是死循环,根本没有去尝试读取xxx的值。那么l4那个标号又是怎么回事?l4的代码是读取 vvv 变量再判断。但是它为什么没有在循环里?
再用gdb从汇编调试下,发现主线程的确是执行了死循环:
一个jmp指令原地跳转,自然是一个死循环,正对应上面汇编代码的l6部分。
相当于生成了这样的代码:
可见gcc生成的代码有问题,它根本就没有生成正确的汇编代码。尽管这种优化是符合规范的,但我个人比较反感这种严重违反直觉的优化。
那么我们的问题还没有解决,接下来修改汇编代码,让它真正的像这样所预期的那样工作。只要简单地把l6的jmp跳转到l4上:
这个才我们真正预期的代码。
再测试下这个修改过后的代码:
执行2秒之后,退出了。
说明,主线程并没有一直读取到旧的共享变量的值,符合预期。
给" vvv "变量加上volatile,即:
重新编绎后,再跑下,发现正常了,2秒后进程退出。
查看下汇编代码,是这样的:
这段汇编代码符合预期。
但是这里还是有点不对,volatile的特殊性在哪里?生成的汇编没有什么特别的指令,那它是如何“防止”了线程不缓存共享变量的?
网上流传的一种说法是使用volatile关键字之后,读取数据一定从内存中读取。
这种说法既是对的,也是错的。volatile关键字防止了编绎器优化,所以对于变量不会被放到寄存器里,或者被优化掉。但是volatile并不能防止cpu从cache中读取数据。
cpu内部有寄存器,有各级cache,l1,l2,l3。我们来考虑下到底怎样才会出现线程共享变量被放到cpu的寄存器或者各级cache的情况。
volatile阻止了编绎器把变量放到寄存器里,那么对线程共享变量的读取即直接的内存访问。
cpu cache放的正是内存的数据,像
movl _zl3vvv(%rip), %eax
这样的指令,是会先从cpu cache里查找,如果没有的话,再通过总线到内存里读取。
而现代cpu有多核,通常来说每个核的l1, l2 cache是不共享的,l3 cache是共享的。
那么问题就变成了:线程a修改了cache中的内容,线程b是否会一直读取到的都是旧数据?
既然cache数据会不一致,那么自然要有个机制,让它们之间重回一致。经典的cache一致性协议是mesi协议。
mesi协议是使用的是write back策略,即当一个核内的cache更新了,它只修改自己核内部的,并不是同步修改到其它核上。
在mesi协议里,每行cache line可以有4种状态:
modified 该cache line数据被修改,和内存中的不一致,数据只存储在本cache line里。
exclusive 该cache line数据和内存中的一致,数据只存在本cache line里。
shared 该cache line数据和内存中的一致,数据存在多个cache line里,随时会变成invalid状态。
invalid 该cache line数据无效(即不会再使用)
mesi协议里,状态的转换比较复杂,但是都和人的直觉一致。对于我们研究的问题而言,只需要知道:
当是shared状态的时,修改cache line的内容前,要先通过request for ownership (rfo)的方式广播通知其它核,把cache line置为invalid。
当是modified状态时,cache控制器会(snoop)拦截其它核对该cache line对应的内存地址的访问,在回应回插入当前cache line的数据。并把本cache line的内容回写到内存里,状态改为shared。
因此,并不会存在一个核内的cache数据修改了,另一个核没有感知的情况。
即不会出现线程a修改了cache中的内容,线程b一直读取到的都是旧数据的情况。考虑到cpu内部通迅都是很快的,本人估计线程a修改了共享变量,线程b读取到新值的时间应该是纳秒级之内。
现代很多cpu都有乱序执行能力,从上面加了volatile之后生成的汇编代码来看,没有什么特别的地方。那么它对于cpu乱序执行也是无能为力的。比如:
对于这两个线程,jobb()有可能比joba()先执行!
因为thread1里,可能会因为cpu乱序执行,先执行了flag = 1,再执行joba()。
那么如何防止这种情况?这个麻烦是cpu搞出来的,自然也是cpu提供的解决办法。
gcc内置了一些原子内存访问的函数,如:
http://gcc.gnu.org/onlinedocs/gcc-4.6.2/gcc/atomic-builtins.html
type __sync_fetch_and_add (type *ptr, type value, ...)
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
type __sync_fetch_and_and (type *ptr, type value, ...)
type __sync_fetch_and_xor (type *ptr, type value, ...)
type __sync_fetch_and_nand (type *ptr, type value, ...)
这些函数实际即隐含了memory barrier。
比如为之前讨论的代码加上memory barrier:
再查看下生成的汇编代码:
可以看到,加多了一条 lock addl 的指令。
这个lock,实际上是一个指令前缀,它保证了当前操作的cache line是处于exclusive状态,而且保证了指令的顺序性。这个指令有可能是通过锁总线来实现的,但是如果总线已经被锁住了,那么只会消耗后缀指令的时间。
实际上java里的volatile就是在前面加了一个lock add指令实现的。这个有空再写。
抛开上面的讨论,其实有些场景可以不使用volatile,比如这种随机获取资源的代码:
这样的代码pos是非volatile,但多线程调用getresource()函数完全没有问题。
为什么c11和c++11不把volatile升级为java/c#那样的语义?我猜可能是所谓的“兼容性”问题。。蛋疼
c++11提供了atomic相关的操作,语义和java里的volatile差不多。但是c11仍然没有什么好的办法,貌似只能用gcc内置函数,或者写一些类似的汇编的宏了。
http://en.cppreference.com/w/cpp/atomic
gcc优化的一些东东
其实在讨论的代码里,如果while循环里多一些代码,gcc可能就分辨不出是否能优化了
比如,在大部分语言里(特别是动态语言),第一份代码要比第二份代码要高效得多。
回到最初的问题:多线程共享非volatile变量,会不会可能导致线程while死循环?
其实这事要看很多别的东西的脸色。。编绎器的,cpu的,语言规范的。。
对于没有被编绎器优化掉的代码,cpu的cache一致性协议(典型mesi)保证了,不会出现死循环的情况。这个不是volatile的功劳,这个只是cpu内部的正常机制而已。
对于多线程同步程序,要小心地在合适的地方加上内存屏障(memory barrier)。
http://en.wikipedia.org/wiki/volatile_variable
http://en.wikipedia.org/wiki/mesi
http://en.wikipedia.org/wiki/write-back#write-back
http://en.wikipedia.org/wiki/bus_snooping
http://en.wikipedia.org/wiki/cpu_cache#multi-level_caches
http://blog.jobbole.com/36263/ 每个程序员都应该了解的 cpu 高速缓存
http://stackoverflow.com/questions/4232660/which-is-a-better-write-barrier-on-x86-lockaddl-or-xchgl
http://stackoverflow.com/questions/8891067/what-does-the-lock-instruction-mean-in-x86-assembly