首頁>技術>

0. 背景

原子操作 就是不可再分的操作。在多執行緒程式中原子操作是一個非常重要的概念,它常常用來實現一些同步機制,同時也是一些常見的多執行緒Bug的源頭。本文主要討論了三個問題:1. 多執行緒程式中對變數的讀寫操作是否是原子的?2. 多執行緒程式中對Bit field(位域)的讀寫操作是否是執行緒安全的?3. 程式設計師該如何使用原子操作?

1. 多執行緒環境下對變數的讀寫操作是否是原子的?

我們先從一道很熱門的百度筆試題講起。很多人講不清楚其背後的原理,下面我們就來對它進行一下剖析(其實這個題目有點歧義,後面我們會講到):

以下多執行緒對int型變數x的操作,哪幾個需要進行同步:( )A. x=y; B. x++; C. ++x; D. x=1;

要徹底理解這個問題,我們首先需要從硬體講起。以常見的X86 CPU來說,根據Intel的 參考手冊 ,它基於以下三種機制保證了多核中加鎖的原子操作(8.1節):

(1)Guaranteed atomic operations (注:8.1.1節有詳細介紹)

(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

這三個機制相互獨立,相輔相承。簡單的理解起來就是

(1)一些基本的記憶體讀寫操作是本身已經被硬體提供了原子性保證(例如讀寫單個位元組的操作);

(2)一些需要保證原子性但是沒有被第(1)條機制提供支援的操作(例如read-modify-write)可以透過使用”LOCK#”來鎖定匯流排,從而保證操作的原子性

(3)因為很多記憶體資料是已經存放在L1/L2 cache中了,對這些資料的原子操作只需要與本地的cache打交道,而不需要與匯流排打交道,所以CPU就提供了cache coherency機制來保證其它的那些也cache了這些資料的processor能讀到最新的值(關於cache coherency可以參加我的一篇博文)。

那麼CPU對哪些(1)中的基本的操作提供了原子性支援呢?根據Intel手冊8.1.1節的介紹:

從Intel486 processor開始,以下的基本記憶體操作是原子的:

• Reading or writing a byte(一個位元組的讀寫)

• Reading or writing a word aligned on a 16-bit boundary(對齊到16位邊界的字的讀寫)

• Reading or writing a doubleword aligned on a 32-bit boundary(對齊到32位邊界的雙字的讀寫)

從Pentium processor開始,除了之前支援的原子操作外又新增了以下原子操作:

• Reading or writing a quadword aligned on a 64-bit boundary(對齊到64位邊界的四字的讀寫)

• 16-bit accesses to uncached memory locations that fit within a 32-bit data bus(未快取且在32位資料匯流排範圍之內的記憶體地址的訪問)

從P6 family processors開始,除了之前支援的原子操作又新增了以下原子操作:• Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache line(對單個cache line中快取地址的未對齊的16/32/64位訪問)

那麼哪些操作是非原子的呢?

Accesses to cacheable memory that are split across bus widths, cache lines, and

page boundaries are not guaranteed to be atomic by the Intel Core 2 Duo, Intel®

Atom™, Intel Core Duo, Pentium M, Pentium 4, Intel Xeon, P6 family, Pentium, and

Intel486 processors.(說點簡單點,那些被匯流排頻寬、cache line以及page大小給分隔開了的記憶體地址的訪問不是原子的,你如果想保證這些操作是原子的,你就得求助於機制(2),對匯流排發出相應的控制訊號才行)。

需要注意的是儘管從P6 family開始對一些非對齊的讀寫操作已經提供了原子性保障,但是非對齊訪問是非常影響效能的,需要儘量避免。當然了,對於一般的程式設計師來說不需要太擔心這個,因為大部分編譯器會自動幫你完成記憶體對齊。

回到最開始那個筆試題。我們先反彙編一下看看它們到底執行了什麼操作:

x = y;mov eax,dword ptr [y]mov dword ptr [x],eaxx++;mov eax,dword ptr [x]add eax,1mov dword ptr [x],eax++x;mov eax,dword ptr [x]add eax,1mov dword ptr [x],eaxx = 1;mov dword ptr [x],1

(1)很顯然,x=1是原子操作。因為x是int型別,32位CPU上int佔32位,在X86上由硬體直接提供了原子性支援。實際上不管有多少個執行緒同時執行類似x=1這樣的賦值語句,x的值最終還是被賦的值(而不會出現例如某個執行緒只更新了x的低16位然後被阻塞,另一個執行緒緊接著又更新了x的低24位然後又被阻塞,從而出現x的值被損壞了的情況)。

(2)再來看x++和++x。其實類似x++, x+=2, ++x這樣的操作在多執行緒環境下是需要同步的。因為X86會按三條指令的形式來處理這種語句:從記憶體中讀x的值到暫存器中,對暫存器加1,再把新值寫回x所處的記憶體地址(見上面的反彙編程式碼)。

例如有兩個執行緒,它們按照如下順序執行(注意讀x和寫回x是原子操作,兩個執行緒不能同時執行):

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

我們會發現最終x的值會是1而不是2,因為Thread 1的結果被覆蓋掉了。這種情況下我們就需要對x++這樣的操作加鎖(例如Pthread中的mutex)以保證同步,或者使用一些提供了atomic operations的庫(例如Windows API中的 atomic庫 ,Linux核心中的 atomic.h ,Java concurrent庫中的Atomic Integer,C++0x中即將支援的atomic_int等等,這些庫會利用CPU提供的硬體機制做一層封裝,提供一些保證了原子性的API)。

(3)最後來看看x=y。在X86上它包含兩個操作:讀取y至暫存器,再把該值寫入x。讀y的值這個操作本身是原子的,把值寫入x也是原子的,但是兩者合起來是不是原子操作呢?我個人認為x=y不是原子操作,因為它不是不可再分的操作。但是它需要不需要同步呢?其實問題的關鍵在於程式的上下文。

例如有兩個執行緒,執行緒1要執行{y = 1; x = y;},執行緒2要執行{y = 2; y = 3;},假設它們按如下時間順序執行:

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 = 2不會線上程1的兩條語句之間發生。y = 3那條語句儘管在load y和store x之間執行,但是卻不影響x=y這條語句本身的語義。所以你可以說x=y需要同步,也可以說x=y不需要同步,看你怎麼理解題意了。x=1是否需要同步也是一樣的道理,雖然它本身是原子操作,但是如果有另一個執行緒要讀x=1之後的值,那肯定也需要同步,否則另一個執行緒讀到的就是x的舊值而不是1了。

2. 對Bit field(位域)的讀寫操作是否是執行緒安全的?

Bit field 常用來高效的儲存有限位數的變數,多用於核心/底層開發中。一般來說,對同一個結構體內的不同bit成員的多執行緒訪問是無法保證執行緒安全的。

例如 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);

