以下文字摘自《Windows 併發編程指南》,版權歸原作者所有,僅供學習和研究參考。
int *a = &b;(b假設爲一個局部int變量)
(*a)++;
在編譯器把以上語句翻譯爲機器代碼時,將包含多條機器指令,用匯編表示如下:
MOV EAX, [a]
INC EAX
MOV [a],EAX
從中我們看出: 在第一條機器指令中將對a執行解引用操作以獲得一個虛擬內存地址,並且把從這個地址開始的四個字節複製到寄存器EAX中。接下來,機器指令將遞增EAX中的值,最後將遞增好a的值從EAX複製回a指向的地址。
但是,請注意,我們從C++源文件中根本無法看到在++運算中包含的這些機器執行步驟。在現代處理器中通常能夠保證: 如果在遞增運算中對內存的讀取和寫入等操作是以機器字節大小爲單位(即CPU的內存尋址單位),那麼將以原子方式來執行這些操作。例如在32位機器上執行32位值的遞增操作,以及在64位機器上執行64位值的遞增操作等。相反,如果在讀取或者寫入數據時使用的字節數大於CPU的內存尋址單位,那麼這些操作就是非原子的。例如,如果在32位機器上寫入一個64位值,那麼這個操作就需要兩條mov指令將這個值從處理器的私有內存移動到公有內存,其中每條指令複製4個字節。同樣,如果在未對齊的地址(即地址的範圍至少跨越了一個內存尋址單位)上執行讀取操作或者寫入操作,那麼也需要執行多次內存操作,此外還可能需要一些位掩碼操作和移動操作,即使這個值所佔的內存空間小於或者等於機器的內存尋址單位也是如此。
那麼,以上非原子運算在併發執行會帶來哪些問題呢?請看解釋:
遞增運算的含義是,將在某個內存位置上的值單調地增加1.如果對初始值爲0的計數器執行三次遞增運算,那麼得到的結果應該爲3。下一次讀取到的值應該比前一次讀取到的值要大;因此,如果線程執行了兩次(*a)++操作,並且兩次操作依次執行,那麼執行第二次操作之後的值總是大於執行第一次操作之後的值。這沒有什麼問題,符合我們的編程邏輯。但是,請注意,在多線程執行環境中,以上邏輯不能保證前後兩次操作的結果相差爲1;因爲另一個線程非常有可能會在兩次操作之間插入進來並執行另一次遞增操作。
我們可以假設有三個線程t1,t2和t3同時執行編譯之後的機器指令。
t1 t2 t3
t1(0):MOV EAX,[a] t2(0):MOV EAX,[a] t3(0):MOV EAX,[a]
t1(1):INC EAX t2(1):INC,EAX t3(1):INC,EAX
t1(2):MOV [a],EAX t2(2):MOV [a],EAX t3(2):MOV [a],EAX
以上每個線程都在單獨的處理器上運行。這意味着每個處理器都擁有自己私有的EAX寄存器,但所有線程看到的a值都是相同的並且指向同一塊共享內存。具體這三個線程在時間上是如何更替運行,以及各指令間執行順序如何,我們可以通過時間刻度來分析這個併發執行的行爲。注意:這三條指令不會真正地“同時”執行。雖然處理器可以同時執行多條指令,但有一點非常重要,即帶有緩存一致性機制的共享內存系統將確保一致的內存全局視圖。因此,我們可以以一種簡單的、串行的時間刻度來描述程序的執行流程。
每一行序號之前的數字表示時間,橫座標以#n的形式表示相應指令之後a的值。
Time t1 t2 t3
0 t1(0):MOV EAX, [a] #0
1 t1(1):INC,EAX #1
2 t1(2):MOV [a], EAX #1
3 t2(0):MOV EAX, [a] #1
4 t2(1):INC,EAX #2
5 t3(2):MOV [a],EAX #2
6 t3(0):MOV EAX,[a] #2
7 t3(1):INC,EAX #3
8 t3(2):MOV [a],EAX #3
在多線程環境中運行,以上執行結果和執行流程符合我們的編程邏輯。但是一旦操作系統在中間進行了線程切換,以上這段代碼很有可能出現錯誤!爲了分析方便,我們暫時先忽略線程t3,看看代碼流。
Time t1 t2
0 t1(0): MOV EAX, [a] #0
1 t2(0):MOV EAX, [a] #0
2 t2(1):INC, EAX #1
3 t2(2):MOV [a], EAX #1
4 t1(1):INC, EAX #1
5 t1(2):MOV [a], EAX #1
邏輯上講,我們希望代碼執行完第5步時,a=2纔對。因爲 每個線程對同一塊內存進行了一次遞增操作。但爲什麼會出現上面這種情況呢? 因爲 線程 t1 和 t2分別將共享內存中的值複製到各自私有的寄存器中,因此這兩個線程看到的是同一個值 0, 然後他們將對各自私有的副本執行遞增操作。接下來,這兩個線程將遞增之後的新值複製到共享內存中,此時並沒有執行某種驗證或者同步操作來防止覆蓋對方的值。這兩個線程在各自寄存器中的值都是1,而並不知道彼此的存在。這樣,在上面的情況中,t1 就用1覆蓋了t2之前在共享位置上寫入的值1.
有了上面的分析,我們可以將t3也加入進來,看看可能會出現的代碼流及其後果。
Time t1 t2 t3
0 t3(0):MOV EAX,[a] #0
1 t1(0):MOV EAX, [a] #0
2 t1(1):INC,EAX #1
3 t1(2):MOV [a], EAX #1
4 t2(0):MOV EAX, [a] #1
5 t2(1):INC, EAX #2
6 t2(2):MOV [a], EAX #2
7 t3(1):INC, EAX #1
8 t3(2):MOV [a],EAX #1
程序邏輯完全亂了!這就是所謂的數據競爭問題,那有什麼解決方案沒?當然有,信號量(Semaphore),互斥鎖(Mutex),臨界區(Critical Section)等核心數據結構的引入,就是用來解決共享內存數據競爭帶來的問題。
從以上分析,我們得出以下幾個關鍵知識點:
1. 每個線程對共享位置上的值都各自擁有一個私有副本。
2. 線程將運算結果寫回到共享內存中,並在這個過程中覆蓋了其他線程寫入的值。
3. 爲個建立或者維持多個獨立共享位置之間的不變性,我們可能需要複雜的更新操作。
4. 多個線程併發運行,從而導致了運行時間的重疊以及彼此執行操作的相互干擾。