Go 部落格

Go 競爭檢測器介紹

Dmitry 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

第一個示例是競態檢測器發現的實際 bug 的簡化版本。它使用計時器在 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 賦值之前觸發,因此對 t.Reset 的呼叫是以 nil t 進行的。

為了修復競態條件,我們將程式碼更改為只從主 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 中已知的競態程式碼,其中 Discard 是一個 devNull,它在所有使用者之間共享一個緩衝區。

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 同時雜湊檔案,每個 goroutine 都共享同一個 blackHole 緩衝區時,競態條件透過在讀取和雜湊之間破壞資料而顯現出來。沒有發生錯誤或 panic,但雜湊值是錯誤的。真糟糕!

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 提供一個唯一的緩衝區來修復了這個 bug,消除了共享緩衝區上的競態條件。

結論

競態檢測器是檢查併發程式正確性的強大工具。它不會發出誤報,因此請認真對待其警告。但它僅取決於您的測試;您必須確保它們充分鍛鍊程式碼的併發屬性,以便競態檢測器能夠完成其工作。

還在等什麼?今天就在你的程式碼上執行 "go test -race"

下一篇文章:第一個 Go 程式
上一篇文章:Go 和 Google Cloud Platform
部落格索引