兩個執行緒分別對my_foo.flag和my_foo.counter進行讀寫操作,但是即使有上面的加鎖方式仍然不能保證它是執行緒安全的。原因在於不同的成員在記憶體中的具體排列方式“跟Byte Order、Bit Order、對齊等問題都有關,不同的平臺和編譯器可能會排列得很不一樣,要編寫可移植的程式碼就不能假定Bit-field是按某一種固定方式排列的”[3]。而且一般來講CPU對記憶體操作的最小單位是 word (X86的word是16bits),而不是1bit。這就是說,如果my_foo.flag和my_foo.counter儲存在同一個word裡,CPU在讀寫任何一個bit member的時候會同時把兩個值一起讀進暫存器,從而造成讀寫衝突。這個例子正確的處理方式是用一個mutex同時保護my_foo.flag和my_foo.counter,這樣才能確保讀寫是執行緒安全的。

在 C++0x草案 中對bit field是這樣定義的:

連續的多個非0bit的bit fields是屬於同一個memory location的;長度為0bit的bit field會把佔單獨的一個memory location。對同一個memory location的讀寫不是執行緒安全的;對不同memory location的讀寫是執行緒安全的。

例如在下圖的例子中bf1和bf2是同一個memory location,bf3是一個單獨的memory location,bf4是一個單獨的memory location:

這裡有一個因為Bit field不是執行緒安全所導致的一個Linux核心中的 Bug 。

引用一下Pongba的 總結 :

所以,如果你的多個bitfields是連續的,同時又想要無衝突的讀取它們,有兩種做法,一是在中間用0大小bitfield隔開,但這種做法實際上就消除了bitfield的節省記憶體的初衷,因為為了使它們不衝突,至少被隔開的兩個bitfield肯定不可能共享byte了。另一種做法當然就是用鎖了。

3. 程式設計師該怎麼用Atomic操作?

一般情況下程式設計師不需要跟CPU提供的原子操作直接打交道,所以只需要選擇語言或者平臺提供的atomic API即可。而且使用封裝好了的API還有一個好處是它們常常還提供了諸如compare_and_swap,fetch_and_add這樣既有讀又有寫的較複雜操作的封裝。

常見的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. 參考文獻:

[1] 關於變數操作的原子性(atomicity)FAQ

[2] http://en.wikipedia.org/wiki/Atomic_operation

[3] 關於記憶體對齊、bit field等 –《Linux C程式設計一站式學習》

[4] Do you need mutex to protect an ‘int’?

[5] C++ Concurrency in Action

[6] Multithreaded simple data type access and atomic variables

[6] http://www.newsmth.net/bbscon.php?bid=335&id=236629http://www.newsmth.net/bbscon.php?bid=335&id=209239http://www.newsmth.net/bbscon.php?bid=335&id=186723

32
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 題目不讓我幹什麼,我偏要幹什麼