Go 譯文之競態檢測器 race

做者: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 個實際的案例。

案例 1:Timer.Reset

這是一個由競態檢測器發現的真實的 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)
}

代碼很是簡潔易懂,缺點呢,就是效率相對不高。

案例 2:ioutil.Discard

這個案例的問題隱藏更深。

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。


波羅學的微信公衆號

相關文章
相關標籤/搜索