使用 Go 語言時,謹防鎖拷貝!

四哥水平有限,如有翻譯或理解錯誤,煩請幫忙指出,感謝!

相信大家對 Go 語言的鎖拷貝問題並不陌生,那我們應該如何規範使用 Go 語言才能規避這個問題呢?一起來看作者是如何處理的。

原文如下:


假設我們有一個包含 map 的結構體,現在想在方法中修改這個 map,看下面的例子 [1]:

package main

import "fmt"

type Container struct {
  counters map[string]int
}

func (c Container) inc(name string) {
  c.counters[name]++
}

func main() {
  c := Container{counters: map[string]int{"a": 0, "b": 0}}

  doIncrement := func(name string, n int) {
    for i := 0; i < n; i++ {
      c.inc(name)
    }
  }

  doIncrement("a", 100000)

  fmt.Println(c.counters)
}

Container 包含一個計數器集合,按 name 區分。inc() 會按 name 對相應的計數器執行自增操作 (假設計數器存在)。main() 裏循環多次調用 inc()。

執行上面的代碼,輸出:

map[a:100000 b:0]

現在假設有兩個 goroutine 會併發地調用 inc()。因爲我們必須小心競爭條件,所以使用了 Mutex 保護臨界區。

package main

import (
  "fmt"
  "sync"
  "time"
)

type Container struct {
  sync.Mutex                       // <-- Added a mutex
  counters map[string]int
}

func (c Container) inc(name string) {
  c.Lock()                         // <-- Added locking of the mutex
  defer c.Unlock()
  c.counters[name]++
}

func main() {
  c := Container{counters: map[string]int{"a": 0, "b": 0}}

  doIncrement := func(name string, n int) {
    for i := 0; i < n; i++ {
      c.inc(name)
    }
  }

  go doIncrement("a", 100000)
  go doIncrement("a", 100000)

  // Wait a bit for the goroutines to finish
  time.Sleep(300 * time.Millisecond)
  fmt.Println(c.counters)
}

你期望上面這段代碼會輸出什麼呢?我得到的結果是這樣的:

fatal error: concurrent map writes

goroutine 5 [running]:
runtime.throw(0x4b765b, 0x15)

<...> more goroutine stacks
exit status 2

我們使用 mutex 時已經很小心了,怎麼還會出問題呢?你覺得應該如何修復這個問題?提示:只需要改動一個字符的代碼就可以了!

代碼的問題在於,無論何時調用 inc(),c 都會是一份拷貝,因爲 inc() 是定義在 Container 上,而非 *Container;換句話說,c 是值接受者,而不是指針接受者。因此,inc() 並不能真正修改 c 的內容。

但等等,文章第一個示例是如何工作的?在單協程的例子中,c 也是按值傳遞,但是爲什麼能得到正確的結果 -- 在 inc() 在對 map 所做的修改,能影響到 main() 函數的原始值。這是因爲 map 是引用類型而非值類型。Container 裏保存的是指向 map 的指針,而不是 map 實際的數據。所以即使我們創建 Container 的副本,counters 保存的仍是指向 map 的地址。

所以文章第一個例子也是存在問題的,儘管執行結果沒有問題,但是使用方法不符合官方指南 [2] - 在方法中對原始數據進行修改,則方法應定義成指針方法,而非值方法。這裏對 map 的使用給了我們一種錯誤的提示。作爲練習,可以將第一個示例中的 map 換成 int 類型的計數器,並注意觀察 inc() 的副本是如何遞增的,在 inc() 中對副本做的修改不會影響到 main() 中的原始值。

Mutex 是值類型 (可以看 Go 文檔 [3] 相關的定義,包括註釋裏也明確地提示不能拷貝),複製再使用是錯誤的。複製僅僅是創建了一個新的 mutex,很顯然地,對計數器的互斥使用就失效了。

所以應該這樣修改,定義 inc() 方法時在 Container 之前添加 *:

func (c *Container) inc(name string) {
  c.Lock()
  defer c.Unlock()
  c.counters[name]++
}

c 通過指針方式傳到方法中,指向的 Container 與 main() 函數里面的是同一個。

這個問題並不罕見,事實上,使用 go vet 命令就會發現這個問題:

$ go tool vet method-mutex-value-receiver.go
method-mutex-value-receiver.go:19: inc passes lock by value: main.Container

在我看來,實際上這個問題幫助我們理清了值接收者與指針接收者之間的區別。爲了說明這一點,下面還有一個示例,這個示例與上面兩個示例沒有關係。這個示例使用到了 & 取值符和 %p 格式化輸出變量的地址。

package main

import "fmt"

type Container struct {
  i int
  s string
}

func (c Container) byValMethod() {
  fmt.Printf("byValMethod got &c=%p, &(c.s)=%p\n"&c, &(c.s))
}

func (c *Container) byPtrMethod() {
  fmt.Printf("byPtrMethod got &c=%p, &(c.s)=%p\n", c, &(c.s))
}

func main() {
  var c Container
  fmt.Printf("in main &c=%p, &(c.s)=%p\n"&c, &(c.s))

  c.byValMethod()
  c.byPtrMethod()
}

執行代碼後輸出 (如果在你的機器上執行,輸出的地址可能不同,但是這不影響說明問題):

in main &c=0xc00000a060, &(c.s)=0xc00000a068
byValMethod got &c=0xc00000a080, &(c.s)=0xc00000a088
byPtrMethod got &c=0xc00000a060, &(c.s)=0xc00000a068

main() 函數里創建了 Container 變量 c,並且輸出它的地址和它的成員 s 的地址,接着調用了 Container 的兩個方法。byValMethod() 是值接受者,因爲是原值的拷貝所有打印的地址不一樣。另一方面,byPtrMethod() 是指針接收者,輸出的地址與 main() 函數輸出的地址一致,因爲調用時獲取的是 c 實際的地址,而不是副本。

參考資料

[1]

例子: https://github.com/eliben/code-for-blog/tree/master/2018/go-copying-mutex

[2]

官方指南: https://golang.org/doc/faq#methods_on_values_or_pointers

[3]

Go 文檔: https://golang.org/src/sync/mutex.go

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/zLbd0PclO9g6W0dg7pT3AQ