The Go Blog

Go レース検出器の紹介

Dmitry Vyukov と Andrew Gerrand
2013 年 6 月 26 日

はじめに

競合状態は、最も巧妙でとらえどころのないプログラミングエラーの 1 つです。これらは通常、コードが本番環境にデプロイされてからずっと後に、不安定で不可解な障害を引き起こします。Go の並行処理メカニズムは、クリーンな並行コードを簡単に記述できるようにしますが、競合状態を防止するわけではありません。注意、勤勉さ、そしてテストが必要です。そしてツールが役立ちます。

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

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

仕組み

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

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

レース検出器の使用

レース検出器は Go ツールチェーンと完全に統合されています。レース検出器を有効にしてコードをビルドするには、コマンドラインに -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

レース検出器を自分で試すには、このサンプルプログラムを 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
}

次に、レース検出器を有効にして実行します。

$ go run -race racy.go

レース検出器によって捕捉された実際の問題の 2 つの例を次に示します。

例 1: Timer.Reset

最初の例は、レース検出器によって発見された実際のバグを簡略化したものです。これは、0 から 1 秒の間のランダムな時間間隔でメッセージを出力するためにタイマーを使用します。これを 5 秒間繰り返し行います。最初のメッセージのために time.AfterFunc を使用して Timer を作成し、その後 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

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

==================
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
==================

レース検出器は問題を示しています。異なるゴルーチンから変数 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)

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

レース検出器が実装されたとき、このコードはすぐに競合状態にあるとフラグが立てられました。繰り返しますが、コードに問題がある可能性を検討しましたが、競合状態は「実際の」ものではないと判断しました。ビルドでの「偽陽性」を避けるために、レース検出器が実行されている場合にのみ有効になる競合状態のないバージョンを実装しました。

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

以下は io/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 を使用するたびに一意のバッファを割り当てることで修正され、共有バッファの競合状態が解消されました。

結論

レース検出器は、並行プログラムの正確性をチェックするための強力なツールです。偽陽性は発生しないため、警告を真剣に受け止めてください。しかし、それはテストの良し悪しに左右されます。レース検出器がその役割を果たせるように、コードの並行プロパティを徹底的にテストするようにしてください。

何を待っていますか?今すぐコードで "go test -race" を実行してください!

次の記事: 最初の Go プログラム
前の記事: Go と Google Cloud Platform
ブログインデックス