這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
理解競態對於並發編程來說很重要,如果能通過某種手段來瞭解程式中存在的競態,以便進一步的調整避免競態,也是非常有效最佳化手段。Go 1.1 的工具鏈引入了競態檢測器可以檢測並展示程式中存在的競態情況。Go 團隊撰寫了博文詳細介紹了這一工具的原理和使用。原文在此《Introducing the Go Race Detector》。
————翻譯分隔線————
Go 的競態檢測器
Dmitry Vyukov 和 Andrew Gerrand
競態條件幾乎是最為隱蔽和難以發現的程式錯誤。它們通常會導致詭異且無法解釋的錯誤,尤其是在代碼已經部署到生產環境很長時間之後。雖然 Go 的並發機制使得編寫清晰的並發代碼變得容易,但是它們無法避免競態條件。認真、勤快的測試是必須的。而工具可以給予協助。
我們很高興的宣布 Go 1.1 包含了競態檢測器,一個用於發現 Go 代碼中的競態條件的新工具。它當前在 64 位元 x86 處理器的 Linux、MacOS 和 64 位元 x86 處理器的 Windows 系統中可用。
該競態檢測器是基於 C/C++ ThreadSanitizer 執行階段程式庫,這個庫已經被用在 Google 的基礎代碼和 Chromium 中,並檢測出了許多錯誤。該技術於 2012 年 12 月被整合到了 Go;從那時開始,它已經檢測出標準庫的 42 處競態代碼。它現在是持續整合過程的一部分,會持續的發現競態條件並捕捉。
工作原理
這個競態檢測器被整合進 go 工具鏈。當命令列參數 -race 被設定時,編譯器會記錄代碼中所有的記憶體訪問,包括在什麼時候、是如何訪問的,而執行階段程式庫會監視非同步的共用變數。當這種“下流”的行為被檢測到,會列印一個警告。(參閱這個文章瞭解演算法的細節。)
由於其設計,這個競態檢測器只能檢測到被正在啟動並執行代碼觸發的競態條件,這意味著讓開啟競態的執行檔案運行在真實的工作壓力下很重要。然而,開啟競態的執行檔案會使用十倍的 CPU 和記憶體,因此一直啟用競態檢測器是不現實的。一個避免這個困境的辦法是在競態檢測器開啟的情況下運行一些測試。由於壓力測試和整合測試更傾向於對並發的部分進行驗證,因此是不錯的選擇。另一種在生產環境工作負載下使用的辦法是部署一個開啟競態的執行個體到一個服務池中去(譯註:TcpCopy 或許也是個不錯的選擇)。
競態檢測器的使用
競態檢測器已經被完全整合到了 Go 工具鏈中。為了編譯開啟競態檢測器的代碼,只需要增加命令列參數 -race 標識:
如果要親手嘗試一下競態檢測器的話,擷取並執行這個例子程式:
$ go get -race code.google.com/p/go.blog/support/racy$ racy
執行個體
這裡有兩個競態檢測器捕捉到的真實案例。
執行個體 1:Timer.Reset
第一個簡單的例子是競態檢測器發現的一個真實的錯誤。它使用一個計時器在隨機 0 到 1 秒的延遲後列印一條訊息。然後重複這一過程五秒鐘。它使用 time.AfterFunc 建立了一個 Timer 用於第一條訊息,然後用 Reset 方法來調度接下來的訊息,每次都複用這個 Timer。
package mainimport ( "fmt" "math/rand" "time")func main() { start := time.Now() var t *time.Timer t = time.AfterFunc(randomDuration(), func() { fmt.Println(time.Now().Sub(start)) t.Reset(randomDuration()) }) time.Sleep(5 * time.Second)}func randomDuration() time.Duration { return time.Duration(rand.Int63n(1e9)) }
這看起來是很合理的代碼,但是在一些特定的環境下,它會用一種奇異的方式出錯:
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:14 +0x169
Previous write by goroutine 1:
main.main()
race.go:15 +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 讀寫的代碼:
func main() { start := time.Now() reset := make(chan bool) var t *time.Timer t = time.AfterFunc(randomDuration(), func() { fmt.Println(time.Now().Sub(start)) reset <- true }) for time.Since(start) < 5*time.Second { <-reset t.Reset(randomDuration()) }}
這裡的主 goroutine 對設定和重設 Timer t 負全責,而新添加的 channel reset 用來通訊,以確保線上程安全的途徑下重設定時器。
一個簡單而更有效率的辦法是避免重用定時器。
執行個體 2:ioutil.Discard
第二個例子更加微妙。
ioutil 包的 Discard 對象實現了 io.Writer,來丟棄寫入它的所有資料。可以將其比作 /dev/null:一個可以向其發送資料,而不用儲存它們的地方。這通常可以用在 io.Copy 排空一個 Reader,就像這樣:
io.Copy(ioutil.Discard, reader)
回到 2011 年七月,Go 團隊留意到這種方法來使用 Discard 效率低下:Copy 函數在每次調用的時候都會在內部分配 32 kB 的緩衝區,但是當使用 Discard 的時候只要將讀取到的資料丟棄,這時緩衝區不是必要的。我們認為對於 Copy 和 Discard 的這種慣例用法不應當有如此大的開銷。
修複的辦法很簡單。如果提供的 Writer 實現了 ReadFrom 方法,Copy 就會像這樣調用:
io.Copy(writer, reader)
這是一個隱含的更加有效率的調用的委託:
writer.ReadFrom(reader)
通過向 Discard 的底層類型添加了 ReadFrom 方法,這樣內部的緩衝區就在其所有的使用者之間共用了。我們知道理論上這是一個競態條件,不過既然所有寫道緩衝區的資料都會被丟棄,所以沒有覺得這會很重要。
當競態檢測器被實現以後,它立刻標識出這段代碼有問題。同樣,我們認為這個代碼不會有什麼問題,而認為競態條件並不“真實”。為了避免在構建中出現的“偽錯誤”,我們實現了一個沒有競態的版本,只在競態檢測器開啟的時候工作。
但是幾個月後,Brad 遇到一個令人沮喪的奇怪錯誤。經過若干天的調試,最終定位到了由於 ioutil.Discard 引起的真正的競態條件上。
這裡是 io/ioutil 裡已知有競態的代碼,Discard 是 devNull 且在所有使用者之間共用同一個緩衝區。
var blackHole [4096]byte // 共用的緩衝區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 緩衝區,競態條件通過搞亂讀取和雜湊之間的資料再次證明了它的存在。沒有錯誤或者 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”吧!