來(lái)源:Guancheng (G.C.)
0. 背景
原子操作就是不可再分的操作。在多線程程序中原子操作是一個(gè)非常重要的概念,它常常用來(lái)實(shí)現(xiàn)一些同步機(jī)制,同時(shí)也是一些常見(jiàn)的多線程Bug的源頭。
本文主要討論了三個(gè)問(wèn)題:
1. 多線程程序中對(duì)變量的讀寫(xiě)操作是否是原子的?
2. 多線程程序中對(duì)Bit field(位域)的讀寫(xiě)操作是否是線程安全的?
3. 程序員該如何使用原子操作?
我們先從一道很熱門的百度筆試題講起。很多人講不清楚其背后的原理,下面我們就來(lái)對(duì)它進(jìn)行一下剖析(其實(shí)這個(gè)題目有點(diǎn)歧義,后面我們會(huì)講到):
要徹底理解這個(gè)問(wèn)題,我們首先需要從硬件講起。以常見(jiàn)的X86 CPU來(lái)說(shuō),根據(jù)Intel的參考手冊(cè),它基于以下三種機(jī)制保證了多核中加鎖的原子操作(8.1節(jié)):
(1)Guaranteed atomic operations (注:8.1.1節(jié)有詳細(xì)介紹)
(2)Bus locking, using the LOCK# signal and the LOCK instruction prefix
(3)Cache coherency protocols that ensure that atomic operations can be carried out on cached data structures (cache lock); this mechanism is present in the Pentium 4, Intel Xeon, and P6 family processors
這三個(gè)機(jī)制相互獨(dú)立,相輔相承。簡(jiǎn)單的理解起來(lái)就是:
(1)一些基本的內(nèi)存讀寫(xiě)操作是本身已經(jīng)被硬件提供了原子性保證(例如讀寫(xiě)單個(gè)字節(jié)的操作);
(2)一些需要保證原子性但是沒(méi)有被第(1)條機(jī)制提供支持的操作(例如read-modify-write)可以通過(guò)使用”LOCK#”來(lái)鎖定總線,從而保證操作的原子性
(3)因?yàn)楹芏鄡?nèi)存數(shù)據(jù)是已經(jīng)存放在L1/L2 cache中了,對(duì)這些數(shù)據(jù)的原子操作只需要與本地的cache打交道,而不需要與總線打交道,所以CPU就提供了cache coherency機(jī)制來(lái)保證其它的那些也cache了這些數(shù)據(jù)的processor能讀到最新的值。
那么CPU對(duì)哪些(1)中的基本的操作提供了原子性支持呢?根據(jù)Intel手冊(cè)8.1.1節(jié)的介紹:
從Intel486 processor開(kāi)始,以下的基本內(nèi)存操作是原子的:
? Reading or writing a byte(一個(gè)字節(jié)的讀寫(xiě))
? Reading or writing a word aligned on a 16-bit boundary(對(duì)齊到16位邊界的字的讀寫(xiě))
? Reading or writing a doubleword aligned on a 32-bit boundary(對(duì)齊到32位邊界的雙字的讀寫(xiě))
從Pentium processor開(kāi)始,除了之前支持的原子操作外又新增了以下原子操作:
? Reading or writing a quadword aligned on a 64-bit boundary(對(duì)齊到64位邊界的四字的讀寫(xiě))
? 16-bit accesses to uncached memory locations that fit within a 32-bit data bus(未緩存且在32位數(shù)據(jù)總線范圍之內(nèi)的內(nèi)存地址的訪問(wèn))
從P6 family processors開(kāi)始,除了之前支持的原子操作又新增了以下原子操作:
? Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache line(對(duì)單個(gè)cache line中緩存地址的未對(duì)齊的16/32/64位訪問(wèn))
需要注意的是盡管從P6 family開(kāi)始對(duì)一些非對(duì)齊的讀寫(xiě)操作已經(jīng)提供了原子性保障,但是非對(duì)齊訪問(wèn)是非常影響性能的,需要盡量避免。當(dāng)然了,對(duì)于一般的程序員來(lái)說(shuō)不需要太擔(dān)心這個(gè),因?yàn)榇蟛糠志幾g器會(huì)自動(dòng)幫你完成內(nèi)存對(duì)齊。
回到最開(kāi)始那個(gè)筆試題。我們先反匯編一下看看它們到底執(zhí)行了什么操作:
x = y;
mov eax,dword ptr [y]
mov dword ptr [x],eax
x++;
mov eax,dword ptr [x]
add eax,1
mov dword ptr [x],eax
++x;
mov eax,dword ptr [x]
add eax,1
mov dword ptr [x],eax
x = 1;
mov dword ptr [x],1
(1)很顯然,x=1是原子操作。
因?yàn)閤是int類型,32位CPU上int占32位,在X86上由硬件直接提供了原子性支持。實(shí)際上不管有多少個(gè)線程同時(shí)執(zhí)行類似x=1這樣的賦值語(yǔ)句,x的值最終還是被賦的值(而不會(huì)出現(xiàn)例如某個(gè)線程只更新了x的低16位然后被阻塞,另一個(gè)線程緊接著又更新了x的低24位然后又被阻塞,從而出現(xiàn)x的值被損壞了的情況)。
(2)再來(lái)看x++和++x。
其實(shí)類似x++, x+=2, ++x這樣的操作在多線程環(huán)境下是需要同步的。因?yàn)閄86會(huì)按三條指令的形式來(lái)處理這種語(yǔ)句:從內(nèi)存中讀x的值到寄存器中,對(duì)寄存器加1,再把新值寫(xiě)回x所處的內(nèi)存地址(見(jiàn)上面的反匯編代碼)。
例如有兩個(gè)線程,它們按照如下順序執(zhí)行(注意讀x和寫(xiě)回x是原子操作,兩個(gè)線程不能同時(shí)執(zhí)行):
time Thread 1 Thread 2
0 load eax, x
1 load eax, x
2 add eax, 1 add eax, 1
3 store x, eax
4 store x, eax
我們會(huì)發(fā)現(xiàn)最終x的值會(huì)是1而不是2,因?yàn)門hread 1的結(jié)果被覆蓋掉了。這種情況下我們就需要對(duì)x++這樣的操作加鎖(例如Pthread中的mutex)以保證同步,或者使用一些提供了atomic operations的庫(kù)(例如Windows API中的atomic庫(kù),Linux內(nèi)核中的atomic.h,Java concurrent庫(kù)中的Atomic Integer,C++0x中即將支持的atomic_int等等,這些庫(kù)會(huì)利用CPU提供的硬件機(jī)制做一層封裝,提供一些保證了原子性的API)。
(3)最后來(lái)看看x=y。
在X86上它包含兩個(gè)操作:讀取y至寄存器,再把該值寫(xiě)入x。讀y的值這個(gè)操作本身是原子的,把值寫(xiě)入x也是原子的,但是兩者合起來(lái)是不是原子操作呢?我個(gè)人認(rèn)為x=y不是原子操作,因?yàn)樗皇遣豢稍俜值牟僮鳌5撬枰恍枰侥兀科鋵?shí)問(wèn)題的關(guān)鍵在于程序的上下文。
例如有兩個(gè)線程,線程1要執(zhí)行{y = 1; x = y;},線程2要執(zhí)行{y = 2; y = 3;},假設(shè)它們按如下時(shí)間順序執(zhí)行:
time Thread 1 Thread 2
0 store y, 1
1 store y, 2
2 load eax, y
3 store y, 3
4 store x, eax
那么最終線程1中x的值為2,而不是它原本想要的1。我們需要加上相應(yīng)的同步語(yǔ)句確保y = 2不會(huì)在線程1的兩條語(yǔ)句之間發(fā)生。y = 3那條語(yǔ)句盡管在load y和store x之間執(zhí)行,但是卻不影響x=y這條語(yǔ)句本身的語(yǔ)義。所以你可以說(shuō)x=y需要同步,也可以說(shuō)x=y不需要同步,看你怎么理解題意了。x=1是否需要同步也是一樣的道理,雖然它本身是原子操作,但是如果有另一個(gè)線程要讀x=1之后的值,那肯定也需要同步,否則另一個(gè)線程讀到的就是x的舊值而不是1了。
2. 對(duì)Bit field(位域)的讀寫(xiě)操作是否是線程安全的?
Bit field常用來(lái)高效的存儲(chǔ)有限位數(shù)的變量,多用于內(nèi)核/底層開(kāi)發(fā)中。一般來(lái)說(shuō),對(duì)同一個(gè)結(jié)構(gòu)體內(nèi)的不同bit成員的多線程訪問(wèn)是無(wú)法保證線程安全的。
例如Wikipedia中的如下例子:
struct foo {
int flag : 1;
int counter : 15;
};
struct foo my_foo;
/* ... */
/* in thread 1 */
pthread_mutex_lock(&my_mutex_for_flag);
my_foo.flag = !my_foo.flag;
pthread_mutex_unlock(&my_mutex_for_flag);
/* in thread 2 */
pthread_mutex_lock(&my_mutex_for_counter);
++my_foo.counter;
pthread_mutex_unlock(&my_mutex_for_counter);
兩個(gè)線程分別對(duì)my_foo.flag和my_foo.counter進(jìn)行讀寫(xiě)操作,但是即使有上面的加鎖方式仍然不能保證它是線程安全的。原因在于不同的成員在內(nèi)存中的具體排列方式“跟Byte Order、Bit Order、對(duì)齊等問(wèn)題都有關(guān),不同的平臺(tái)和編譯器可能會(huì)排列得很不一樣,要編寫(xiě)可移植的代碼就不能假定Bit-field是按某一種固定方式排列的”[3]。而且一般來(lái)講CPU對(duì)內(nèi)存操作的最小單位是word(X86的word是16bits),而不是1bit。這就是說(shuō),如果my_foo.flag和my_foo.counter存儲(chǔ)在同一個(gè)word里,CPU在讀寫(xiě)任何一個(gè)bit member的時(shí)候會(huì)同時(shí)把兩個(gè)值一起讀進(jìn)寄存器,從而造成讀寫(xiě)沖突。這個(gè)例子正確的處理方式是用一個(gè)mutex同時(shí)保護(hù)my_foo.flag和my_foo.counter,這樣才能確保讀寫(xiě)是線程安全的。
在C++0x草案中對(duì)bit field是這樣定義的:
連續(xù)的多個(gè)非0bit的bit fields是屬于同一個(gè)memory location的;長(zhǎng)度為0bit的bit field會(huì)把占單獨(dú)的一個(gè)memory location。對(duì)同一個(gè)memory location的讀寫(xiě)不是線程安全的;對(duì)不同memory location的讀寫(xiě)是線程安全的。
這里有一個(gè)因?yàn)锽it field不是線程安全所導(dǎo)致的一個(gè)Linux內(nèi)核中的Bug。
引用一下Pongba的總結(jié):
3. 程序員該怎么用Atomic操作?
一般情況下程序員不需要跟CPU提供的原子操作直接打交道,所以只需要選擇語(yǔ)言或者平臺(tái)提供的atomic API即可。而且使用封裝好了的API還有一個(gè)好處是它們常常還提供了諸如compare_and_swap,fetch_and_add這樣既有讀又有寫(xiě)的較復(fù)雜操作的封裝。
常見(jiàn)的API如下:
Windows上InterlockedXXXX的API
GNU/Linux上linux kernel中atomic_32.h
GCC中的Atomic Builtins (__sync_fetch_and_add()等)
Java中的java.util.concurrent.atomic
C++0x中的atomic operation
Intel TBB中的atomic operation
4. 參考文獻(xiàn):
[1] 關(guān)于變量操作的原子性(atomicity)FAQ
[2] http://en.wikipedia.org/wiki/Atomic_operation
[3] 關(guān)于內(nèi)存對(duì)齊、bit field等 –《Linux C編程一站式學(xué)習(xí)》
[4] Do you need mutex to protect an ‘int’?
[5] C++ Concurrency in Action
[6] Multithreaded simple data type access and atomic variables
往期推薦