Go 部落格

Go Race Detector 簡介

Dmitri Vyukov 和 Andrew Gerrand
2013 年 6 月 26 日

簡介

競爭條件 是程式設計中最隱微且難以捉摸的錯誤之一。競爭條件通常會造成不定期且難以理解的錯誤,而這些錯誤常常發作在程式碼部署到生產環境很久之後。儘管 Go 的並行機制能讓我們輕鬆寫出乾淨的並行程式碼,但這並不能防止競爭條件發生。因此,還是需要審慎、勤勉和測試。而且工具也能提供協助。

我們很開心地宣布 Go 1.1 包含 競爭條件偵測器,這是 Go 程式碼中一個尋找競爭條件的新工具。目前適用於具備 64 位元 x86 處理器的 Linux、OS X 和 Windows 系統。

競態偵測器是基於 C/C++ 的 ThreadSanitizer 執行時期函式庫,已用於偵測 Google 內部程式碼庫和 Chromium 中的許多錯誤。這項技術已於 2012 年 9 月整合至 Go;從那時起,它已偵測出標準函式庫中的 42 個競態條件。現在,它已成為我們的連續建置流程的一部分,並持續偵測競態情況,只要情況發生就會捕捉到。

運作方式

競態偵測器已整合至 go 工具鏈。當設定 -race 命令列旗標時,編譯器會利用能記錄存取時間和方式的程式碼,讓所有記憶體存取都有提示,而執行時期函式庫會監控非同步地存取共用變數的情況。偵測到此類「競態」行為時,會印出一個警告。(請參閱 本文,深入瞭解演算法的詳細資訊。)

根據其設計,競態偵測器僅能在實際觸發執行程式碼時偵測競態情況,這表示在現實的工作負載下執行已啟用競態的二進位檔很重要。不過,已啟用競態的二進位檔可能耗費原本記憶體和 CPU 的十倍,因此不切實際地總是啟用競態偵測器。一個解決這個困境的方法是在啟用競態偵測器的狀態下執行某些測試。負載測試和整合測試是可以考慮的選項,因為它們會執行程式的並行部分。另一種使用實際工作負載的方法是在執行中的伺服器池內部署單一已啟用競態的執行個體。

使用競態偵測器

競態偵測器完全整合至 Go 工具鏈。如要以已啟用競態偵測器的狀態建置您的程式碼,只需將 -race 旗標新增到命令列

$ go test -race mypkg    // test the package
$ go run -race mysrc.go  // compile and run the program
$ go build -race mycmd   // build the command
$ go install -race mypkg // install the package

如要親自試用競態偵測器,請將此範例程式複製到 racy.go

package main

import "fmt"

func main() {
    done := make(chan bool)
    m := make(map[string]string)
    m["name"] = "world"
    go func() {
        m["name"] = "data race"
        done <- true
    }()
    fmt.Println("Hello,", m["name"])
    <-done
}

然後在啟用競態偵測器的狀態下執行它

$ go run -race racy.go

範例

以下是兩個競態偵測器偵測出實際問題的範例。

範例 1:Timer.Reset

第一個範例是競態偵測器發現的真實錯誤的簡化版本。它使用計時器在 0 到 1 秒之間的隨機持續時間後印出訊息。它重複執行此動作五秒鐘。它使用 time.AfterFunc 建立第一個訊息的 Timer,然後使用 Reset 方法來排定下一個訊息,每次都重新使用 Timer


package main

import (
    "fmt"
    "math/rand"
    "time"
)


10  func main() {
11      start := time.Now()
12      var t *time.Timer
13      t = time.AfterFunc(randomDuration(), func() {
14          fmt.Println(time.Now().Sub(start))
15          t.Reset(randomDuration())
16      })
17      time.Sleep(5 * time.Second)
18  }
19  
20  func randomDuration() time.Duration {
21      return time.Duration(rand.Int63n(1e9))
22  }
23  

這看起來像合理的程式碼,但在某些情況下它會以驚人的方式失敗

panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x8 pc=0x41e38a]

goroutine 4 [running]:
time.stopTimer(0x8, 0x12fe6b35d9472d96)
    src/pkg/runtime/ztime_linux_amd64.c:35 +0x25
time.(*Timer).Reset(0x0, 0x4e5904f, 0x1)
    src/pkg/time/sleep.go:81 +0x42
main.func·001()
    race.go:14 +0xe3
created by time.goFunc
    src/pkg/time/sleep.go:122 +0x48

問題出在哪裡?在啟用競態偵測器的狀態下執行程式可以更清楚

==================
WARNING: DATA RACE
Read by goroutine 5:
  main.func·001()
     race.go:16 +0x169

Previous write by goroutine 1:
  main.main()
      race.go:14 +0x174

Goroutine 5 (running) created at:
  time.goFunc()
      src/pkg/time/sleep.go:122 +0x56
  timerproc()
     src/pkg/runtime/ztime_linux_amd64.c:181 +0x189
==================

競爭偵測器顯示問題:不同 goroutine 非同步讀寫變數 t。如果初始計時器持續時間極小,計時器功能可能會在主 goroutine 已指派一個值給 t 之前執行,所以會使用 nil t 呼叫 t.Reset

