閱讀146 返回首頁    go 技術社區[雲棲]


Go語言與數據庫開發:01-08

基於共享變量的並發

.競爭條件

在一個線性(就是說隻有一個goroutine的)的程序中,程序的執行順序隻由程序的邏輯來決定。
例如,我們有一段語句序列,第一個在第二個之前(廢話),以此類推。在有兩個或更多
goroutine的程序中,每一個goroutine內的語句也是按照既定的順序去執行的,但是一般情況
下我們沒法去知道分別位於兩個goroutine的事件x和y的執行順序,x是在y之前還是之後還是
同時發生是沒法判斷的。當我們能夠沒有辦法自信地確認一個事件是在另一個事件的前麵或
者後麵發生的話,就說明x和y這兩個事件是並發的。
考慮一下,一個函數在線性程序中可以正確地工作。如果在並發的情況下,這個函數依然可
以正確地工作的話,那麼我們就說這個函數是並發安全的,並發安全的函數不需要額外的同
步工作。我們可以把這個概念概括為一個特定類型的一些方法和操作函數,如果這個類型是
並發安全的話,那麼所有它的訪問方法和操作就都是並發安全的。
在一個程序中有非並發安全的類型的情況下,我們依然可以使這個程序並發安全。確實,並
發安全的類型是例外,而不是規則,所以隻有當文檔中明確地說明了其是並發安全的情況
下,你才可以並發地去訪問它。我們會避免並發訪問大多數的類型,無論是將變量局限在單
一的一個goroutine內還是用互斥條件維持更高級別的不變性都是為了這個目的。我們會在本
章中說明這些術語。
相反,導出包級別的函數一般情況下都是並發安全的。由於package級的變量沒法被限製在單
一的gorouine,所以修改這些變量“必須”使用互斥條件。
一個函數在並發調用時沒法工作的原因太多了,比如死鎖(deadlock)、活鎖(livelock)和餓死
(resource starvation)。我們沒有空去討論所有的問題,這裏我們隻聚焦在競爭條件上。
競爭條件指的是程序在多個goroutine交叉執行操作時,沒有給出正確的結果。競爭條件是很
惡劣的一種場景,因為這種問題會一直潛伏在你的程序裏,然後在非常少見的時候蹦出來,
或許隻是會在很大的負載時才會發生,又或許是會在使用了某一個編譯器、某一種平台或者
某一種架構的時候才會出現。這些使得競爭條件帶來的問題非常難以複現而且難以分析診
斷。
傳統上經常用經濟損失來為競爭條件做比喻,所以我們來看一個簡單的銀行賬戶程序。

// Package bank implements a bank with only one account.
package bank
var balance int
func Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }
(當然我們也可以把Deposit存款函數寫成balance += amount,這種形式也是等價的,不過長
一些的形式解釋起來更方便一些。)

對於這個具體的程序而言,我們可以瞅一眼各種存款和查餘額的順序調用,都能給出正確的
結果。也就是說,Balance函數會給出之前的所有存入的額度之和。然而,當我們並發地而不
是順序地調用這些函數的話,Balance就再也沒辦法保證結果正確了。考慮一下下麵的兩個
goroutine,其代表了一個銀行聯合賬戶的兩筆交易:
// Alice:
go func() {
bank.Deposit(200) // A1
fmt.Println("=", bank.Balance()) // A2
}()
// Bob:
go bank.Deposit(100) // B
Alice存了$200,然後檢查她的餘額,同時Bob存了$100。因為A1和A2是和B並發執行的,我
們沒法預測他們發生的先後順序。直觀地來看的話,我們會認為其執行順序隻有三種可能
性:“Alice先”,“Bob先”以及“Alice/Bob/Alice”交錯執行。下麵的表格會展示經過每一步驟後
balance變量的值。引號裏的字符串表示餘額單。
Alice first Bob first Alice/Bob/Alice
0 0 0
A1 200 B 100 A1 200
A2 "=200" A1 300 B 300
B 300 A2 "=300" A2 "=300"
所有情況下最終的餘額都是$300。唯一的變數是Alice的餘額單是否包含了Bob交易,不過無
論怎麼著客戶都不會在意。
但是事實是上麵的直覺推斷是錯誤的。第四種可能的結果是事實存在的,這種情況下Bob的存
款會在Alice存款操作中間,在餘額被讀到(balance + amount)之後,在餘額被更新之前
(balance = ...),這樣會導致Bob的交易丟失。而這是因為Alice的存款操作A1實際上是兩個操
作的一個序列,讀取然後寫;可以稱之為A1r和A1w。下麵是交叉時產生的問題:
Data race
0
A1r 0 ... = balance + amount
B 100
A1w 200 balance = ...
A2 "= 200"
在A1r之後,balance + amount會被計算為200,所以這是A1w會寫入的值,並不受其它存款
操作的幹預。最終的餘額是$200。銀行的賬戶上的資產比Bob實際的資產多了$100。(譯注:
因為丟失了Bob的存款操作,所以其實是說Bob的錢丟了)

