程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 並行編程之多線程共享非volatile變量,會不會可能導致線程while死循環

並行編程之多線程共享非volatile變量,會不會可能導致線程while死循環

編輯:C++入門知識

背景

大家都知道線程之間共享變量要用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協議裡,每行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:
	lock addl	$0, _ZL3vvv(%rip)
	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

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved