1.前言

雖然在 go 中,併發程式設計十分簡單, 只需要使用 go func() 就能啟動一個 goroutine 去做一些事情,但是正是由於這種簡單我們要十分當心,不然很容易出現一些莫名其妙的 bug 或者是你的服務由於不知名的原因就重啟了。 而最常見的bug是關於執行緒安全方面的問題,比如對同一個map進行寫操作。

2.資料競爭

執行緒安全是否有什麼辦法檢測到呢?

答案就是 data race tag,go 官方早在 1.1 版本就引入了資料競爭的檢測工具,我們只需要在執行測試或者是編譯的時候加上 -race 的 flag 就可以開啟資料競爭的檢測

使用方式如下

go test -race main.go
go build -race

不建議在生產環境 build 的時候開啟資料競爭檢測,因為這會帶來一定的效能損失(一般記憶體5-10倍,執行時間2-20倍),當然 必須要 debug 的時候除外。

建議在執行單元測試時始終開啟資料競爭的檢測

2.1 示例一

執行如下程式碼,檢視每次執行的結果是否一樣

2.1.1 測試

  1. 程式碼

    package main
    
    import (
    "fmt"
    "sync"
    ) var wg sync.WaitGroup
    var counter int func main() {
    // 多跑幾次來看結果
    for i := 0; i < 100000; i++ {
    run()
    }
    fmt.Printf("Final Counter: %d\n", counter)
    } func run() {
    // 開啟兩個 協程,操作
    for i := 1; i <= 2; i++ {
    wg.Add(1)
    go routine(i)
    }
    wg.Wait()
    } func routine(id int) {
    for i := 0; i < 2; i++ {
    value := counter
    value++
    counter = value
    }
    wg.Done()
    }
  2. 執行三次檢視結果,分別是

    Final Counter: 399950
    Final Counter: 399989
    Final Counter: 400000
  3. 原因分析:每一次執行的時候,都使用 go routine(i) 啟動了兩個 goroutine,但是並沒有控制它的執行順序,並不能滿足順序一致性記憶體模型。

    當然由於種種不確定性,所有肯定不止這兩種情況,

2.1.2 data race 檢測

上面問題的出現在上線後如果出現bug會非常難定位,因為不知道到底是哪裡出現了問題,所以我們就要在測試階段就結合 data race 工具提前發現問題。

  1. 使用
    go run -race ./main.go
  2. 輸出: 執行結果發現輸出記錄太長,除錯的時候並不直觀,結果如下
    main.main()
    D:/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x44
    ==================
    Final Counter: 399987
    Found 1 data race(s)
    exit status 66

2.1.3 data race 配置

在官方的文件當中,可以通過設定 GORACE 環境變數,來控制 data race 的行為, 格式如下:


GORACE="option1=val1 option2=val2"

可選配置見下表

  1. 配置
    GORACE="halt_on_error=1 strip_path_prefix=/mnt/d/gopath/src/Go_base/daily_test/data_race/01_data_race" go run -race ./demo.go
  2. 輸出:
    ==================
    WARNING: DATA RACE
    Read at 0x00000064d9c0 by goroutine 8:
    main.routine()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:31 +0x47 Previous write at 0x00000064d9c0 by goroutine 7:
    main.routine()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:33 +0x64 Goroutine 8 (running) created at:
    main.run()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75
    main.main()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c Goroutine 7 (finished) created at:
    main.run()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75
    main.main()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c
    ==================
    exit status 66
  3. 說明:結果告訴可以看出 31 行這個地方有一個 goroutine 在讀取資料,但是呢,在 33 行這個地方又有一個 goroutine 在寫入,所以產生了資料競爭。

    然後下面分別說明這兩個 goroutine 是什麼時候建立的,已經當前是否在運行當中。

