背景
大家都知道线程之间共享变量要用volatile关键字。但是,如果不用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#include #include 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
.L6:
jmp .L6
.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=> 0x0000000000400613 <+35>: jmp 0x400613 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的特殊性在哪里?生成的汇编没有什么特别的指令,那它是如何“防止”了线程不缓存共享变量的?
网上流传的一种说法是使用volatile关键字之后,读取数据一定从内存中读取。
这种说法既是对的,也是错的。volatile关键字防止了编绎器优化,所以对于变量不会被放到寄存器里,或者被优化掉。但是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协议里,