はじめに
競合状態は、最も陰湿で捉えにくいプログラミングエラーの一つです。通常、コードが本番環境にデプロイされた後、長い時間が経ってから不規則で神秘的な失敗を引き起こします。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
フラグを追加するだけです:
$ 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番目の例はより微妙です。
``````bash
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の基になる型に追加しました。これは、すべてのユーザー間で共有される内部バッファを持っています。これは理論的には競合状態であることを知っていましたが、すべてのバッファへの書き込みは捨てられるべきであるため、重要ではないと考えました。
競合検出器が実装されると、すぐにこのコードを[https://golang.org/issue/3970]として競合状態であるとフラグを立てました。再び、私たちはそのコードが問題を引き起こす可能性があると考えましたが、競合状態は「実際のもの」ではないと判断しました。「偽陽性」をビルドで回避するために、競合検出器が実行されているときのみ有効な[非競合バージョン](https://golang.org/cl/6624059)を実装しました。
しかし数ヶ月後、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のプログラムには、trackDigestReader
型が含まれており、io.Reader
をラップし、読み取ったもののハッシュダイジェストを記録します。
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
}
バグは最終的に[https://golang.org/cl/7011047]によって修正され、`````ioutil.Discard`````の各使用にユニークなバッファを与え、共有バッファ上の競合状態を排除しました。
結論
競合検出器は、並行プログラムの正確性をチェックするための強力なツールです。偽陽性を発生させないため、その警告を真剣に受け止めてください。しかし、それはあなたのテストの質に依存します。競合検出器がその仕事をするために、コードの並行特性を徹底的に行使することを確認する必要があります。
何を待っていますか?今日、"go test -race"
をあなたのコードで実行してください!