這個程序包含了一個特定的競爭條件,叫作數據競爭。無論任何時候,隻要有兩個goroutine
並發訪問同一變量,且至少其中的一個是寫操作的時候就會發生數據競爭。
如果數據競爭的對象是一個比一個機器字(譯注:32位機器上一個字=4個字節)更大的類型時,
事情就變得更麻煩了,比如interface,string或者slice類型都是如此。下麵的代碼會並發地更
新兩個不同長度的slice:
var x []int
go func() { x = make([]int, 10) }()
go func() { x = make([]int, 1000000) }()
x[999999] = 1 // NOTE: undefined behavior; memory corruption possible!
最後一個語句中的x的值是未定義的;其可能是nil,或者也可能是一個長度為10的slice,也可
能是一個程度為1,000,000的slice。但是回憶一下slice的三個組成部分:指針(pointer)、長度
(length)和容量(capacity)。如果指針是從第一個make調用來,而長度從第二個make來,x就
變成了一個混合體,一個自稱長度為1,000,000但實際上內部隻有10個元素的slice。這樣導致
的結果是存儲999,999元素的位置會碰撞一個遙遠的內存位置,這種情況下難以對值進行預
測,而且定位和debug也會變成噩夢。這種語義雷區被稱為未定義行為,對C程序員來說應該
很熟悉;幸運的是在Go語言裏造成的麻煩要比C裏小得多。
盡管並發程序的概念讓我們知道並發並不是簡單的語句交叉執行。我們將會在9.4節中看到,
數據競爭可能會有奇怪的結果。許多程序員,甚至一些非常聰明的人也還是會偶爾提出一些
理由來允許數據競爭,比如:“互斥條件代價太高”,“這個邏輯隻是用來做logging”,“我不介意
丟失一些消息”等等。因為在他們的編譯器或者平台上很少遇到問題,可能給了他們錯誤的信
心。一個好的經驗法則是根本就沒有什麼所謂的良性數據競爭。所以我們一定要避免數據競
爭,那麼在我們的程序中要如何做到呢?
我們來重複一下數據競爭的定義,因為實在太重要了:數據競爭會在兩個以上的goroutine並
發訪問相同的變量且至少其中一個為寫操作時發生。根據上述定義,有三種方式可以避免數
據競爭:
第一種方法是不要去寫變量。考慮一下下麵的map,會被“懶”填充,也就是說在每個key被第
一次請求到的時候才會去填值。如果Icon是被順序調用的話,這個程序會工作很正常,但如果
Icon被並發調用,那麼對於這個map來說就會存在數據競爭。

var icons = make(map[string]image.Image)
func loadIcon(name string) image.Image
// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
icon, ok := icons[name]
if !ok {
icon = loadIcon(name)
icons[name] = icon
}
return icon
}
反之,如果我們在創建goroutine之前的初始化階段,就初始化了map中的所有條目並且再也
不去修改它們,那麼任意數量的goroutine並發訪問Icon都是安全的,因為每一個goroutine都
隻是去讀取而已。

var icons = map[string]image.Image{
"spades.png": loadIcon("spades.png"),
"hearts.png": loadIcon("hearts.png"),
"diamonds.png": loadIcon("diamonds.png"),
"clubs.png": loadIcon("clubs.png"),
}
// Concurrency-safe.
func Icon(name string) image.Image { return icons[name] }
上麵的例子裏icons變量在包初始化階段就已經被賦值了,包的初始化是在程序main函數開始
執行之前就完成了的。隻要初始化完成了,icons就再也不會修改的或者不變量是本來就並發
安全的,這種變量不需要進行同步。不過顯然我們沒法用這種方法,因為update操作是必要
的操作,尤其對於銀行賬戶來說。
第二種避免數據競爭的方法是,避免從多個goroutine訪問變量。這也是前一章中大多數程序
所采用的方法。例如前麵的並發web爬蟲(§8.6)的main goroutine是唯一一個能夠訪問seen
map的goroutine,而聊天服務器(§8.10)中的broadcaster goroutine是唯一一個能夠訪問clients
map的goroutine。這些變量都被限定在了一個單獨的goroutine中。
由於其它的goroutine不能夠直接訪問變量,它們隻能使用一個channel來發送給指定的
goroutine請求來查詢更新變量。這也就是Go的口頭禪“不要使用共享數據來通信;使用通信來
共享數據”。一個提供對一個指定的變量通過cahnnel來請求的goroutine叫做這個變量的監控
(monitor)goroutine。例如broadcaster goroutine會監控(monitor)clients map的全部訪問。

最後更新:2017-10-28 21:33:23

  上一篇:go  ZooKeeper
  下一篇:go  Hbase 配置流程