做者:Dmitry Vyukov,Andrew Gerrand | Introducing the Go Race Detectorlinux
第三篇 Go 官方博客譯文,主要是關於 Go 內置的競態條件檢測工具。它能夠有效地幫助咱們檢測併發程序的正確性。使用很是簡單,只需在 go 命令加上 -race 選項便可。git
本文最後介紹了兩個真實場景下的競態案例,第一個案例相對比較簡單。重點在於第二個案例,這個案例比較難以理解,在原文的基礎上,我也簡單作了些補充,不知道是否把問題講的足夠清楚。同時,這個案例也告訴咱們,任什麼時候候咱們都須要重視檢測器給咱們的提示,由於一不當心,你就可能爲本身留下一個大坑。github
在程序世界中,競態條件是一種潛伏深且很難發現的錯誤,若是將這樣的代碼部署線上,常會產生各類謎通常的結果。Go 對併發的支持讓咱們能很是簡單就寫出支持併發的代碼,但它並不能阻止競態條件的發生。golang
本文將會介紹一個工具幫助咱們實現它。緩存
Go 1.1 加入了一個新的工具,競態檢測器,它可用於檢測 Go 程序中的競態條件。當前,運行在 x86_64 處理器的 Linux、Mac 或 Windows 下可用。bash
競態檢測器的實現基於 C/C++ 的 ThreadSanitizer 運行時庫,ThreadSanitier 在 Googgle 已經被用在一些內部基礎庫以及 Chromium上,而且幫助發現了不少有問題的代碼。微信
ThreadSanitier 這項技術在 2012 年 9 月被集成到了 Go 上,它幫助檢測出了標準庫中的 42 個競態問題。它如今已是 Go 構建流程中的一部分,當競態條件出現,將會被它捕獲。併發
競態檢測器集成在 Go 工具鏈,當命令行設置了 -race 標誌,編譯器將會經過代碼記錄全部的內存訪問,什麼時候以及如何被訪問,運行時庫也會負責監視共享變量的非同步訪問。當檢測到競態行爲,警告信息會把打印出來。(具體詳情閱讀 文章)負載均衡
這樣的設計致使競態檢測只能在運行時觸發,這也意味着,真實環境下運行 race-enabled 的程序就變得很是重要,但 race-enabled 程序耗費的 CPU 和內存一般是正常程序的十倍,在真實環境下一直啓用競態檢測是很是不切合實際的。dom
是否感覺到了一陣涼涼的氣息?
這裏有幾個解決方案能夠嘗試。好比,咱們能夠在 race-enabled 的狀況下執行測試,負載測試和集成測試是個不錯的選擇,它偏向於檢測代碼中可能存在的併發問題。另外一種方式,能夠利用生產環境的負載均衡,選擇一臺服務部署啓動競態檢測的程序。
競態檢測器已經集成到 Go 工具鏈中了,只要設置 -race 標誌便可啓用。命令行示例以下:
$ go test -race mypkg $ go run -race mysrc.go $ go build -race mycmd $ go install -race mypkg
經過具體案例體驗下,安裝運行一個命令,步驟以下:
$ go get -race golang.org/x/blog/support/racy $ racy
接下來,咱們介紹 2 個實際的案例。
這是一個由競態檢測器發現的真實的 bug,這裏將演示的是它的一個簡化版本。咱們經過 timer 實現隨機間隔(0-1 秒)的消息打印,timer 會重複執行 5 秒。
首先,經過 time.AfterFunc 建立 timer,定時的間隔從 randomDuration 函數得到,定時函數打印消息,而後經過 timer 的 Reset 方法重置定時器,重複利用。
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)) }
咱們的代碼看起來一切正常。但在屢次運行後,咱們會發如今某些特定狀況下可能會出現以下錯誤:
anic: 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
什麼緣由?啓用下競態檢測器測試下吧,你會恍然大悟的。
$ go run -race main.go ================== 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 ==================
結果顯示,程序中存在 2 個 goroutine 非同步讀寫變量 t。若是初始定時時間很是短,就可能出如今主函數還未對 t 賦值,定時函數已經執行,而此時 t 仍然是 nil,沒法調用 Reset 方法。
咱們只要把變量 t 的讀寫移到主 goroutine 執行,就能夠解決問題了。以下:
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()) } }
main goroutine 徹底負責 timer 的初始化和重置,重置信號經過一個 channel 負責傳遞。
固然,這個問題還有個更簡單直接的解決方案,避免重用定時器便可。示例代碼以下:
package main import ( "fmt" "math/rand" "time" ) func main() { start := time.Now() var f func() f = func() { fmt.Println(time.Now().Sub(start)) time.AfterFunc(time.Duration(rand.Int63n(1e9)), f) } time.AfterFunc(time.Duration(rand.Int63n(1e9)), f) time.Sleep(5 * time.Second) }
代碼很是簡潔易懂,缺點呢,就是效率相對不高。
這個案例的問題隱藏更深。
ioutil 包中的 Discard 實現了 io.Writer 接口,不過它會丟棄全部寫入它的數據,可類比 /dev/null。可在咱們須要讀取數據但又不許備保存的場景下使用。它經常會和 io.Copy 結合使用,實現抽空一個 reader,以下:
io.Copy(ioutil.Discard, reader)
時間回溯至 2011 年,當時 Go 團隊注意以這種方式使用 Discard 效率不高,Copy 函數每次調用都會在內部分配 32 KB 的緩存 buffer,但咱們只是要丟棄讀取的數據,並不須要分配額外的 buffer。咱們認爲,這種習慣性的用法不該該這樣耗費資源。
解決方案很是簡單,若是指定的 Writer 實現了 ReadFrom 方法,io.Copy(writer, reader) 調用內部將會把讀取工做委託給 writer.ReadFrom(reader) 執行。
Discard 類型增長 ReadFrom 方法共享一個 buffer。到這裏,咱們天然會想到,這裏理論上會存在競態條件,但由於寫入到 buffer 中的數據會被馬上丟棄,咱們就沒有過重視。
競態檢測器完成後,這段代碼馬上被標記爲競態的,查看 issues/3970。這促使咱們再一次思考,這段代碼是否真的存在問題呢,但結論依然是這裏的競態不影響程序運行。爲了不這種 "假的警告",咱們實現了 2 個版本的 black_hole buffer,競態版本和無競態版本。而無競態版只會其在啓用競態檢測器的時候啓用。
black_hole.go,無競態版本。
// +build race package ioutil // Replaces the normal fast implementation with slower but formally correct one. func blackHole() []byte { return make([]byte, 8192) }
black_hole_race.go,競態版本。
// +build !race package ioutil var blackHoleBuf = make([]byte, 8192) func blackHole() []byte { return blackHoleBuf }
但幾個月後,Brad 遇到了一個迷之 bug。通過幾天調試,終於肯定了緣由所在,這是一個由 ioutil.Discard 致使的競態問題。
實際代碼以下:
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 類型字段,和 io.Reader 中信息的 hash 摘要。
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 HASH。
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 buffer 並不是僅僅是一個黑洞,它同時也是 io.Reader 和 hash.Hash 之間傳遞數據的紐帶。當多個 goroutine 併發執行文件 hash 時,它們所有共享一個 buffer,Read 和 Write 之間的數據就可能產生相應的衝突。No error 而且 No panic,可是 hash 的結果是錯的。就是如此可惡。
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 }
最終,經過爲每個 io.Discard 提供惟一的 buffer,咱們解決了這個 bug,排除了共享 buffer 的競態條件。代碼以下:
var blackHoleBuf = make(chan []byte, 1) func blackHole() []byte { select { case b := <-blackHoleBuf: return b default: } return make([]byte, 8192) } func blackHolePut(p []byte) { select { case blackHoleBuf <- p: default: } }
iouitl.go 中的 devNull ReadFrom 方法也作了相應修正。
func (devNull) ReadFrom(r io.Reader) (n int64, err error) { buf := blackHole() defer blackHolePut(buf) readSize := 0 for { readSize, err = r.Read(buf) // other }
經過 defer 將使用完的 buffer 從新發送至 blackHoleBuf,由於 channel 的 size 爲 1,只能複用一個 buffer。並且經過 select 語句,咱們在沒有可用 buffer 的狀況下,建立新的 buffer。
競態檢測器,一個很是強大的工具,在併發程序的正確性檢測方面有着很重要的地位。它不會發出假的提示,認真嚴肅地對待它的每條警示很是必要。但它並不是萬能,仍是須要以你對併發特性的正確理解爲前提,才能真正地發揮出它的價值。
試試吧!開始你的 go test -race。