背景

大家都知道线程之间共享变量要用volatilekeyword。可是,假设不用volatile来标识,会不会导致线程死循环?比方以下的伪代码:

static int flag = -1;
void thread1(){
while(flag > 0){
//wait or do something
}
}
void thread2(){
//do something
flag = -1;
}

线程1,线程2同一时候执行,线程2退出之后,线程1会不会有可能由于缓存等原因,一直死循环?

真实的世界

第一个坑:不靠谱的编绎器

直接上代码:

#include <pthread.h>
#include <unistd.h>
#include <stdio.h> static int vvv = 1;
void* thread1(void *){
sleep(2);
printf("sss\n");
vvv = -1;
return NULL;
}
int main() {
pthread_t t;
int re = pthread_create(&t, NULL, &thread1, NULL);
if(re < 0){
perror("thread");
}
while(vvv > 0){
// sleep(1);
}
return 0;
}

在main函数里启动了一个线程thread1,thread1会等待一段时间后改动vvv = -1,然后当vvv > 0时,主线程会一直while循环等待。

理想的情况下是这种:

主线程死循环等待,2秒之后thread1输出"sss",thread1退出,主线程退出。

保存为thread-study.c 文件,直接用gcc -O3 优化:

gcc thread-study.c -O3  -pthread -gstabs

再运行 ./a.out,能够发现控制台输出“sss”之后,会一直等待,再查看CPU使用率,一个核跑满了,说明主线程在死循环。

貌似就像上面所的,主线程由于缓存的原因,导致读取的 vvv 变量一直是旧的,从而死循环了。

可是否真的如此?

经过測试,除了O0级别(即全然不优化)不死循环外,O1,O2,O3级别,都会死循环。

再查看下O3级别的汇编代码(用 gcc -S thread-study.c 生成),main函数部分是这种:

为了便于查看,手动加了凝视。

main:
.LFB56:
.cfi_startproc
subq $24, %rsp
.cfi_def_cfa_offset 32
xorl %ecx, %ecx
xorl %esi, %esi
movl $_Z7thread1Pv, %edx
movq %rsp, %rdi
call pthread_create //int re = pthread_create(&t, NULL, &thread1, NULL);
testl %eax, %eax
js .L9
.L4:
movl _ZL3vvv(%rip), %eax //while(vvv > 0){
testl %eax, %eax
jle .L5
<strong>.L6:
jmp .L6</strong>
.p2align 4,,10
.p2align 3
.L5:
xorl %eax, %eax
addq $24, %rsp
.cfi_remember_state
.cfi_def_cfa_offset 8
ret
.L9:
.cfi_restore_state
movl $.LC1, %edi
call perror //perror("thread");
jmp .L4
.cfi_endproc

在L6标号那里,比較奇怪:

.L6:

jmp .L6

这里明显就是死循环,根本没有去尝试读取xxx的值。那么L4那个标号又是怎么回事?L4的代码是读取 vvv 变量再推断。可是它为什么没有在循环里?

再用gdb从汇编调试下,发现主线程的确是运行了死循环:

   0x0000000000400609 <+25>:    mov    0x200a51(%rip),%eax        # 0x601060 <_ZL3vvv>
0x000000000040060f <+31>: test %eax,%eax
0x0000000000400611 <+33>: jle 0x400618 <main+40>
<strong>=> 0x0000000000400613 <+35>: jmp 0x400613 <main+35></strong>
0x0000000000400615 <+37>: nopl (%rax)

一个jmp指令原地跳转,自然是一个死循环,正相应上面汇编代码的L6部分。

相当于生成了这种代码:

	if(vvv > 0){
goto return
}
for(;;){
}

可见gcc生成的代码有问题,它根本就没有生成正确的汇编代码。虽然这样的优化是符合规范的,但我个人比較反感这样的严重违反直觉的优化。

那么我们的问题还没有解决,接下来改动汇编代码,让它真正的像这样所预期的那样工作。仅仅要简单地把L6的jmp跳转到L4上:

.L4:
movl _ZL3vvv(%rip), %eax
testl %eax, %eax
jle .L5
.L6:
jmp .L4
.p2align 4,,10
.p2align 3

这个才我们真正预期的代码。

再測试下这个改动过后的代码:

gcc thread-study.s -o test -pthread -gstabs -O3
./test

运行2秒之后,退出了。

说明,主线程并没有一直读取到旧的共享变量的值,符合预期。

加上volatile

给" vvv "变量加上volatile,即:

