天天看點

Go 中的并發是困難的

作者:技術的遊戲
我明白标題可能有些令人困惑,因為一般來說,Go 被認為在并發方面有很好的内置支援。然而,我并不認為在 Go 中編寫并發軟體是容易的。讓我向您展示我是什麼意思。

使用全局變量

第一個例子是我們在項目中遇到的問題。直到最近,sarama 庫(用于 Apache Kafka 的 Go 庫)中包含了以下代碼(位于 sarama/version.go):

package sarama

import "runtime/debug"

var v string

func version() string {

if v == "" {

bi, ok := debug.ReadBuildInfo()

if ok {

v = bi.Main.Version

} else {

v = "dev"

}

}

return v

}

乍一看,這看起來沒問題,對吧?如果版本沒有在全局設定中,它要麼基于建構資訊,要麼被配置設定為靜态值(dev)。否則,版本将按原樣傳回。當我們運作這段代碼時,它似乎按預期工作。

然而,當并發調用 version 函數時,全局變量 v 可能會被多個 goroutine 同時通路,導緻潛在的資料競争。這些問題很難跟蹤,因為它們隻在運作時在恰當的條件下才會發生。

解決方案

這個問題在#2171 中得到修複,通過使用 sync.Once,根據文檔的解釋,它是 “執行一次且僅執行一次操作的對象”。這意味着我們可以使用它來設定版本,以便後續對 version 函數的調用将傳回結果。修複代碼如下所示:

package sarama

import (

"runtime/debug"

"sync"

)

var (

v string

vOnce sync.Once

)

func version() string {

vOnce.Do(func() {

bi, ok := debug.ReadBuildInfo()

if ok {

v = bi.Main.Version

} else {

v = "dev"

}

})

return v

}

盡管我認為在這種情況下,也可以在不使用 sync 包的情況下通過使用 init 函數來設定變量 v 一次來進行修複。由于在 Go 運作 init 函數後變量 v 不會改變,是以應該是沒問題的。

如何預防

您可以在測試期間或在使用 go run 時使用 data race detector(自 Go 1.1 起可用)。當它檢測到潛在的資料競争時,它會列印一個警告。為了展示這是如何工作的,我稍微修改了一下代碼來觸發資料競争:

package main

import (

"fmt"

"runtime/debug"

)

var v string

func version() string {

if v == "" {

bi, ok := debug.ReadBuildInfo()

if ok {

v = bi.Main.Version

} else {

v = "dev"

}

}

return v

}

func main() {

go func() {

version()

}()

fmt.Println(version())

}

現在我們可以使用 -race 标志來啟用資料競争檢測器來運作它:

➜ go run -race .

==================

WARNING: DATA RACE

Write at 0x000104a16b90 by main goroutine:

main.version()

main.go:14 +0x78

main.main()

main.go:27 +0x30Previous read at 0x000104a16b90 by goroutine 7:

main.version()

main.go:11 +0x2c

main.main.func1()

main.go:24 +0x24Goroutine 7 (finished) created at:

main.main()

main.go:23 +0x2c

==================

(devel)

Found 1 data race(s)

exit status 66

正如你所看到的,檢測到了資料競争。如果我們分析輸出,可以看到我們同時對變量 v 進行讀寫操作。這就是我們所說的資料競争。之是以稱為資料競争,是因為兩個 goroutine 正在 "競争" 通路相同的資料。

Go 中的并發是困難的

從 sync 包中複制結構體

我在 GitHub 上找到了一些實際的例子,但沒有一個足夠重要以至于在這裡提及。相反,我将基于我制作的一個示例來解釋。是以,下面是例子的說明:

package main

import "sync"

type User struct {

lock sync.RWMutex

Name string

}

func doSomething(u User) {

u.lock.RLock()

defer u.lock.RUnlock()

// do something with `u`

}

func main() {

u := User{Name: "John"}

doSomething(u)

}

