大家都知道線程之間共享變量要用volatile關鍵字。但是,如果不用volatile來標識,會不會導致線程死循環?比如下面的偽代碼:
static int flag = -1; void thread1(){ while(flag > 0){ //wait or do something } } void thread2(){ //do something flag = -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:
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)
相當於生成了這樣的代碼:
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秒之後,退出了。
說明,主線程並沒有一直讀取到舊的共享變量的值,符合預期。
給" 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放的正是內存的數據,像
movl _ZL3vvv(%rip), %eax
這樣的指令,是會先從CPU Cache裡查找,如果沒有的話,再通過總線到內存裡讀取。
而現代CPU有多核,通常來說每個核的L1, L2 Cache是不共享的,L3 Cache是共享的。
那麼問題就變成了:線程A修改了Cache中的內容,線程B是否會一直讀取到的都是舊數據?
既然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都有亂序執行能力,從上面加了volatile之後生成的匯編代碼來看,沒有什麼特別的地方。那麼它對於CPU亂序執行也是無能為力的。比如:
volatile static int flag = -1; void thread1(){ ... jobA(); flag = 1; } void thread2(){ ... while(1){ if(flag > 0) jobB(); } }
因為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,比如這種隨機獲取資源的代碼:
ramdonArray[10]; int pos = 0; Resource getResource(){ return ramdonArray[pos++%10]; }
為什麼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