2.2 迴圈中使用goroutine引用臨時變數

  1. 程式碼如下:

    func main() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
    go func() {
    fmt.Println(i)
    wg.Done()
    }()
    }
    wg.Wait()
    }
  2. 輸出:常見的答案就是會輸出 5 個 5,因為在 for 迴圈的 i++ 會執行的快一些,所以在最後列印的結果都是 5

    這個答案不能說不對,因為真的執行的話大概率也是這個結果,但是不全。因為這裡本質上是有資料競爭,在新啟動的 goroutine 當中讀取 i 的值,在 main 中寫入,導致出現了 data race,這個結果應該是不可預知的,因為我們不能假定 goroutine 中 print 就一定比外面的 i++ 慢,習慣性的做這種假設在併發程式設計中是很有可能會出問題的

  3. 正確示例:將 i 作為引數傳入即可,這樣每個 goroutine 拿到的都是拷貝後的資料

    func main() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
    go func(i int) {
    fmt.Println(i)
    wg.Done()
    }(i)
    }
    wg.Wait()
    }

2.3 引起變數共享

  1. 程式碼

    package main
    
    import "os"
    
    func main() {
    ParallelWrite([]byte("xxx"))
    } // ParallelWrite writes data to file1 and file2, returns the errors.
    func ParallelWrite(data []byte) chan error {
    res := make(chan error, 2) // 建立/寫入第一個檔案
    f1, err := os.Create("/tmp/file1") if err != nil {
    res <- err
    } else {
    go func() {
    // 下面的這個函式在執行時,是使用err進行判斷,但是err的變數是個共享的變數
    _, err = f1.Write(data)
    res <- err
    f1.Close()
    }()
    } // 建立寫入第二個檔案n
    f2, err := os.Create("/tmp/file2")
    if err != nil {
    res <- err
    } else {
    go func() {
    _, err = f2.Write(data)
    res <- err
    f2.Close()
    }()
    }
    return res
    }
  2. 分析: 使用 go run -race main.go 執行,可以發現這裡報錯的地方是,21 行和 28 行,有 data race,這裡主要是因為共享了 err 這個變數

    root@failymao:/mnt/d/gopath/src/Go_base/daily_test/data_race# go run -race demo2.go
    ==================
    WARNING: DATA RACE
    Write at 0x00c0001121a0 by main goroutine:
    main.ParallelWrite()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:28 +0x1dd
    main.main()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84 Previous write at 0x00c0001121a0 by goroutine 7:
    main.ParallelWrite.func1()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:21 +0x94 Goroutine 7 (finished) created at:
    main.ParallelWrite()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:19 +0x336
    main.main()
    /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84
    ==================
    Found 1 data race(s)
    exit status 66
  3. 修正: 在兩個goroutine中使用新的臨時變數

    _, err := f1.Write(data)
    ...
    _, err := f2.Write(data)
    ...

2.4 不受保護的全域性變數

  1. 所謂全域性變數是指,定義在多個函式的作用域之外,可以被多個函式或方法進行呼叫,常用的如 map資料型別

    // 定義一個全域性變數 map資料型別
    var service = map[string]string{} // RegisterService RegisterService
    // 用於寫入或更新key-value
    func RegisterService(name, addr string) {
    service[name] = addr
    } // LookupService LookupService
    // 用於查詢某個key-value
    func LookupService(name string) string {
    return service[name]
    }
  2. 要寫出可測性比較高的程式碼就要少用或者是儘量避免用全域性變數,使用 map 作為全域性變數比較常見的一種情況就是配置資訊。關於全域性變數的話一般的做法就是加鎖,或者也可以使用 sync.Ma

    var (
    service map[string]string
    serviceMu sync.Mutex
    ) func RegisterService(name, addr string) {
    serviceMu.Lock()
    defer serviceMu.Unlock()
    service[name] = addr
    } func LookupService(name string) string {
    serviceMu.Lock()
    defer serviceMu.Unlock()
    return service[name]
    }

