はじめに

競合状態は、最も陰湿で捉えにくいプログラミングエラーの一つです。通常、コードが本番環境にデプロイされた後、長い時間が経ってから不規則で神秘的な失敗を引き起こします。Goの並行処理メカニズムは、クリーンな並行コードを書くのを容易にしますが、競合状態を防ぐことはできません。注意、勤勉さ、テストが必要です。そして、ツールが役立ちます。

Go 1.1には、Goコード内の競合状態を見つけるための新しいツールである競合検出器が含まれていることをお知らせできることを嬉しく思います。これは現在、64ビットx86プロセッサを搭載したLinux、OS X、およびWindowsシステムで利用可能です。

競合検出器は、C/C++のThreadSanitizerランタイムライブラリに基づいており、Googleの内部コードベースやChromiumで多くのエラーを検出するために使用されてきました。この技術は2012年9月にGoに統合され、それ以来、標準ライブラリ内で42の競合を検出しています。現在、これは私たちの継続的なビルドプロセスの一部であり、競合状態が発生するたびにそれを検出し続けています。

動作原理

競合検出器は、Goツールチェーンに統合されています。-raceコマンドラインフラグが設定されると、コンパイラはすべてのメモリアクセスを記録するコードでインスツルメントし、メモリがいつ、どのようにアクセスされたかを記録します。一方、ランタイムライブラリは共有変数への非同期アクセスを監視します。このような「競合的」な動作が検出されると、警告が表示されます。(アルゴリズムの詳細についてはこの記事を参照してください。)

その設計上、競合検出器は、実際にコードを実行することによってトリガーされた場合にのみ競合状態を検出できます。したがって、競合を有効にしたバイナリを現実的なワークロードの下で実行することが重要です。ただし、競合を有効にしたバイナリはCPUとメモリを10倍使用する可能性があるため、常に競合検出器を有効にすることは実用的ではありません。このジレンマを解決する一つの方法は、競合検出器を有効にしていくつかのテストを実行することです。負荷テストや統合テストは、コードの並行部分を行使する傾向があるため、良い候補です。生産ワークロードを使用する別のアプローチは、実行中のサーバーのプール内に単一の競合を有効にしたインスタンスをデプロイすることです。

競合検出器の使用

競合検出器はGoツールチェーンに完全に統合されています。競合検出器を有効にしてコードをビルドするには、コマンドラインに-raceフラグを追加するだけです:

  1. $ go test -race mypkg // test the package
  2. $ go run -race mysrc.go // compile and run the program
  3. $ go build -race mycmd // build the command
  4. $ go install -race mypkg // install the package

競合検出器を自分で試すには、このサンプルプログラムをracy.goにコピーします:

  1. package main
  2. import "fmt"
  3. func main() {
  4. done := make(chan bool)
  5. m := make(map[string]string)
  6. m["name"] = "world"
  7. go func() {
  8. m["name"] = "data race"
  9. done <- true
  10. }()
  11. fmt.Println("Hello,", m["name"])
  12. <-done
  13. }

次に、競合検出器を有効にして実行します:

  1. $ go run -race racy.go

ここに、競合検出器によって捕捉された実際の問題の2つの例があります。

例1: Timer.Reset

最初の例は、競合検出器によって見つかった実際のバグの簡略化されたバージョンです。これは、0から1秒の間のランダムな期間の後にメッセージを印刷するためにタイマーを使用します。これは5秒間繰り返し行います。最初のメッセージのためにtime.AfterFuncを使用してTimerを作成し、その後、次のメッセージをスケジュールするためにResetメソッドを使用し、毎回Timerを再利用します。

  1. package main
  2. import (
  3. "fmt"
  4. "math/rand"
  5. "time"
  6. )
  1. 10 func main() {
  2. 11 start := time.Now()
  3. 12 var t *time.Timer
  4. 13 t = time.AfterFunc(randomDuration(), func() {
  5. 14 fmt.Println(time.Now().Sub(start))
  6. 15 t.Reset(randomDuration())
  7. 16 })
  8. 17 time.Sleep(5 * time.Second)
  9. 18 }
  10. 19
  11. 20 func randomDuration() time.Duration {
  12. 21 return time.Duration(rand.Int63n(1e9))
  13. 22 }
  14. 23

これは合理的なコードのように見えますが、特定の状況下では驚くべき方法で失敗します:

  1. panic: runtime error: invalid memory address or nil pointer dereference
  2. [signal 0xb code=0x1 addr=0x8 pc=0x41e38a]
  3. goroutine 4 [running]:
  4. time.stopTimer(0x8, 0x12fe6b35d9472d96)
  5. src/pkg/runtime/ztime_linux_amd64.c:35 +0x25
  6. time.(*Timer).Reset(0x0, 0x4e5904f, 0x1)
  7. src/pkg/time/sleep.go:81 +0x42
  8. main.func·001()
  9. race.go:14 +0xe3
  10. created by time.goFunc
  11. src/pkg/time/sleep.go:122 +0x48

ここで何が起こっているのでしょうか?競合検出器を有効にしてプログラムを実行すると、より明らかになります:

  1. ==================
  2. WARNING: DATA RACE
  3. Read by goroutine 5:
  4. main.func·001()
  5. race.go:16 +0x169
  6. Previous write by goroutine 1:
  7. main.main()
  8. race.go:14 +0x174
  9. Goroutine 5 (running) created at:
  10. time.goFunc()
  11. src/pkg/time/sleep.go:122 +0x56
  12. timerproc()
  13. src/pkg/runtime/ztime_linux_amd64.c:181 +0x189
  14. ==================

