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

背景

大家都知道线程之间共享变量要用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 <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的特殊性在哪里?生成的汇编没有什么特别的指令,那它是如何“防止”了线程不缓存共享变量的?

网上流传的一种说法是使用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协议里,每行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

时间: 2024-11-01 03:53:29

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

深入探讨Java多线程中的volatile变量_java

volatile 变量提供了线程的可见性,并不能保证线程安全性和原子性. 什么是线程的可见性: 锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility).互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据.可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 -- 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前

Java并发编程:从根源上解析volatile关键字的实现

Java并发编程:volatile关键字解析 1.解析概览 内存模型的相关概念 并发编程中的三个概念 Java内存模型 深入剖析volatile关键字 使用volatile关键字的场景 2.内存模型的相关概念 缓存一致性问题.通常称这种被多个线程访问的变量为共享变量. 也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题. 为了解决缓存不一致性问题,通常来说有以下2种解决方法: 通过在总线加LOCK#锁的方式 通过缓存一致性协议 这2种方式

一起谈.NET技术,.NET并行(多核)编程系列之七 共享数据问题和解决概述

之前的文章介绍了了并行编程的一些基础的知识,从本篇开始,将会讲述并行编程中实际遇到一些问题,接下来的几篇将会讲述数据共享问题. 本篇的议题如下: 1.数据竞争 2.解决方案提出 3.顺序的执行解决方案 4.数据不变解决方案 在开始之前,首先,我们来看一个很有趣的例子: class BankAccount { public int Balance { get; set; } } class App { static void Main(string[] args) { // create the

.NET并行(多核)编程系列之七 共享数据问题和解决概述

前言:之前的文章介绍了了并行编程的一些基础的知识,从本篇开始,将会讲述并行编程中实际遇到一些问题,接下来的几篇将会讲述数据共享问题. 本篇的议题如下: 数据竞争 解决方案提出 顺序的执行解决方案 数据不变解决方案 在开始之前,首先,我们来看一个很有趣的例子: class BankAccount{ public int Balance { get; set; }}class App{ static void Main(string[] args) { // create the bank acco

C#并行编程-线程同步原语

原文:C#并行编程-线程同步原语 菜鸟学习并行编程,参考<C#并行编程高级教程.PDF>,如有错误,欢迎指正. 背景 有时候必须访问变量.实例.方法.属性或者结构体,而这些并没有准备好用于并发访问,或者有时候需要执行部分代码,而这些代码必须单独运行,这是不得不通过将任务分解的方式让它们独立运行. 当任务和线程要访问共享的数据和资源的时候,您必须添加显示的同步,或者使用原子操作或锁. 之前的.NET Framework提供了昂贵的锁机制以及遗留的多线程模型,新的数据结构允许细粒度的并发和并行化,

《深入理解并行编程》中文版

原文的下载地址:http://kernel.org/pub/linux/kernel/people/paulmck/perfbook/perfbook.html 中文版下载地址:深入理解并行编程V1.0 (4.1M) 本书是linux内核大牛paul的力作,和鲁阳同学一起,花了两个月时间进行翻译. 目前没有翻译问答部分,主要是时间不够,也担心不能将这部分翻译准确. 对内核深度发烧的同学可以看看. 本书目录 1. 简介----------------------------------- 14 1

Java理论与实践:正确使用Volatile变量

Java 语言中的 volatile 变量可以被看作是一种 "程度较轻的 synchronized":与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分.本文介绍了几种有效使用 volatile 变量的模式,并强调了几种不适合使用 volatile 变量的情形. 锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility).互斥即一次只允许一

Java 理论与实践: 正确使用 volatile 变量 线程同步

Java语言规范中指出:为了获得最佳速度,允许线程保存共享成员变量的私有拷贝,而且只当线程进入或者离开同步代码块时才与共享成员变量的原始值对比. 这样当多个线程同时与某个对象交互时,就必须要注意到要让线程及时的得到共享成员变量的变化. 而volatile关键字就是提示VM:对于这个成员变量不能保存它的私有拷贝,而应直接与共享成员变量交互. 使用建议:在两个或者更多的线程访问的成员变量上使用volatile.当要访问的变量已在synchronized代码块中,或者为常量时,不必使用. 由于使用vo

《OpenACC并行编程实战》—— 1.3 CUDA C

1.3 CUDA C 本节简要介绍CUDA C编程的相关概念,使读者能够看懂OpenACC编译过程中出现的CUDA内置变量,理解并行线程的组织方式.如果读者已有CUDA编程经验,请跳过. CPU用得好好的,为什么要费心费力地改写程序去到GPU上运行呢?只有一个理由:跑得更快.小幅的性能提升吸引力不够,必须有大幅提升才值得采购新设备.学习新工具.设计新算法.从图1.19可以看出,在双精度浮点峰值和内存带宽这两个关键指标上,GPU的性能都达到同时期主力型号CPU的5-7倍.如果利用得当,可以预期获得