9.1. 競爭條件

在一個線性(就是説隻有一個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的全部訪問。

下面是一個重寫了的銀行的例子,這個例子中balance變量被限製在了monitor goroutine中,名爲teller:

gopl.io/ch9/bank1 ```go // Package bank provides a concurrency-safe bank with one account. package bank

var deposits = make(chan int) // send amount to deposit var balances = make(chan int) // receive balance

func Deposit(amount int) { deposits <- amount } func Balance() int { return <-balances }

func teller() { var balance int // balance is confined to teller goroutine for { select { case amount := <-deposits: balance += amount case balances <- balance: } } }

func init() { go teller() // start the monitor goroutine } ```

卽使當一個變量無法在其整個生命週期內被綁定到一個獨立的goroutine,綁定依然是併發問題的一個解決方案。例如在一條流水線上的goroutine之間共享變量是很普遍的行爲,在這兩者間會通過channel來傳輸地址信息。如果流水線的每一個階段都能夠避免在將變量傳送到下一階段時再去訪問它,那麽對這個變量的所有訪問就是線性的。其效果是變量會被綁定到流水線的一個階段,傳送完之後被綁定到下一個,以此類推。這種規則有時被稱爲串行綁定。

下面的例子中,Cakes會被嚴格地順序訪問,先是baker gorouine,然後是icer gorouine:

type Cake struct{ state string }

func baker(cooked chan<- *Cake) {
    for {
        cake := new(Cake)
        cake.state = "cooked"
        cooked <- cake // baker never touches this cake again
    }
}

func icer(iced chan<- *Cake, cooked <-chan *Cake) {
    for cake := range cooked {
        cake.state = "iced"
        iced <- cake // icer never touches this cake again
    }
}

第三種避免數據競爭的方法是允許很多goroutine去訪問變量,但是在同一個時刻最多隻有一個goroutine在訪問。這種方式被稱爲“互斥”,在下一節來討論這個主題。

練習 9.1: 給gopl.io/ch9/bank1程序添加一個Withdraw(amount int)取款函數。其返迴結果應該要表明事務是成功了還是因爲沒有足夠資金失敗了。這條消息會被發送給monitor的goroutine,且消息需要包含取款的額度和一個新的channel,這個新channel會被monitor goroutine來把boolean結果發迴給Withdraw。