volatile static int vvv = 1;

又一次编绎后,再跑下,发现正常了,2秒后进程退出。

查看下汇编代码,是这种:

.L5:
movl _ZL3vvv(%rip), %eax
testl %eax, %eax
setg %al
testb %al, %al
jne .L5

这段汇编代码符合预期。

可是这里还是有点不正确,volatile的特殊性在哪里?生成的汇编没有什么特别的指令,那它是怎样“防止”了线程不缓存共享变量的?

网上流传的一种说法是使用volatilekeyword之后,读取数据一定从内存中读取。

这样的说法既是对的,也是错的。volatilekeyword防止了编绎器优化,所以对于变量不会被放到寄存器里,或者被优化掉。可是volatile并不能防止CPU从Cache中读取数据。

所谓的“缓存”究竟是什么

CPU内部有寄存器,有各级Cache,L1,L2,L3。我们来考虑下究竟如何才会出现线程共享变量被放到CPU的寄存器或者各级Cache的情况。

volatile阻止了编绎器把变量放到寄存器里,那么对线程共享变量的读取即直接的内存訪问。

CPU Cache

CPU Cache放的正是内存的数据,像

movl _ZL3vvv(%rip), %eax

这种指令,是会先从CPU Cache里查找,假设没有的话,再通过总线到内存里读取。

而现代CPU有多核,通常来说每一个核的L1, L2 Cache是不共享的,L3 Cache是共享的。

那么问题就变成了:线程A改动了Cache中的内容,线程B是否会一直读取到的都是旧数据?

MESI协议

既然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乱序运行

现代非常多CPU都有乱序运行能力,从上面加了volatile之后生成的汇编代码来看,没有什么特别的地方。那么它对于CPU乱序运行也是无能为力的。比方:

volatile static int flag = -1;
void thread1(){
...
jobA();
flag = 1;
}
void thread2(){
...
while(1){
if(flag > 0)
jobB();
}
}

对于这两个线程,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:

	while(true){
__sync_fetch_and_add(&vvv,0);
if(vvv < 0 )
break;
}

再查看下生成的汇编代码:

.L4:
<strong>lock addl $0, _ZL3vvv(%rip)</strong>
movl _ZL3vvv(%rip), %eax
shrl $31, %eax
testb %al, %al
je .L5
jmp .L8
.L5:
jmp .L4

能够看到,加多了一条 lock addl 的指令。

这个lock,实际上是一个指令前缀,它保证了当前操作的Cache Line是处于Exclusive状态,并且保证了指令的顺序性。这个指令有可能是通过锁总线来实现的,可是假设总线已经被锁住了,那么仅仅会消耗后缀指令的时间。

实际上Java里的volatile就是在前面加了一个lock add指令实现的。这个有空再写。

其他的一些东东

有些场景能够不用volatile

抛开上面的讨论,事实上有些场景能够不使用volatile,比方这样的随机获取资源的代码:

ramdonArray[10];
int pos = 0;
Resource getResource(){
return ramdonArray[pos++%10];
}

这种代码pos是非volatile,但多线程调用getResource()函数全然没有问题。

C11与C++11

为什么C11和C++11不把volatile升级为java/C#那样的语义?我猜可能是所谓的“兼容性”问题。。蛋疼

C++11提供了Atomic相关的操作,语义和Java里的volatile差点儿相同。可是C11仍然没有什么好的办法,貌似仅仅能用GCC内置函数,或者写一些类似的汇编的宏了。

http://en.cppreference.com/w/cpp/atomic

GCC优化的一些东东

事实上在讨论的代码里,假设while循环里多一些代码,GCC可能就分辨不出能否优化了

优化的一些东东:

比方,在大部分语言里(特别是动态语言),第一份代码要比第二份代码要高效得多。

//1
int len = array.length;
for(int i = 0; i < len; ++i){
}
//2
for(int i = 0; i < array.length; ++i){
}

总结:

回到最初的问题:多线程共享非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

http://gcc.gnu.org/onlinedocs/gcc-4.6.2/gcc/Atomic-Builtins.html

http://en.cppreference.com/w/cpp/atomic

