Goブログ

Go Race Detectorのご紹介

Dmitry VyukovとAndrew Gerrand
2013年6月26日

はじめに

競合状態は、最も悪質で分かりにくいプログラミングエラーの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を再利用します。


package main

import (
    "fmt"
    "math/rand"
    "time"
)


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の読み取りと書き込みを行うようにコードを変更します。


package main

import (
    "fmt"
    "math/rand"
    "time"
)


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  

func randomDuration() time.Duration {
    return time.Duration(rand.Int63n(1e9))
}

ここでは、メインゴルーチンが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で使用すると、読み取ったデータを単に破棄するため、バッファは不要です。CopyDiscardのこの慣習的な使用は、それほど高価ではないと考えました。

修正は簡単でした。指定されたWriterReadFromメソッドを実装する場合、次のような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
ブログインデックス