Goブログ
Go Race Detectorのご紹介
はじめに
競合状態は、最も悪質で分かりにくいプログラミングエラーの1つです。通常、コードが本番環境にデプロイされた後、長い間、不安定で不可解なエラーを引き起こします。Goの並行処理メカニズムはクリーンな並行コードを簡単に記述できるようにしますが、競合状態を防ぐわけではありません。注意、勤勉さ、テストが必要です。そしてツールが役立ちます。
Go 1.1には、Goコードの競合状態を見つけるための新しいツールであるRace Detectorが含まれていることをお知らせできて嬉しく思います。現在、64ビットx86プロセッサを搭載したLinux、OS X、Windowsシステムで使用できます。
Race Detectorは、C/C++のThreadSanitizerランタイムライブラリに基づいており、Googleの内部コードベースやChromiumの多くのエラー検出に使用されてきました。このテクノロジーは2012年9月にGoと統合されました。それ以来、標準ライブラリで42件の競合状態を検出しました。現在、これは継続的ビルドプロセスの一部であり、競合状態が発生すると引き続き検出されます。
動作方法
Race DetectorはGoツールチェーンと統合されています。-race
コマンドラインフラグが設定されている場合、コンパイラはメモリへのすべてのアクセスを、メモリがいつどのようにアクセスされたかを記録するコードでインストルメント化し、ランタイムライブラリは共有変数への非同期アクセスを監視します。このような「競合状態」の動作が検出されると、警告が表示されます。(アルゴリズムの詳細については、この記事を参照してください。)
その設計により、Race Detectorは実行中のコードによって実際にトリガーされた場合にのみ競合状態を検出できます。これは、Race Detectorを有効にしたバイナリを現実的なワークロードで実行することが重要であることを意味します。ただし、Race Detectorを有効にしたバイナリは、CPUとメモリの消費量が10倍になる可能性があるため、常にRace Detectorを有効にすることは現実的ではありません。このジレンマからの1つの解決策は、Race Detectorを有効にしていくつかのテストを実行することです。ロードテストと統合テストは、コードの並行部分を実行する傾向があるため、適した候補です。本番ワークロードを使用するもう1つのアプローチは、実行中のサーバーのプール内で、Race Detectorを有効にした単一のインスタンスをデプロイすることです。
Race Detectorの使用
Race DetectorはGoツールチェーンと完全に統合されています。Race Detectorを有効にしてコードをビルドするには、コマンドラインに-race
フラグを追加するだけです。
$ go test -race mypkg // test the package
$ go run -race mysrc.go // compile and run the program
$ go build -race mycmd // build the command
$ go install -race mypkg // install the package
Race Detectorを自分で試すには、このサンプルプログラムをracy.go
にコピーします。
package main
import "fmt"
func main() {
done := make(chan bool)
m := make(map[string]string)
m["name"] = "world"
go func() {
m["name"] = "data race"
done <- true
}()
fmt.Println("Hello,", m["name"])
<-done
}
次に、Race Detectorを有効にして実行します。
$ go run -race racy.go
例
Race Detectorによって検出された実際の2つの問題の例を次に示します。
例1:Timer.Reset
最初の例は、Race Detectorによって発見された実際のバグの簡略化されたバージョンです。0〜1秒の間のランダムな期間後にメッセージを出力するためにタイマーを使用します。5秒間繰り返し実行します。最初のメッセージのTimer
を作成するためにtime.AfterFunc
を使用し、次にReset
メソッドを使用して次のメッセージをスケジュールし、毎回Timer
を再利用します。
10 func main() { 11 start := time.Now() 12 var t *time.Timer 13 t = time.AfterFunc(randomDuration(), func() { 14 fmt.Println(time.Now().Sub(start)) 15 t.Reset(randomDuration()) 16 }) 17 time.Sleep(5 * time.Second) 18 } 19 20 func randomDuration() time.Duration { 21 return time.Duration(rand.Int63n(1e9)) 22 } 23
これは妥当なコードのように見えますが、特定の状況下では驚くべき方法で失敗します。
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
何が起きているのでしょうか?Race Detectorを有効にしてプログラムを実行すると、さらに明確になります。
==================
WARNING: DATA RACE
Read by goroutine 5:
main.func·001()
race.go:16 +0x169
Previous write by goroutine 1:
main.main()
race.go:14 +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
==================
Race Detectorは、問題を示しています。異なるゴルーチンからの変数t
の非同期読み取りと書き込みです。初期タイマーの時間が非常に短い場合、メインゴルーチンがt
に値を割り当てる前にタイマー関数が実行される可能性があり、そのためt.Reset
への呼び出しはnilのt
を使用して行われます。
競合状態を修正するために、メインゴルーチンのみから変数t
の読み取りと書き込みを行うようにコードを変更します。
10 func main() { 11 start := time.Now() 12 reset := make(chan bool) 13 var t *time.Timer 14 t = time.AfterFunc(randomDuration(), func() { 15 fmt.Println(time.Now().Sub(start)) 16 reset <- true 17 }) 18 for time.Since(start) < 5*time.Second { 19 <-reset 20 t.Reset(randomDuration()) 21 } 22 } 23
ここでは、メインゴルーチンがTimer
t
の設定とリセットを完全に担当し、新しいリセットチャネルがスレッドセーフな方法でタイマーのリセットの必要性を伝達します。
より単純ですが効率の低いアプローチは、タイマーの再利用を避けることです。
例2:ioutil.Discard
2番目の例は、さらに微妙です。
ioutil
パッケージのDiscard
オブジェクトはio.Writer
を実装しますが、それに書き込まれたすべてのデータを破棄します。/dev/null
のようなものです。読み取る必要があるが保存したくないデータを送信する場所です。これは、io.Copy
を使用して、次のようにリーダーをドレインするために一般的に使用されます。
io.Copy(ioutil.Discard, reader)
2011年7月、Goチームは、この方法でDiscard
を使用すると非効率的であることに気付きました。Copy
関数は呼び出されるたびに内部の32 kBバッファを割り当てますが、Discard
で使用すると、読み取ったデータを単に破棄するため、バッファは不要です。Copy
とDiscard
のこの慣習的な使用は、それほど高価ではないと考えました。
修正は簡単でした。指定されたWriter
がReadFrom
メソッドを実装する場合、次のようなCopy
呼び出しは、
io.Copy(writer, reader)
この潜在的により効率的な呼び出しに委任されます。
writer.ReadFrom(reader)
私たちはReadFrom
メソッドをDiscard
の基になる型に追加しました。これは、すべてのユーザー間で共有される内部バッファを持っています。これは理論的には競合状態であることを知っていましたが、バッファへのすべての書き込みは破棄されるはずなので、重要ではないと考えました。
Race Detectorが実装されると、すぐにこのコードに競合状態のフラグが立てられました。再び、コードに問題がある可能性があると判断しましたが、競合状態は「実際のものではない」と判断しました。ビルドでの「誤検知」を回避するために、Race Detectorが実行されている場合にのみ有効になる非競合状態バージョンを実装しました。
しかし、数ヶ月後、Bradはイライラする奇妙なバグに遭遇しました。数日間のデバッグの後、彼はioutil.Discard
によって引き起こされた実際の競合状態に絞り込みました。
これは、ioutil
内の既知の競合状態コードです。ここで、Discard
はすべてのユーザー間で単一のバッファを共有するdevNull
です。
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のプログラムには、io.Reader
をラップし、読み取ったもののハッシュダイジェストを記録するtrackDigestReader
型が含まれています。
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
への書き込みの間のデータを格納する正当な場所です。複数のゴルーチンが同時にファイルをハッシュし、同じblackHole
バッファを共有すると、競合状態は読み取りとハッシュの間のデータを破損させることによって現れました。エラーやパニックは発生しませんでしたが、ハッシュは間違っていました。厄介な!
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
の各使用に一意のバッファを提供することで、共有バッファの競合状態が解消されました。
結論
Race Detectorは、並行プログラムの正確性をチェックするための強力なツールです。誤検知は発生しません。そのため、警告を真剣に受け止めましょう。ただし、テストと同じくらいしか機能しません。Race Detectorが機能するように、コードの並行プロパティを徹底的に実行するテストを作成する必要があります。
さあ、今日からコードで"go test -race"
を実行してみましょう!
次の記事:最初のGoプログラム
前の記事:GoとGoogle Cloud Platform
ブログインデックス