Goの競合検出機能の紹介(Introducing the Go Race Detector)

Goの競合検出機能の紹介 #

Introducing the Go Race Detector by By Dmitry Vyukov and Andrew Gerrand

はじめに #

競合状態は、もっとも陰湿でとらえどころのないプログラミングエラーの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    // パッケージをテストする
$ 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 #

最初の例は、競合検出機能によって発見された実際のバグを単純化したものです。 これはタイマーを使って、0秒から1秒の間のランダムな時間の後にメッセージを表示します。 これを5秒間繰り返します。 time.AfterFuncを使って最初のメッセージ用のタイマーを作成し、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))
}

実行結果(毎回変化します)

947.77941ms
1.029932961s
1.696078782s
1.931088833s
2.21820277s
2.76737009s
3.400339848s
3.732115996s
3.915233212s
4.395512661s

Program exited.

これは一見合理的なコードのように見えますが、ある状況下では驚くべき失敗をします。

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

競合検出機能は、異なるゴルーチンからの変数tの同期されていない読み取りと書き込みという問題を示しています。 タイマーの初期継続時間が非常に小さい場合、メインゴルーチンがtに値を代入する前にタイマー関数が起動する可能性があり、t.Resetの呼び出しはnilなtで行われます。

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

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())
    }
}

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

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

例2: ioutil.Discard #

2つ目の例はもっと微妙です。

ioutilパッケージのDiscardオブジェクトはio.Writerを実装していますが、書き込まれたデータはすべて破棄されます。 これは/dev/nullのようなもので、読む必要があるが保存したくないデータを送る場所だと考えてください。 一般的には、io.Copyと一緒に使って、このようにReaderを排出します。

io.Copy(ioutil.Discard, reader)

2011年7月、囲碁チームはこのような使い方が非効率的であることに気づきました。 Copy関数は呼び出されるたびに32KBの内部バッファを確保しますが、Discardと一緒に使うと、読み取ったデータを捨てるだけなのでバッファは不要になります。 CopyとDiscardの慣用的な使い方は、それほどコストがかかるものではないと考えました。

修正は簡単でした。 指定されたWriterがReadFromメソッドを実装していれば、次のようなCopy呼び出しができます。

io.Copy(writer, reader)

これは、より効率的と思われるこの呼び出しに委ねられます。

writer.ReadFrom(reader)

ReadFromメソッドをDiscardの基礎となる型に追加しました。 この型は、それを使うすべてのユーザー間で共有される内部バッファを持っています。 これは理論的には競合状態になることはわかっていましたが、バッファへの書き込みはすべて捨てられるはずなので、重要だとは思いませんでした。

競合検出機能が実装されたとき、このコードにはすぐに競合が起きやすいというフラグが立ちました。 再度、このコードに問題があるかもしれないと考えましたが、競合状態は「本物」ではないと判断しました。 私たちのビルドで「誤検出」を避けるために、競合検出機能が実行されているときだけ有効になる競合が起きづらいバージョンを実装しました。

しかし数ヵ月後、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” を実行してください!

By Dmitry Vyukov and Andrew Gerrand

あわせて読みたい #