這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
原文串連http://blog.golang.org/race-detector
介紹:
競爭條件是最狡詐的、最難以找到的編程錯誤。通常,在代碼被布置到生產環境很久以後,它們才會出現並且造成奇怪的、神秘的錯誤。儘管Go語言的並發機制使得更容易的編寫出乾淨的並發代碼,依然無法避免競爭條件的出現。小心、勤勉以及測試是必須的。工具也可以提供協助。
我們很高興的宣布Go1.1包含了一個競爭檢測器,一個全新的工具,用於在Go代碼中找到競爭條件。該工具當前在Linux,OS X 和Windows平台可用,只要CPU是64位的x86架構。
競爭檢測器基於C/C++的ThreadSanitizer 執行階段程式庫,該庫在Google內部代碼基地和Chromium找到許多錯誤。這個技術在2012年九月整合到Go中,從那時開始,它已經在標準庫中檢測到42個競爭條件。現在,它已經是我們持續構建過程的一部分,當競爭條件出現時,它會繼續捕捉到這些錯誤。
工作原理
競爭檢測器整合在go工具鏈中。當使用了-race作為命令列參數後,編譯器會插樁代碼,使得所有代碼在訪問記憶體時,會記錄訪問時間和方法。同時執行階段程式庫會觀察對共用變數的未同步訪問。當這種競爭行為被檢測到,就會輸出一個警告資訊。(查看這裡瞭解演算法的具體細節)
由於設計原因,競爭檢測器只有在被啟動並執行代碼觸發時,才能檢測到競爭條件,因此在現實的負載條件下運行是非常重要的。但是由於代碼插樁,程式會使用10倍的CPU和記憶體,所以總是運行插樁後的程式是不現實的。矛盾的解決方案之一就是使用插樁後的程式來運行測試。負載測試和整合測試是好的候選,因為它們傾向於檢驗代碼的並發部分。另外的方法是將單個插樁後的程式布置到運行伺服器組成的池中,並且給予生產環境的負載。
使用方法:
競爭檢測器已經完全整合到Go工具鏈中,僅僅添加-race標誌到命令列就使用了檢測器。
$ go test -race mypkg // 測試包$ go run -race mysrc.go // 編譯和運行程式$ go build -race mycmd // 構建程式$ go install -race mypkg // 安裝程式
擷取以下樣本程式,就可以嘗試使用競爭檢測器
$ go get -race code.google.com/p/go.blog/support/racy$ racy
這裡有兩個例子,是由競爭檢測器捕捉到的而上報產生的話題。
第1個例子: Timer.Reset
第一個例子是一個由競爭檢測器發生的真實bug的簡化版。它使用一個計時器來隨機的時間間隔後列印訊息,該間隔在0-1秒之間。該過程重複5秒。代碼使用了time.AfterFunc來建立一個計時器,該計時器用於初次列印;隨後使用Reset方法來安排下一條訊息,每次都重用了該計時器。
9 func main() {10 start := time.Now()11 var t *time.Timer12 t = time.AfterFunc(randomDuration(), func() {13 fmt.Println(time.Now().Sub(start)) 14 t.Reset(randomDuration())15 })16 time.Sleep(5 * time.Second)17 }18 19 func randomDuration() time.Duration {20 return time.Duration(rand.Int63n(1e9))21 }
看上去代碼很合理,但是在特定約束下,程式以一種令人驚奇的方式失敗了。
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 +0x25time.(*Timer).Reset(0x0, 0x4e5904f, 0x1) src/pkg/time/sleep.go:81 +0x42main.func·001() race.go:14 +0xe3created by time.goFunc src/pkg/time/sleep.go:122 +0x48
發生了什麼事情? 運行插樁後的代碼將給予更深入的資訊。
==================WARNING: DATA RACERead by goroutine 5: main.func·001() race.go:14 +0x169Previous write by goroutine 1: main.main() race.go:15 +0x174Goroutine 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有未同步的讀、寫操作。如果最初的計時間隔非常小,那麼計時器函數有可能在main線程給t賦值前就調用,所以t.Reset就是在值為nil的t上調用。
為了修正這個競爭條件,更改代碼使得對t的讀寫只能從main線程發出。
9 func main() {10 start := time.Now()11 reset := make(chan bool)12 var t *time.Timer13 t = time.AfterFunc(randomDuration(), func() {14 fmt.Println(time.Now().Sub(start))15 reset <- true16 })17 for time.Since(start) < 5*time.Second {18 <-reset19 t.Reset(randomDuration())20 }21 }
這裡main線程完全負責設定、重設定時器t,一個全新的channel以安全執行緒的方式通訊是否定時器需要重設
另一種更簡單但是效率比較低的方法就是避免重用定時器
第2個例子: ioutil.Discard
第二個例子更加微妙
ioutil包中的Discard對象實現了介面io.Writer,但是拋棄了所有寫入的資料。可以將其當做/dev/null:用於發送需要讀取但不想儲存的資料。該對象被廣泛使用於io.Copy(),目的是耗盡讀取端的資料,如:
io.Copy(ioutil.Discard, reader)
在2011年7年,Go團隊注意到這樣使用Discard比較低效:Copy函數每次調用時會分配一個大小為32K位元組的內部緩衝,當Discadrd作為參數時,這個緩衝就沒用了。我們認為這種將Copy和Discard組合使用,不應該這樣浪費。
解決方案很簡單。如果指定的寫入端實現了ReadFrom方法,那麼如下的Copy調用
io.Copy(writer, reader)
將被託管給可能更高效的調用
writer.ReadFrom(reader)
我們添加了一個ReadFrom()方法到Discard的底層類型,該類型有一個在所有使用者之間共用的內部緩衝。我們知道在理論上,存在著競爭條件,但是所有寫入該緩衝的資料被拋棄,所以我們認為這沒有什麼大不了的。
當實現了競爭檢測器後,它立即檢測出這段代碼存在問題。我們再次認為這是多此一舉,決定將這個競爭條件定義為非真實的。為了避免在構建過程中的該誤判,我們實現了一個非競爭版本,該版本只在競爭檢測器運行時可用。
但是幾個月後,Brad遇到了一個奇怪的bug。在經過數日的調試後,他將問題定位於一個有ioutil.Discrd引發的真實競爭條件上。
這就是已經的競爭發生的代碼,Discard是一個devNull,在所有使用者間共用快取。
var blackHole [4096]byte // shared bufferfunc (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,並且記錄了已經閱讀內容的hash digest
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 雜湊值:F
tdr := trackDigestReader{r: file, h: sha1.New()}io.Copy(writer, tdr)fmt.Printf("File hash: %x", tdr.h.Sum(nil))
某些情況下,資料並無寫入,但是依然需要hash該檔案,所以使用了Discard
io.Copy(ioutil.Discard, tdr)
但是在這種情況下,blackHole緩衝已經不再是一個黑洞,它是一個合法的地方,用於在讀取到資料到寫入hash.Hash之間儲存資料。在多線程並發hash檔案時,每個線程共用blackHole緩衝,競爭條件就會在讀取和hash之間破壞資料。沒有錯誤或者panic發生,但是hash值出錯了。多麼令人不愉快!
func (t trackDigestReader) Read(p []byte) (n int, err error) { // the buffer p is blackHole n, err = t.r.Read(p) // p可能被其餘線程破壞 // t.h.Write(p[:n]) return}
這個bug最終被修正,方法是給予每個ioutil.Discard使用者一個獨一無二的內部緩衝,消除了在共用快取上的競爭條件。
結論
在檢測並發編程的正確性方面,競爭檢測器是一個威力強大的工具。它不會誤判,要認真對待其發出的警告。但是它依賴於你的測試,必須保證測試覆蓋了代碼的並發部分,這樣檢測器才能完成任務。
你還在等待什麼?今天就在你的代碼上運行"go test -race"