競合検出器は問題を示しています:異なるゴルーチンからの変数tの非同期読み取りと書き込み。初期のタイマーの期間が非常に短い場合、タイマーファンクションはメインゴルーチンがtに値を割り当てる前に発火する可能性があり、そのため、t.Resetへの呼び出しはnilのtで行われます。

競合状態を修正するために、コードを変更して変数tをメインゴルーチンからのみ読み書きするようにします:

  1. package main
  2. import (
  3. "fmt"
  4. "math/rand"
  5. "time"
  6. )
  1. 10 func main() {
  2. 11 start := time.Now()
  3. 12 reset := make(chan bool)
  4. 13 var t *time.Timer
  5. 14 t = time.AfterFunc(randomDuration(), func() {
  6. 15 fmt.Println(time.Now().Sub(start))
  7. 16 reset <- true
  8. 17 })
  9. 18 for time.Since(start) < 5*time.Second {
  10. 19 <-reset
  11. 20 t.Reset(randomDuration())
  12. 21 }
  13. 22 }
  14. 23
  1. func randomDuration() time.Duration {
  2. return time.Duration(rand.Int63n(1e9))
  3. }

ここでは、メインゴルーチンがTimer tの設定とリセットを完全に担当し、新しいリセットチャネルがスレッドセーフな方法でタイマーをリセットする必要性を伝えます。

より簡単ですが効率が悪いアプローチは、タイマーの再利用を避けることです。

例2: ioutil.Discard

2番目の例はより微妙です。

  1. ``````bash
  2. io.Copy(ioutil.Discard, reader)
  3. `

2011年7月、Goチームはこの方法でDiscardを使用することが非効率的であることに気付きました:Copy関数は呼び出されるたびに内部32 kBバッファを割り当てますが、Discardと共に使用されると、バッファは不要です。なぜなら、私たちはただ読み取ったデータを捨てているからです。このCopyDiscardの慣用的な使用はそれほどコストがかかるべきではないと考えました。

修正は簡単でした。与えられたWriterReadFromメソッドを実装している場合、次のようなCopy呼び出しは:

  1. io.Copy(writer, reader)

この潜在的により効率的な呼び出しに委任されます:

  1. writer.ReadFrom(reader)

私たちはReadFromメソッドをDiscardの基になる型に追加しました。これは、すべてのユーザー間で共有される内部バッファを持っています。これは理論的には競合状態であることを知っていましたが、すべてのバッファへの書き込みは捨てられるべきであるため、重要ではないと考えました。

競合検出器が実装されると、すぐにこのコードを[https://golang.org/issue/3970]として競合状態であるとフラグを立てました。再び、私たちはそのコードが問題を引き起こす可能性があると考えましたが、競合状態は「実際のもの」ではないと判断しました。「偽陽性」をビルドで回避するために、競合検出器が実行されているときのみ有効な[非競合バージョン](https://golang.org/cl/6624059)を実装しました。

しかし数ヶ月後、Brad厄介で奇妙なバグに遭遇しました。数日間のデバッグの後、彼はそれをioutil.Discardによって引き起こされた実際の競合状態に絞り込みました。

ここにio/ioutilの既知の競合コードがあります。ここでDiscardは、すべてのユーザー間で単一のバッファを共有するdevNullです。

  1. var blackHole [4096]byte // shared buffer
  2. func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
  3. readSize := 0
  4. for {
  5. readSize, err = r.Read(blackHole[:])
  6. n += int64(readSize)
  7. if err != nil {
  8. if err == io.EOF {
  9. return n, nil
  10. }
  11. return
  12. }
  13. }
  14. }

Bradのプログラムには、trackDigestReader型が含まれており、io.Readerをラップし、読み取ったもののハッシュダイジェストを記録します。

  1. type trackDigestReader struct {
  2. r io.Reader
  3. h hash.Hash
  4. }
  5. func (t trackDigestReader) Read(p []byte) (n int, err error) {
  6. n, err = t.r.Read(p)
  7. t.h.Write(p[:n])
  8. return
  9. }

たとえば、ファイルを読みながらSHA-1ハッシュを計算するために使用される可能性があります:

  1. tdr := trackDigestReader{r: file, h: sha1.New()}
  2. io.Copy(writer, tdr)
  3. fmt.Printf("File hash: %x", tdr.h.Sum(nil))

場合によっては、データを書き込む場所がないが、ファイルをハッシュする必要があるため、Discardが使用されることがあります:

  1. io.Copy(ioutil.Discard, tdr)

しかし、この場合、blackHoleバッファは単なるブラックホールではなく、ソースio.Readerから読み取った後、hash.Hashに書き込む間にデータを保存するための正当な場所です。複数のゴルーチンが同時にファイルをハッシュし、同じblackHoleバッファを共有することで、競合状態は読み取りとハッシュの間でデータを破損させることによって現れました。エラーやパニックは発生しませんでしたが、ハッシュは間違っていました。厄介です!

  1. func (t trackDigestReader) Read(p []byte) (n int, err error) {
  2. // the buffer p is blackHole
  3. n, err = t.r.Read(p)
  4. // p may be corrupted by another goroutine here,
  5. // between the Read above and the Write below
  6. t.h.Write(p[:n])
  7. return
  8. }

バグは最終的に[https://golang.org/cl/7011047]によって修正され、`````ioutil.Discard`````の各使用にユニークなバッファを与え、共有バッファ上の競合状態を排除しました。

結論

競合検出器は、並行プログラムの正確性をチェックするための強力なツールです。偽陽性を発生させないため、その警告を真剣に受け止めてください。しかし、それはあなたのテストの質に依存します。競合検出器がその仕事をするために、コードの並行特性を徹底的に行使することを確認する必要があります。

何を待っていますか?今日、"go test -race"をあなたのコードで実行してください!