2.5 未受保護的成員變數

  1. 一般講成員變數 指的是資料型別為結構體的某個欄位。 如下一段程式碼

    type Watchdog struct{
    last int64
    } func (w *Watchdog) KeepAlive() {
    // 第一次進行賦值操作
    w.last = time.Now().UnixNano()
    } func (w *Watchdog) Start() {
    go func() {
    for {
    time.Sleep(time.Second)
    // 這裡在進行判斷的時候,很可能w.last更新正在進行
    if w.last < time.Now().Add(-10*time.Second).UnixNano() {
    fmt.Println("No keepalives for 10 seconds. Dying.")
    os.Exit(1)
    }
    }
    }()
    }
  2. 使用原子操作atomiic

    type Watchdog struct{
    last int64 } func (w *Watchdog) KeepAlive() {
    // 修改或更新
    atomic.StoreInt64(&w.last, time.Now().UnixNano())
    } func (w *Watchdog) Start() {
    go func() {
    for {
    time.Sleep(time.Second)
    // 讀取
    if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
    fmt.Println("No keepalives for 10 seconds. Dying.")
    os.Exit(1)
    }
    }
    }()
    }

2.6 介面中存在的資料競爭

  1. 一個很有趣的例子 Ice cream makers and data races

    package main
    
    import "fmt"
    
    type IceCreamMaker interface {
    // Great a customer.
    Hello()
    } type Ben struct {
    name string
    } func (b *Ben) Hello() {
    fmt.Printf("Ben says, \"Hello my name is %s\"\n", b.name)
    } type Jerry struct {
    name string
    } func (j *Jerry) Hello() {
    fmt.Printf("Jerry says, \"Hello my name is %s\"\n", j.name)
    } func main() {
    var ben = &Ben{name: "Ben"}
    var jerry = &Jerry{"Jerry"}
    var maker IceCreamMaker = ben var loop0, loop1 func() loop0 = func() {
    maker = ben
    go loop1()
    } loop1 = func() {
    maker = jerry
    go loop0()
    } go loop0() for {
    maker.Hello()
    }
    }
  2. 這個例子有趣的點在於,最後輸出的結果會有這種例子

    Ben says, "Hello my name is Jerry"
    Ben says, "Hello my name is Jerry"

    這是因為我們在maker = jerry這種賦值操作的時候並不是原子的,在上一篇文章中我們講到過,只有對 single machine word 進行賦值的時候才是原子的,雖然這個看上去只有一行,但是 interface 在 go 中其實是一個結構體,它包含了 type 和 data 兩個部分,所以它的複製也不是原子的,會出現問題

    type interface struct {
    Type uintptr // points to the type of the interface implementation
    Data uintptr // holds the data for the interface's receiver
    }

    這個案例有趣的點還在於,這個案例的兩個結構體的記憶體佈局一模一樣所以出現錯誤也不會 panic 退出,如果在裡面再加入一個 string 的欄位,去讀取就會導致 panic,但是這也恰恰說明這個案例很可怕,這種錯誤在線上實在太難發現了,而且很有可能會很致命。

3. 總結

  1. 使用 go build -race main.gogo test -race ./ 可以測試程式程式碼中是否存在資料競爭問題

    • 善用 data race 這個工具幫助我們提前發現併發錯誤
    • 不要對未定義的行為做任何假設,雖然有時候我們寫的只是一行程式碼,但是 go 編譯器可能後面做了很多事情,並不是說一行寫完就一定是原子的
    • 即使是原子的出現了 data race 也不能保證安全,因為我們還有可見性的問題,上篇我們講到了現代的 cpu 基本上都會有一些快取的操作。
    • 所有出現了 data race 的地方都需要進行處理

4 參考

  1. https://lailin.xyz/post/go-training-week3-data-race.html#典型案例
  2. https://dave.cheney.net/2014/06/27/ice-cream-makers-and-data-races
  3. http://blog.golang.org/race-detector
  4. https://golang.org/doc/articles/race_detector.html
  5. https://dave.cheney.net/2018/01/06/if-aligned-memory-writes-are-atomic-why-do-we-need-the-sync-atomic-package