為修正競爭狀態,我們將程式碼變更為僅從主 goroutine 讀取和寫入變數 t


package main

import (
    "fmt"
    "math/rand"
    "time"
)


10  func main() {
11      start := time.Now()
12      reset := make(chan bool)
13      var t *time.Timer
14      t = time.AfterFunc(randomDuration(), func() {
15          fmt.Println(time.Now().Sub(start))
16          reset <- true
17      })
18      for time.Since(start) < 5*time.Second {
19          <-reset
20          t.Reset(randomDuration())
21      }
22  }
23  

func randomDuration() time.Duration {
    return time.Duration(rand.Int63n(1e9))
}

在這裡,主 goroutine 全權負責設定和重設 Timer t,而新的重置頻道以執行緒安全的方式傳達需要重設計時器的需求。

較簡單但效率較低的做法是 避免重複使用計時器

範例 2:ioutil.Discard

第二個範例比較隱晦。

ioutil 套件的 Discard 物件會實作 io.Writer,但是會捨棄所有寫入其中的資料。可以將它想像成 /dev/null:一個傳送您需要讀取但不需要儲存的資料的地方。它通常與 io.Copy 搭配使用來排放讀取器,如下所示

io.Copy(ioutil.Discard, reader)

2011 年 7 月,Go 團隊注意到,以這種方式使用 Discard 效率不佳:Copy 函數每次呼叫時都會配置一個 32 kB 內部緩衝區,但是當與 Discard 搭配使用時,緩衝區是不必要的,因為我們只是丟棄讀取的資料。我們認為這樣慣例性的使用 CopyDiscard 不應該這麼昂貴。

修正方法很簡單。如果指定的 Writer 會實作 ReadFrom 方法,則類似的 Copy 呼叫

io.Copy(writer, reader)

會委派給這種潛在更有效率的呼叫

writer.ReadFrom(reader)

我們為 Discard 的基礎類型 新增一個 ReadFrom 方法,該類型有一個在其所有使用者之間共用的內部緩衝區。我們知道這理論上是一個競爭狀態,但是由於應該會丟棄對緩衝區的所有寫入,所以我們認為這並不重要。

在實作競爭偵測器時,它立即 將這段程式碼標示為 有競爭狀態。同樣地,我們認為這段程式碼可能有問題,但認定這個競爭狀態並非「真實」。為了避免在我們的組建中出現「錯誤警示」,我們實作了一個 沒有競爭狀態的版本,而只有在競爭偵測器正在執行時才會啟用。

但幾個月後,Brad 遇上一個 令人沮喪且奇怪的錯誤。經過幾天的除錯,他將問題縮小到由 ioutil.Discard 導致的真實競爭狀態。

以下是 io/ioutil 中已知的競爭狀態程式碼,其中 DiscarddevNull,會在所有使用者之間共用一個單一緩衝區。

var blackHole [4096]byte // shared buffer

func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
    readSize := 0
    for {
        readSize, err = r.Read(blackHole[:])
        n += int64(readSize)
        if err != nil {
            if err == io.EOF {
                return n, nil
            }
            return
        }
    }
}

Brad 的程式碼包含一個 trackDigestReader 類型,它包裝了一個 io.Reader 並且記錄它所讀取內容的雜湊摘要。

type trackDigestReader struct {
    r io.Reader
    h hash.Hash
}

func (t trackDigestReader) Read(p []byte) (n int, err error) {
    n, err = t.r.Read(p)
    t.h.Write(p[:n])
    return
}

例如,它可以用於在讀取一個檔案時計算其 SHA-1 雜湊值

tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf("File hash: %x", tdr.h.Sum(nil))

在某些情況下,可能沒有地方可以寫入資料(但仍然需要對檔案進行雜湊),因此將使用 Discard

io.Copy(ioutil.Discard, tdr)

但在這種情況下,blackHole 緩衝區不只是個黑洞;它是一個合法的地方,可用於在從來源 io.Reader 讀取資料,並將它寫入 hash.Hash 之間儲存資料。在有多個 goroutine 同時對檔案進行雜湊時,每個檔案都共用同一個 blackHole 緩衝區,競爭條件會在讀取和雜湊之間損毀資料。沒有發生任何錯誤或恐慌,但雜湊值是錯誤的。真糟糕!

func (t trackDigestReader) Read(p []byte) (n int, err error) {
    // the buffer p is blackHole
    n, err = t.r.Read(p)
    // p may be corrupted by another goroutine here,
    // between the Read above and the Write below
    t.h.Write(p[:n])
    return
}

錯誤最後在 這裡 被修復,方法是為每個 ioutil.Discard 用法提供一個唯一的緩衝區,消除了對共用緩衝區的競爭條件。

結論

競爭偵測器是一個強大的工具,可以用來檢查並行程式的正確性。它不會發布錯誤的正向訊息,所以要認真對待它的警告。但它的成效只和你的測試一樣好;你必須確保這些測試徹底鍛鍊了程式碼的並行屬性,以便競爭偵測器可以發揮功用。

你還在等什麼?立即對你的程式碼執行 "go test -race"

下一篇文章: 第一個 Go 程式碼
前一篇文章: Go 和 Google Cloud 平臺
部落格索引