資料競速偵測
簡介
資料競速是並發系統中最常見,也是最難除錯的錯誤類型。當兩個 goroutine 並發地存取同一個變數,且其中至少一個存取為寫入時,就會發生資料競速。請參閱 Go 記憶體模型 以取得詳細資料。
以下是可能導致崩潰和記憶體損毀的資料競速範例
func main() { c := make(chan bool) m := make(map[string]string) go func() { m["1"] = "a" // First conflicting access. c <- true }() m["2"] = "b" // Second conflicting access. <-c for k, v := range m { fmt.Println(k, v) } }
用法
為協助診斷此類錯誤,Go 內建了資料競速偵測器。若要使用它,請將 -race
旗標新增至 go 指令
$ go test -race mypkg // to test the package $ go run -race mysrc.go // to run the source file $ go build -race mycmd // to build the command $ go install -race mypkg // to install the package
報告格式
當競速偵測器在程式中找到資料競速時,會印出報告。報告包含衝突存取的堆疊追蹤,以及涉及 goroutine 建立的堆疊。以下是範例
WARNING: DATA RACE Read by goroutine 185: net.(*pollServer).AddFD() src/net/fd_unix.go:89 +0x398 net.(*pollServer).WaitWrite() src/net/fd_unix.go:247 +0x45 net.(*netFD).Write() src/net/fd_unix.go:540 +0x4d4 net.(*conn).Write() src/net/net.go:129 +0x101 net.func·060() src/net/timeout_test.go:603 +0xaf Previous write by goroutine 184: net.setWriteDeadline() src/net/sockopt_posix.go:135 +0xdf net.setDeadline() src/net/sockopt_posix.go:144 +0x9c net.(*conn).SetDeadline() src/net/net.go:161 +0xe3 net.func·061() src/net/timeout_test.go:616 +0x3ed Goroutine 185 (running) created at: net.func·061() src/net/timeout_test.go:609 +0x288 Goroutine 184 (running) created at: net.TestProlongTimeout() src/net/timeout_test.go:618 +0x298 testing.tRunner() src/testing/testing.go:301 +0xe8
選項
GORACE
環境變數設定競速偵測器選項。格式為
GORACE="option1=val1 option2=val2"
選項如下
-
log_path
(預設stderr
):競態偵測寫入其報告到名為log_path.pid
的檔案。特殊名稱stdout
和stderr
會造成產生報告分別寫入標準輸出和標準錯誤。 -
exitcode
(預設66
):偵測到競態後用於結束的結束狀態。 -
strip_path_prefix
(預設""
):將此字首從所有報告檔案路徑中移除,以讓報告更簡潔。 -
history_size
(預設1
):每個 goroutine 的記憶體存取歷程記錄是32K * 2**history_size 元素
。增加此值可以避免報告中出現「無法還原堆疊」錯誤,但會增加記憶體用量。 -
halt_on_error
(預設0
):控制程式在報告第一個資料競態後是否結束。 -
atexit_sleep_ms
(預設1000
):結束前主 goroutine 睡眠的毫秒數。
範例
$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race
排除測試
以 -race
旗標建置時,go
指令會定義額外的 建置標籤 race
。您可以在執行競態偵測時使用標籤來排除部分程式碼和測試。以下是部分範例:
// +build !race package foo // The test contains a data race. See issue 123. func TestFoo(t *testing.T) { // ... } // The test fails under the race detector due to timeouts. func TestBar(t *testing.T) { // ... } // The test takes too long under the race detector. func TestBaz(t *testing.T) { // ... }
如何使用
首先,使用競態偵測執行您的測試(go test -race
)。競態偵測僅能找到執行期間發生的競態,因此它無法找到未執行的程式碼路徑中的競態。如果您的測試覆蓋率不完整,您可以在實際工作負載下執行以 -race
建置的二進位檔,找出更多競態。
典型的資料競態
以下是部分典型的資料競態。所有競態都可以透過競態偵測偵測。
迴圈計數器競態
func main() { var wg sync.WaitGroup wg.Add(5) var i int for i = 0; i < 5; i++ { go func() { fmt.Println(i) // Not the 'i' you are looking for. wg.Done() }() } wg.Wait() }
函式文字中的變數 i
是迴圈使用的相同變數,因此 goroutine 中的讀取會與迴圈增量競爭。(此程式通常會印出 55555,而不是 01234。)複製變數可以修正程式
func main() { var wg sync.WaitGroup wg.Add(5) var i int for i = 0; i < 5; i++ { go func(j int) { fmt.Println(j) // Good. Read local copy of the loop counter. wg.Done() }(i) } wg.Wait() }
意外共用變數
// 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("file1") if err != nil { res <- err } else { go func() { // This err is shared with the main goroutine, // so the write races with the write below. _, err = f1.Write(data) res <- err f1.Close() }() } f2, err := os.Create("file2") // The second conflicting write to err. if err != nil { res <- err } else { go func() { _, err = f2.Write(data) res <- err f2.Close() }() } return res }
修正方法是在 goroutine 中引入新變數(請注意 :=
的使用方法)
... _, err := f1.Write(data) ... _, err := f2.Write(data) ...
未受保護的全球變數
如果以下程式碼是由幾個 goroutine 呼叫的,它會在 service
地圖上產生競態。對相同地圖進行同時讀取和寫入並不安全
var service map[string]net.Addr func RegisterService(name string, addr net.Addr) { service[name] = addr } func LookupService(name string) net.Addr { return service[name] }
保護存取方式會讓程式變得安全,可透過 mutex 來保護存取
var ( service map[string]net.Addr serviceMu sync.Mutex ) func RegisterService(name string, addr net.Addr) { serviceMu.Lock() defer serviceMu.Unlock() service[name] = addr } func LookupService(name string) net.Addr { serviceMu.Lock() defer serviceMu.Unlock() return service[name] }
未受保護的原始變數
資料競態也可能發生在原始類型的變數上(bool
、int
、int64
等),如以下範例所示
type Watchdog struct{ last int64 } func (w *Watchdog) KeepAlive() { w.last = time.Now().UnixNano() // First conflicting access. } func (w *Watchdog) Start() { go func() { for { time.Sleep(time.Second) // Second conflicting access. if w.last < time.Now().Add(-10*time.Second).UnixNano() { fmt.Println("No keepalives for 10 seconds. Dying.") os.Exit(1) } } }() }
即使是這樣的「無害的」資料競合也可能導致難以偵錯的問題,這些問題是由記憶體存取的非原子性、編譯器最佳化的干擾,或處理器記憶體存取的排序問題所造成。
修正這種競合的典型方法是使用通道或互斥鎖。若要保留無鎖定的行為,也可以使用 sync/atomic
套件。
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) } } }() }
未同步的發送和關閉作業
此範例有說明,在同一個通道上進行未同步的發送和關閉作業也可能是競合狀態
c := make(chan struct{}) // or buffered channel // The race detector cannot derive the happens before relation // for the following send and close operations. These two operations // are unsynchronized and happen concurrently. go func() { c <- struct{}{} }() close(c)
根據 Go 記憶體模型,在通道上進行發送會在對應的接收作業完成之前發生。若要同步發送和關閉作業,請使用可以保證在關閉之前先執行發送的接收作業
c := make(chan struct{}) // or buffered channel go func() { c <- struct{}{} }() <-c close(c)
需求
競合偵測器需要啟用 cgo,而且在非 Darwin 系統上需要安裝 C 編譯器。競合偵測器支援的平台包括:linux/amd64
、linux/ppc64le
、linux/arm64
、linux/s390x
、freebsd/amd64
、netbsd/amd64
、darwin/amd64
、darwin/arm64
,以及 windows/amd64
。
在 Windows 系統上,競合偵測器執行時間環境會受到安裝的 C 編譯器版本影響;在 Go 1.21 中,要建立有 `-race` 的程式需要一個包含 mingw-w64
執行時間環境版本 8 或更高版本的 C 編譯器。您可以透過使用引數 `--print-file-name libsynchronization.a` 來呼叫 C 編譯器來測試。較新的相容 C 編譯器會為此函式庫列印出完整路徑,而較舊的 C 編譯器只會回應引數。
執行時間環境的負擔
執行競合偵測的成本會因程式而異,但對於一般的程式而言,記憶體用量可能會增加 5-10 倍,而執行時間可能會增加 2-20 倍。
競合偵測器目前會為每一個 defer
和 recover
陳述式另外配置 8 個位元組。這些額外的配置會保留直到 goroutine 退出為止。這些額外配置直到 goroutine 退出為止,都無法恢復。這表示如果有一個執行時間很長的 goroutine 在定期發出 defer
和 recover
呼叫,這個程式所使用的記憶體可能會無限制地增加。這些記憶體配置不會顯示在 runtime.ReadMemStats
或 runtime/pprof
的輸出當中。