一.記憶體模型的相關概念
大家都知道,電腦在執行程式時,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到資料的讀取和寫入。由於程式運行過程中的臨時資料是存放在主存(實體記憶體)當中的,這時就存在一個問題,由於CPU執行速度很快,而從記憶體讀取資料和向記憶體寫入資料的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令執行的速度。因此在CPU裡面就有了快取。
也就是,當程式在運行過程中,會將運算需要的資料從主存複製一份到CPU的快取當中,那麼CPU進行計算時就可以直接從它的快取讀取資料和向其中寫入資料,當運算結束之後,再將快取中的資料重新整理到主存當中。舉個簡單的例子,比如下面的這段代碼:
當線程執行這個語句時,會先從主存當中讀取i的值,然後複製一份到快取當中,然後CPU執行指令對i進行加1操作,然後將資料寫入快取,最後將快取中i最新的值重新整理到主存當中。
這個代碼在單線程中運行是沒有任何問題的,但是在多線程中運行就會有問題了。在多核CPU中,每條線程可能運行於不同的CPU中,因此每個線程運行時有自己的快取(對單核CPU來說,其實也會出現這種問題,只不過是以線程調度的形式來分別執行的)。本文我們以多核CPU為例。
比如同時有2個線程執行這段代碼,假如初始時i的值為0,那麼我們希望兩個線程執行完之後i的值變為2。但是事實會是這樣嗎。
可能存在下面一種情況:初始時,兩個線程分別讀取i的值存入各自所在的CPU的快取當中,然後線程1進行加1操作,然後把i的最新值1寫入到記憶體。此時線程2的快取當中i的值還是0,進行加1操作之後,i的值為1,然後線程2把i的值寫入記憶體。
最終結果i的值是1,而不是2。這就是著名的緩衝一致性問題。通常稱這種被多個線程訪問的變數為共用變數。
也就是說,如果一個變數在多個CPU中都存在緩衝(一般在多線程編程時才會出現),那麼就可能存在緩衝不一致的問題。
為瞭解決緩衝不一致性問題,通常來說有以下2種解決方案:
1)通過在匯流排加LOCK#鎖的方式
2)通過緩衝一致性協議
這2種方式都是硬體層面上提供的方式。
在早期的CPU當中,是通過在匯流排上加LOCK#鎖的形式來解決緩衝不一致的問題。因為CPU和其他組件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他組件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。比如上面例子中 如果一個線程在執行 i = i +1,如果在執行這段代碼的過程中,在匯流排上發出了LCOK#鎖的訊號,那麼只有等待這段代碼完全執行完畢之後,其他CPU才能從變數i所在的記憶體讀取變數,然後進行相應的操作。這樣就解決了緩衝不一致的問題。
但是上面的方式會有一個問題,由於在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下。
所以就出現了緩衝一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個緩衝中使用的共用變數的副本是一致的。它核心的思想是:當CPU寫資料時,如果發現操作的變數是共用變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的緩衝行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己緩衝中緩衝該變數的緩衝行是無效的,那麼它就會從記憶體重新讀取。
二.並發編程中的三個概念
在並發編程中,我們通常會遇到以下三個問題:原子性問題,可見度問題,有序性問題。我們先看具體看一下這三個概念:
1.原子性
原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
一個很經典的例子就是銀行賬戶轉賬問題:
比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。
試想一下,如果這2個操作不具備原子性,會造成什麼樣的後果。假如從賬戶A減去1000元之後,操作突然中止。然後又從B取出了500元,取出500元之後,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。
所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。
同樣地反映到並發編程中會出現什麼結果呢。
舉個最簡單的例子,大家想一下假如為一個32位的變數賦值過程不具備原子性的話,會發生什麼後果。
假若一個線程執行到這個語句時,我暫且假設為一個32位的變數賦值包括兩個過程:為低16位賦值,為高16位賦值。
那麼就可能發生一種情況:當將低16位元值寫入之後,突然被中斷,而此時又有一個線程去讀取i的值,那麼讀取到的就是錯誤的資料。
2.可見度
可見度是指當多個線程訪問同一個變數時,一個線程修改了這個變數的值,其他線程能夠立即看得到修改的值。
舉個簡單的例子,看下面這段代碼: