天天看點

golang 并發資源競争(互斥鎖)

作者:幹飯人小羽
golang 并發資源競争(互斥鎖)

并發本身并不複雜,但是因為有了資源競争的問題,就使得我們開發出好的并發程式變得複雜起來,因為會引起很多莫名其妙的問題。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    count int32
    wg    sync.WaitGroup
)

func main() {
    wg.Add(2)
    go incCount()
    go incCount()
    wg.Wait()
    fmt.Println(count)
}

func incCount() {
    defer wg.Done()
    for i := 0; i < 2; i++ {
        value := count
        runtime.Gosched()
        value++
        count = value
    }
}           

這是一個資源競争的例子。我們可以多運作幾次這個程式,會發現結果可能是 2 ,也可以是 3 ,也可能是 4 。因為共享資源count變量沒有任何同步保護,是以兩個goroutine都會對其進行讀寫,會導緻對已經計算好的結果覆寫,以至于産生錯誤結果。這裡我們示範一種可能,兩個goroutine我們暫時稱之為g1和g2。

g1讀取到count為 0 。

然後g1暫停了,切換到g2運作,g2讀取到count也為 0 。

g2暫停,切換到g1,g1對count+1,count變為 1 。

g1暫停,切換到g2,g2剛剛已經擷取到值 0 ,對其+1,最後指派給count還是 1 。

有沒有注意到,剛剛g1對count+1的結果被g2給覆寫了,兩個goroutine都+1還是 1 。

不再繼續示範下去了,到這裡結果已經錯了,兩個goroutine互相覆寫結果。我們這裡的runtime.Gosched()是讓目前goroutine暫停的意思。退回執行隊列,讓其他等待的goroutine運作,目的是讓我們示範資源競争的結果更明顯。注意,這裡還會牽涉到CPU問題,多核會并行,那麼資源競争的效果更明顯。

是以我們對于同一個資源的讀寫必須是原子化的,也就是說,同一時間隻能有一個goroutine對共享資源進行讀寫操作。

共享資源競争的問題,非常複雜,并且難以察覺,好在Go提供了一個工具來幫助我們檢查,這個就是go build -race指令。我們在目前項目目錄下執行這個指令,生成一個可以執行檔案,然後再運作這個可執行檔案,就可以看到列印出的檢測資訊。

go build -race           

多加了一個-race标志,這樣生成的可執行程式就自帶了檢測資源競争的功能。下面我們運作,也是在終端運作。

./hello           

我這裡示例生成的可執行檔案名是hello,是以是這麼運作的。這時候,我們看終端輸出的檢測結果。

hello ./hello       
==================
WARNING: DATA RACE
Read at 0x0000011a5118 by goroutine 7:
  main.incCount()
      /Users/xxx/code/go/src/flysnow.org/hello/main.go:25 +0x76

Previous write at 0x0000011a5118 by goroutine 6:
  main.incCount()
      /Users/xxx/code/go/src/flysnow.org/hello/main.go:28 +0x9a

Goroutine 7 (running) created at:
  main.main()
      /Users/xxx/code/go/src/flysnow.org/hello/main.go:17 +0x77

Goroutine 6 (finished) created at:
  main.main()
      /Users/xxx/code/go/src/flysnow.org/hello/main.go:16 +0x5f
==================
4
Found 1 data race(s)           

看,找到一個資源競争,連在那一行代碼出了問題,都标示出來了。goroutine 7在代碼 25 行讀取共享資源value := count,而這時goroutine 6正在代碼 28 行修改共享資源count = value,而這兩個goroutine都是從main函數啟動的,在 16、17 行,通過go關鍵字。

既然我們已經知道共享資源競争的問題,是因為同時有兩個或者多個goroutine對其進行了讀寫,那麼我們隻要保證,同時隻有一個goroutine讀寫不就可以了。現在我們就看下傳統解決資源競争的辦法——對資源加鎖。

Go語言提供了atomic包和sync包裡的一些函數對共享資源同步加鎖,我們在此隻看下sync:

sync包裡提供了一種互斥型的鎖,可以讓我們自己靈活地控制那些代碼,同時隻能有一個goroutine通路,被sync互斥鎖控制的這段代碼範圍,被稱之為臨界區。臨界區的代碼,同一時間,隻能又一個goroutine通路。剛剛那個例子,我們還可以這麼改造。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    count int32
    wg    sync.WaitGroup
    mutex sync.Mutex
)

func main() {
    wg.Add(2)
    go incCount()
    go incCount()
    wg.Wait()
    fmt.Println(count)
}

func incCount() {
    defer wg.Done()
    for i := 0; i < 2; i++ {
        mutex.Lock()
        value := count
        runtime.Gosched()
        value++
        count = value
        mutex.Unlock()
    }
}           

執行個體中,新聲明了一個互斥鎖mutex sync.Mutex。這個互斥鎖有兩個方法,一個是mutex.Lock(),一個是mutex.Unlock()。這兩個之間的區域就是臨界區,臨界區的代碼是安全的。

示例中我們先調用mutex.Lock()對有競争資源的代碼加鎖,這樣當一個goroutine進入這個區域的時候,其他goroutine就進不來了,隻能等待,一直到調用mutex.Unlock() 釋放這個鎖為止。

這種方式比較靈活,可以讓代碼編寫者任意定義需要保護的代碼範圍,也就是臨界區。除了原子函數和互斥鎖,Go還為我們提供了更容易在多個goroutine同步的功能,這就是通道chan。