并行编程之多线程共享非volatile变量,会不会可能导致线程while死循环的更多相关文章

  1. 多线程同步工具——volatile变量

    关于volatile,找了一堆资料看,看完后想找一个方法去做测试,测了很久,感觉跟没有一样. 这本书<深入理解Java内存模型>,对volatile描述中有这样一个比喻的说法,如下代码所示 ...

  2. Java多线程 -- 正确使用Volatile变量

    Java 语言中的 volatile 变量可以被看作是一种 “程度较轻的 synchronized”:与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少, ...

  3. python编程系列---多线程共享全局变量出现了安全问题的解决方法

    多线程共享全局变量出现了安全问题的解决方法 当多线程共享全局变量时,可能出现安全问题,解决机制----互斥锁:即在在一段与全局变量修改相关的代码中,假设一个时间片不足以完成全局变量的修改,就在这段代码 ...

  4. 【Java并发编程】:加锁和volatile变量

    加锁和volatile变量两者之间的区别: 1.volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比syn ...

  5. 7.Python网络编程_多线程共享全局变量问题

    Python多线程支持全局变量的共享操作,但是它存在很多问题,先来看以下程序,该程序理论上执行完毕后全局变量g_num的值应该是2000000,但是在实际运行中,结果不足理论值 import thre ...

  6. Java并发编程、内存模型与Volatile

    http://www.importnew.com/24082.html  volatile关键字 http://www.importnew.com/16142.html  ConcurrentHash ...

  7. [Java并发编程(三)] Java volatile 关键字介绍

    [Java并发编程(三)] Java volatile 关键字介绍 摘要 Java volatile 关键字是用来标记 Java 变量,并表示变量 "存储于主内存中" .更准确的说 ...

  8. JAVA并发编程:相关概念及VOLATILE关键字解析

    一.内存模型的相关概念 由于计算机在执行程序时都是在CPU中运行,临时数据存在主存即物理内存,数据的读取和写入都要和内存交互,CPU的运行速度远远快于内存,会大大降低程序执行的速度,于是就有了高速缓存 ...

  9. Python3 系列之 并行编程

    进程和线程 进程是程序运行的实例.一个进程里面可以包含多个线程,因此同一进程下的多个线程之间可以共享线程内的所有资源,它是操作系统动态运行的基本单元:每一个线程是进程下的一个实例,可以动态调度和独立运 ...

随机推荐

  1. js快速打印一个五分制(五颗星)的评分情况

    1.函数 下面这个函数实现了在html页面中快速打印一个五分制(五颗星)的评分情况: function getRating(rating) { if(rating > 5 || rating & ...

  2. The only legal comparisons are between two numbers, two strings, or two dates.

    The only legal comparisons are between two numbers, two strings, or two dates. Left  hand operand is ...

  3. 对于requirejs AMD模块加载的理解

    个人人为使用模块化加载的优点有三: 1,以我的项目为例:90%为图表展示,使用的是echarts,此文件较大,requirejs可以在同一个版本号(urlArgs)之下缓存文件,那么我就可以在访问登陆 ...

  4. Js计算-当月每周有多少天

    查看Demo: 源代码如下: <script> //计算当月总天数 function getCountDays() { var curDate = new Date(); /* 获取当前月 ...

  5. jquery的一些select操作小记

    添加option $("#ID option").each(function(){ if($(this).val() == 111){ $(this).remove(); } }) ...

  6. (二)学习CSS之cursor属性

    参考:http://www.w3school.com.cn/tiy/t.asp?f=csse_zindex cursor 属性规定要显示的光标的类型(形状). <html> <bod ...

  7. 从ramdisk根文件系统启动Linux成功,及使用initramfs启动linux

    下面两篇文章是ARM9论坛上的讲解ramdisk文件系统的很不错的文章 今天做了个试验,让Linux2.6.29.4从ramdisk根文件系统启动成功,总结一下. 其中涉及的内容较多,很多东西不再详述 ...

  8. Win7下的DragEnter、DragDrop事件不触发的解决方案

    Win7与原来的XP和Win2003相比,安全控制方面更严格.比如,当我们以administrator登陆XP或Win2003时,运行所有的程序即是以管理员的身份启动的.但当以administrato ...

  9. POJ 2001-Shortest Prefixes(Trie 入门)

    题意:给你一组串,求每个串在组中唯一标志的最短前缀 分析:保存树上经过各节点的单词个数,扫描每个串当经过节点单词个数是一(能唯一标志)结束 #include <map> #include ...

  10. HDU 1011-Starship Troopers(树形背包)

    题意: 有n个洞,连接像一棵树,每个包含一定数量的怪和价值,给你m个士兵,每个士兵能打20个怪,杀完一个洞的怪可得该洞的价值才可继续打相连的下面的洞(每个士兵只能打一个洞),求获得的最大价值. 分析: ...