User 結構體包含兩個屬性:讀 / 寫鎖和一個字元串。當調用 doSomething 函數時,變量 u 會被複制到棧上(也稱為按值傳遞),包括其字段。這是一個問題,因為 sync 包的文檔中指出:

sync 包提供了基本的同步原語,如互斥鎖。除了 Once 和 WaitGroup 類型外,大多數都是為低級庫例程使用的。更進階的同步最好通過通道和通信來完成。

不應複制包含此包中定義的類型的值。

當評估 doSomething 函數時,運作 RLock/RUnlock 不會影響 User 結構體中的原始鎖,這個鎖無效。

解決方案

改用鎖的指針。指針會被複制,并指向相同的值。更新後的版本如下所示:

type User struct {

lock *sync.RWMutex

Name string

}

如何預防

使用 copylock 分析器來在複制 sync 包中的類型時顯示警告。最簡單的方法是在釋出代碼之前運作 go vet。在原始代碼上運作這個指令會得到以下輸出:

➜ go vet .

# data-synchronization

./main.go:10:20: doSomething passes lock by value: data-synchronization.User contains sync.RWMutex

./main.go:20:14: call of doSomething copies lock value: data-synchronization.User contains sync.RWMutex

使用 time.After

在 GitHub 上搜尋時,我發現了 Hashicorp 的 Raft 實作中的一個 pull request,我們可以使用它來示範以下問題。讓我們首先展示代碼(位于 api.go 檔案中):

var timer <-chan time.Time

if timeout > 0 {

timer = time.After(timeout)

}

// Perform the restore.

restore := &userRestoreFuture{

meta: meta,

reader: reader,

}

restore.init()

select {

case <-timer:

return ErrEnqueueTimeout

case <-r.shutdownCh:

return ErrRaftShutdown

case r.userRestoreCh <- restore:

// If the restore is ingested then wait for it to complete.

if err := restore.Error(); err != nil {

return err

}

}

這段代碼來自 Restore 方法。select 語句等待以下情況之一發生:計時器(用于定義逾時)、關閉通道或還原操作完成時。看起來很簡單,那問題在哪裡呢?

time.After 函數的工作原理如下:

func After(d Duration) <-chan Time {

return NewTimer(d).C

}

是以,它隻是 time.NewTimer 的簡寫形式,但它 “洩露” 了計時器(因為沒有調用 timer.Stop)。文檔對此的說明如下:

After 等待持續時間過去,然後在傳回的通道上發送目前時間。它等價于 NewTimer (d).C。直到計時器觸發後,底層計時器才會被垃圾回收器回收。如果效率是一個問題,可以使用 NewTimer 并在不再需要計時器時調用 Timer.Stop。

我真的不明白為什麼一個有意 “洩露” 計時器的函數(可能會導緻潛在的長期配置設定,取決于持續時間)最終出現在标準庫中...

解決方案

我們可以手動建立計時器,而不是使用 time.After。具體如下所示:

var timerCh <-chan time.Time

if timeout > 0 {

timer := time.NewTimer(timeout)

defer timer.Stop()

timerCh = timer.C

}

// Perform the restore.

restore := &userRestoreFuture{

meta: meta,

reader: reader,

}

restore.init()

select {

case <-timerCh:

return ErrEnqueueTimeout

case <-r.shutdownCh:

return ErrRaftShutdown

case r.userRestoreCh <- restore:

// If the restore is ingested then wait for it to complete.

if err := restore.Error(); err != nil {

return err

}

}

當函數執行完畢時,即使計時器沒有觸發,它也會被清理。

如何預防

我不會在任何代碼庫中使用 time.After。除了節省一兩行代碼外,它沒有實質性的優勢,而且可能會引發很多問題,特别是當它在代碼的熱點路徑中使用時。

結論

使用 Go 的内置并發支援可以快速編寫并發軟體。然而,它将確定資料正确同步和正确使用标準庫中的工具的責任留給使用者。這加上 Go 的簡潔性,使得編寫穩定、無 bug 的并發軟體變得困難。

如果你喜歡我的文章,點贊,關注,轉發!