併發 Go 程式中的共享變數 (四):記憶體同步
本系列是閱讀 “The Go Programming Language” 理解和記錄。
在上一小節中ofollow,noindex" target="_blank">併發 Go 程式中的共享變數 (三):讀寫鎖
,我們在實現Balance
方法也需要一個排他鎖,不論這個排他鎖是通過 channel 實現還是互斥鎖實現都是可以的,在我們的例子中是通過讀寫鎖
實現的。
func Balance()int { mu.RLock() defer mu.RUnlock() return balance }
但是不像Deposit
方法那樣需要讀取 balance 並且加上 amount,Balance
只有一種操作,就是讀取 Balance 並返回,所以即使有其它的 goroutine 在這中間有執行操作也不會造成什麼問題。真的是這樣麼?
實際上我們還是需要鎖,理由有二:
第一,Balance
不能在其他操作執行期間執行,比如 Withdraw 執行中的時候,實際上已經少了 balance,但是實際讀取的 balance 可能還是一箇舊值。
第二,同步不僅僅和 goroutine 的執行順序相關,同步也會影響記憶體。
在現代計算機中,一般都會有多個 CPU,每個有 CPU 有自己的主存快取。為了效能,寫到主存的資料一般都會在每個 CPU 內部首先快取起來,然後在必要的時候提交到主存。這些修改的提交的順序可能和 goroutine 的執行順序不同 。而同步原語比如說 channel 或者互斥鎖的主要目的就是讓 CPU 把 buffer 的資料提交到主存中,以便其它執行在其它 CPU 上的 goroutine 能夠看到這些提交帶來變化。
考慮以下程式碼的輸出:
var x, y int go func(){ x = 1// A1 fmt.Print("y:", y, " ") // A2 }() go func(){ y = 1// B1 fmt.Print("x:", x, " ")// B2 }()
正如前面文章所提到的,兩個 goroutine 併發執行,而且在沒有使用互斥機制的情況下共享變數,存在 data race ,因此在看到不確定的結果時不應該感到驚訝。我們可能會看到由於程式碼的執行順序的不同而有不同的輸出:
y:0 x:1 x:0 y:1 x:1 y:1 y:1 x:1
這四行輸出可以解釋為 A1,B1,A2,B2 或者 B1,A1,A2,B2。但是有一種結果可能會讓你感到吃驚:
x:0 y:0 y:0 x:0
但是現實情況是:由於 CPU 或者編譯器以及其它一些因素的影響,這種結果是有可能發生的。那麼這 4 句語句如何交錯執行才能產生這樣的結果?
在單個 goroutine 中,每個語句帶來的影響可以說是嚴格按照他們的執行順序而產生的,goroutine 是線性一致的(sequentially consistent)。但是在多個 goroutine 中,如果沒有顯式的同步機制,比如 channel 或者互斥鎖,沒有辦法保證 goroutine 彼此之間看到影響都是嚴格按照執行順序的先後而產生的 。雖然 goroutine A 肯定是先觀測到 x = 1 執行完畢之後才去讀取 y 的值,但是它沒有辦法確保 goroutine B 對 y 的修改一定能看的到,所以 goroutine A 可能讀取的還是一個 y 的舊值:0。
理解併發執行的這種嘗試常常很有意思,就好像併發的結果確實是 goroutine 之間的這些語句交錯執行而產生的,事實可能並不是如此,正如上面的例子展示出來的一樣結果都是 0 的輸出,goroutine 的執行完全由可能是 A2,A1,B2,B1。這是由於賦值語句和 Print 語句指向都是不同的變數,編譯器可能會得出一個論是:這兩條語句的執行結果相互不影響從而交換了這兩條語句的執行順序 。如果這兩個 goroutine 是在不同的 CPU 上執行,每個 CPU 都有自己的 cache,其中一個 goroutine 寫到 cache 中的資料是不能被另一個 goroutine 中的 Print 語句看到的,直到資料被同步到主從中。
所有併發的問題都能被這些已經建立的簡單模式所解決: