當 Go struct 遇上 Mutex

haohongfan 發表於 2021-04-21
Go

struct 是我們寫 Go 必然會用到的關鍵字, 不過當 struct 遇上一些比較特殊型別的時候, 你注意過你的程式是否正常嗎 ?

一段程式碼

type URL struct {
    Ip       string
    Port     string
    mux      sync.RWMutex
    params    url.Values
}

func (c *URL) Clone() URL {
    newUrl := URL{}
    newUrl.Ip = c.Ip
    newUrl.params = url.Values{}
    return newUrl
}

這段程式碼你能看出來問題所在嗎 ?

A: 程式正常
B: 編譯失敗
C: panic
D: 有可能發生 data race
E: 有可能發生死鎖

如果你看出來問題在哪裡的話, 那我再悄悄告訴你, 這段程式碼是 github 某 3k star Go 框架的底層核心程式碼, 那你是不是就覺得這個話題開始有意思了 ?

先說結論

上面那段程式碼的問題是 sync.RWMutex 引起的. 如果你看過有關 sync 相關型別的介紹或者相關原始碼時, 在 sync 包裡面的所有型別都有句這樣的註釋: must not be copied after first use, 可能很多人卻並不知道這句話有什麼作用, 頂多看到相關介紹時還記得 sync 相關型別的變數不能複製, 可能真正使用 Mutex, WaitGroup, Cond 時, 早把這個註釋忘的一乾二淨.

究其原因, 我覺得有下面兩點原因:

  1. 不明白什麼叫 sync 型別變數複製
  2. sync 型別的變數複製了會出現怎樣的結果

下面的例子都以 Mutex 來舉例

  1. 最容易看出來的情形
func main() {
    var amux sync.Mutex
    b := amux
    b.Lock()
    b.Unlock()
}

其實這種情況一般情況下, 沒人這麼用. 問題不大, 略過

  1. 巢狀在 struct 裡面, struct 變數間的互相賦值
type URL struct {
    Ip       string
    Port     string
    mux      sync.RWMutex
    params    url.Values
}

func main() {
    var url1 URL
    url2 := url1
}

當 struct 巢狀 不可複製 型別時, 就需要開始小心了. 當 struct 巢狀層次過深或者 struct 變數隨著值傳遞對外擴散時, 這個時候就會變得不可控了, 就需要特別小心了.

  1. struct 型別變數的值傳遞作為返回值
type URL struct {
    Ip       string
    mux      sync.RWMutex
}

func (c *URL) Clone() URL {
    newUrl := URL{}
    newUrl.Ip = c.Ip
    return newUrl
}
  1. struct 型別變數的值傳遞作為 receiver
type URL struct {
    Ip       string
    mux      sync.RWMutex
}

func (c URL) String() string {
    c.paramsLock.Lock()
    defer c.paramsLock.Unlock()
    buf.WriteString(c.params.Encode())
    return buf.String()
}

複製後出現的結果

例子 1:

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup
var age int

type Person struct {
    mux sync.Mutex
}

func (p Person) AddAge() {
    defer wg.Done()
    p.mux.Lock()
    age++
    defer p.mux.Unlock()

}

func main() {
    p1 := Person{
        mux: sync.Mutex{},
    }
    wg.Add(100)
    for i := 0; i < 100; i++ {
        go p1.AddAge()
    }
    wg.Wait()
    fmt.Println(age)
}

結果: 結果有可能是 100, 也有可能是 99....

例子 2:

package main

import (
    "fmt"
    "sync"
)

type Person struct {
    mux sync.Mutex
}

func Reduce(p Person) {
    fmt.Println("step...", )
    p.mux.Lock()
    fmt.Println(p)
    defer p.mux.Unlock()
    fmt.Println("over...")
}

func main() {
    var p Person
    p.mux.Lock()
    go Reduce(p)
    p.mux.Unlock()
    fmt.Println(111)
    for {
    }
}

結果: Reduce 協程會死鎖.

看到這裡我們就能發現, 當 struct 巢狀了 Mutex, 如果以值傳遞的方式使用時, 有可能造成程式死鎖, 有可能需要互斥的變數並不能達到互斥.

所以不管是單獨使用 不能複製 型別的變數, 還是巢狀在 struct 裡面都不能值傳遞的方式使用.

不能複製原因

以 Mutex 為例,

type Mutex struct {
    state int32
    sema  uint32
}

我們使用 Mutex 是為了不同 goroutine 之間共享某個變數, 所以需要讓這個變數做到能夠互斥, 不然該變數就會被互相被覆蓋. Mutex 底層是由 state sema 控制的, 當 Mutex 變數被複制時, Mutex 的 state, sema 當時的狀態也被複制走了, 但是由於不同 goroutine 之間的 Mutex 已經不是同一個變數了, 這樣就會造成要麼某個 goroutine 死鎖或者不同 goroutine 共享的變數達不到互斥

