• 并行编程之多线程共享非volatile变量,会不会可能导致线程while死循环


    背景

    大家都知道线程之间共享变量要用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
    ");
    	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

  • 相关阅读:
    HDU 5716 带可选字符的多字符串匹配(ShiftAnd)
    挑战程序设计竞赛 2.2 一往直前!贪心法
    再叩首
    2016 Multi-University Training Contest 1 总结
    2016 Multi-University Training Contest 2 总结
    2016 Multi-University Training Contest 3 总结
    2016 Multi-University Training Contest 4 总结
    2016 Multi-University Training Contest 5&6 总结
    2016 Multi-University Training Contest 7 总结
    2016 Multi-University Training Contest 8 总结
  • 原文地址:https://www.cnblogs.com/zfyouxi/p/3830045.html
Copyright © 2020-2023  润新知