用一個小例子談談 Golang 中的 Race Condition

Larry Lu
Larry・Blog
Published in
7 min readApr 15, 2018

--

You can find the English version at Concurrency in Go: Race Conditions and their Solutions.

Goroutine 是 Go 最重要的特性之一,它可以讓開發者輕易做到併發(concurrency),而且他的非常輕量,所以一次開一大堆 goroutine 也不會有什麼問題。

但如果在使用 goroutine 時沒有考慮到 race condition,那可能就會導致不正確的結果,這篇文章會用一個簡單的小例子,談談在什麼情況可能會遇到 race condition,以及如何發現、解決它。

Race Condition(競爭危害)

根據維基百科上對 Race Condition 的定義

A race condition or race hazard is the behavior of an electronics, software, or other system where the output is dependent on the sequence or timing of other uncontrollable events.

譬如說有兩個正在進行中的 goroutine 分別要對某變數 a 做 a = a * 2 還有 a = a + 1,這兩個 goroutine 不同的順序有可能導致最後 a 有不同的值,這就是 race condition,為了防止 race condition 要使用一些特別的方式讓他們有確定的順序,以免導致奇怪的 bug

來看看這次要講解的例子,分成三個步驟

  1. 先把 a 的初始值設為 0
  2. 開三個 goroutine 共做了三次 a++
  3. 最後用 channel 等待三個 goroutine 完成
func main() {
a := 0
times := 3
c := make(chan bool)

for i := 0; i < times; i++ {
go func() {
a++
c <- true
}()
}

for i := 0; i < times; i++ {
<-c
}
fmt.Printf("a = %d\n", a)
}

沒意外的話最後應該會得到 a = 3,結果也確實如此

那如果把次數改成一萬次呢?

func main() {
a := 0
times := 10000 // <-- HERE
c := make(chan bool)

for i := 0; i < times; i++ {
go func() {
a++
c <- true
}()
}

for i := 0; i < times; i++ {
<-c
}
fmt.Printf("a = %d\n", a)
}

理論上要得到 a = 10000,實際跑了卻會得到 a = 9903 之類的結果(如果你有多核 CPU 的話),但我們確實開了一萬個 goroutine 也做了一萬次 a++,為什麼結果會不對呢,因為在 a++ 的過程中發生了 race condition

為什麼 a++ 會發生 race condition

當你寫了 a++ 時電腦實際上做了三件事:

  1. CPU 把 a 的值取出來
  2. 把剛剛取得的值加 1
  3. 把運算的結果存回變數 a

但萬一你有多核 CPU 就有可能會這樣:

兩個 CPU 同時去拿變數 a 的值,各自加 1 後存回,導致 a 只被加了一次,因此結果(9903)會小於正確的 10000

解法:互斥鎖

這裡會發生 race condition 最根本的原因是「兩個 goroutine 可能會同時存取變數 a」,如果能限制同時只能有一個 goroutine 做 a++,那就能解決這個問題,為了達到這個目的我們要使用 sync.Mutex

func main() {
a := 0
times := 10000
c := make(chan bool)

var m sync.Mutex

for i := 0; i < times; i++ {
go func() {
m.Lock() // get lock
a++
m.Unlock() // release lock
c <- true
}()
}

for i := 0; i < times; i++ {
<-c
}
fmt.Printf("a = %d\n", a)
}

Mutex 是互斥鎖的意思,同時最多只能有一個人拿到這個鎖,其他人想要鎖的話就會被 block 住直到前一個人用完,所以就可以確保只有一個 goroutine 正在進行 a++,這樣就可以得到正確的結果 10000

如何發現 race condition

在這個例子中 race condition 發生在 a++,但如果對電腦底層不夠熟悉就有可能沒辦法發現問題,還好 Golang 有個很強大的工具叫 Data Race Detector

func main() {
a := 0
times := 10000
c := make(chan bool)

for i := 0; i < times; i++ {
go func() {
a++
c <- true
}()
}

for i := 0; i < times; i++ {
<-c
}
fmt.Printf("a = %d\n", a)
}

在跑的時候加一個 -race 他就可以幫你偵測哪邊可能會產生 race condition,大家也可以自己下載原始碼下來跑

$ go run -race add_few_times/main.go
==================
WARNING: DATA RACE
Read at 0x00c4200a4008 by goroutine 7:
main.main.func1()
.../add_few_times/main.go:12 +0x38
Previous write at 0x00c4200a4008 by goroutine 6:
main.main.func1()
.../add_few_times/main.go:12 +0x4e
Goroutine 7 (running) created at:
main.main()
.../add_few_times/main.go:11 +0xc1
Goroutine 6 (running) created at:
main.main()
.../add_few_times/main.go:11 +0xc1
==================
Found 1 data race(s)
exit status 66

Race Detector 說在第 11 行(go func(){…}())產生了兩個 goroutine(G6 跟 G7),G7 在第十二行(a++)讀取了變數 a 之後,G6 緊接著寫入了變數 a,所以 G7 會讀到舊資料,這時候就有可能會產生 race condition

透過 Race Detector 幾乎可以找到所有的 race condition,大部分時候也都只要加個鎖就可以解決

鎖的缺點

效能

上面的例子用 mutex 來防止多個 goroutine 同時存取同一個變數,因為總共有一萬個 goroutine,當你有其中一個正在存取 a 時其他 9999 個都在等他,他們之間完全沒有並行(parallelism),不如用個迴圈把它從 0 加到 10000 可能還更快

所以在使用鎖時一定要非常小心,只在必要的時候使用,否則效能將會大打折扣

忘記解鎖

有時候上鎖解鎖不像上面 a++ 這麼簡單,中間可能有很多個鎖還有各種條件判斷、網路請求等等,當程式變複雜一不小就有可能忘記或是太晚解鎖,造成整個程式非常慢甚至完全卡住,產生死鎖問題

總結

這次用很簡單的例子談談在 Golang 中什麼時候會遇到 race condition 以及如何解決,因為要開一個 goroutine 太簡單了,所以有時候會不小心忘記考慮 race condition,還好 Go 有提供 Race Detector 不用自己慢慢找 XDD

以前筆者主要寫 Node.js 的時候都不用擔心這個問題,因為 JavaScript 是單線程的,事情不會做到一半被打斷,也不會有兩個線程同時存取變數,寫起來沒什麼心智負擔,但缺點就是某些耗時的計算可能會卡住整個程式,只能說各有優缺

另外,這篇文章中提到都原始碼都放在 Github Repo,需要的人可以自己 clone 下來跑跑看

參考資料

--

--

我是 Larry 盧承億,傳說中的 0.1 倍工程師。我熱愛技術、喜歡與人分享,專長是 JS 跟 Go,平常會寫寫技術文章還有參加各種技術活動,歡迎大家來找我聊聊~