struct 如何與 不可複製 的型別一塊使用 ?

由上面可以看到不只是 sync 相關型別變數自身不能被複制,而且 sturct 巢狀 不可複製 型別變數時, 同樣也不能被複制. 但是如果我將巢狀的不可複製變數改成指標型別變數呢, 是不是就解決了不能複製的問題 ?

type URL struct {
    Ip       string
    mux      *sync.RWMutex
}

這樣確實解決了上述的不能複製問題. 但也引出了另外一個問題. 眾所周知 Go 沒有建構函式, 這就導致我們使用 URL 的時候都需要先去初始化 RWMutex, 不然就會造成同樣很嚴重的空指標問題, 這個問題同樣很棘手,也許哪個位置就忘了初始化這個 RWMutex.

根據 google groups 的討論 How to copy a struct which contains a mutex?, 以及我檢視了Kubernets 的相關原始碼 (這裡只是一個例子, 裡面還有很多), 發現大家的觀點基本上都是一致的, 都不會去選用 struct 去巢狀指標型別的變數, 由此不建議 struct 去巢狀 不可複製的 的指標型別變數. 最重要的原因: 沒有一個工具能去準確的檢測空指標.

所以一般情況下, 當 struct 巢狀了 不可複製 型別的變數時, 都需要傳遞的是 struct 型別變數的指標.

如何防止複製了不該複製的變數呢?

由於 Go 並不提供過載的功能, 所以並不能做到去過載 struct 的相關的被複制的方法. 但是 Go 的槽點就來了, Go 本身還不提供不能被複制的相關的編譯強約束. 這樣就有可能導致出現不能被複制的型別被複制過後矇混過關. 那我們需要怎麼做呢 ?

Go 提供了另外一個工具 go vet 來做補充, 用這個工具是能檢測出來不可複製的型別是否被複制過.

func main() {
    var amux sync.Mutex
    b := amux
    b.Lock()
    b.Unlock()
}
$ go vet main.go
# command-line-arguments
./main.go:7:7: assignment copies lock value to b: sync.Mutex

我們怎麼把 go vet 與 日常開發結合起來呢?

  1. 目前的 Goland, Vscode 都會整合 go vet 的相關功能, 如果你強迫症比較嚴重的話, 你就能發現有相關提示.
  2. 把 go vet 與 CI 流程結合起來, 其實更推薦使用 golangci-lint 這個 lint 工具來做 CI

Go 還提供一段 noCopy 的程式碼, 當你的 struct 有不能被複制的需求的時候, 可以加入這段程式碼

type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

這段程式碼依然是給 go vet 來使用的.

說到這裡, 禁止複製不能被複制的變數, 這個明明能在 編譯期 就杜絕的事情, 為啥非要搞出來工具來做這個事情呢? 有點想不通.

不可複製的型別有哪些?

Go 提供的不可複製的型別基本上就是 sync 包內的所有型別: atomic.Value, sync.Mutex, sync.Cond, sync.RWMutex, sync.Map, sync.Pool, sync.WaitGroup.

這些內建的不可被複制的型別當被複制時配合 go vet 是能夠發現的. 但是下面這種場景你是否遇見過?

package main

import "fmt"

type Books struct {
    someImportantData []int
}

func DoSomething(otherBook Books) Books {
    newBook := otherBook
    // do something
    for k := range newBook.someImportantData {
        newBook.someImportantData[k]++ // just like this
    }
    return otherBook
}

func main() {
    oldBook := Books{
        someImportantData: make([]int, 0, 100),
    }

    oldBook.someImportantData = append(oldBook.someImportantData, 1, 2, 3)
    fmt.Println("before DoSomething, old book:", oldBook.someImportantData)
    DoSomething(oldBook)
    fmt.Println("after DoSomething, old book:", oldBook.someImportantData)
    // 使用oldBook.someImportantData 繼續做某些事情
}

結果:

before DoSomething, old book: [1 2 3]
after DoSomething, old book: [2 3 4]

這個場景其實我們可能不經意間就會遇到. oldBook 是我們要操作的資料, 但是通過 DoSomething` 後, oldBook.someImportantData 的值可能就被改掉了, 這可能並不是我們所期待的. 由於 DoSomething 是通過複製傳遞的, 可能我們並不能很敏感關注到這個點, 導致程式繼續往下走邏輯可能就錯了. 我們是不是可以設定 Books 為不可複製呢 ? 這樣可以讓 go vet 幫助我們發現這些問題

最後的最後

你是否這樣初始化過 WaitGroup ?

wg := sync.WaitGroup{}

這個算不算是被複制了呢, 歡迎留言討論.

歡迎關注我的公眾號

當 Go struct 遇上 Mutex

更多原創文章乾貨分享,請關注公眾號
  • 當 Go struct 遇上 Mutex
  • 加微信實戰群請加微信(註明:實戰群):gocnio