Introduction

データ競合は、並行システムにおいて最も一般的でデバッグが難しいバグの一種です。データ競合は、2つのゴルーチンが同じ変数に同時にアクセスし、少なくとも1つのアクセスが書き込みである場合に発生します。詳細については、The Go Memory Modelを参照してください。

ここに、クラッシュやメモリ破損を引き起こす可能性のあるデータ競合の例があります:

  1. func main() {
  2. c := make(chan bool)
  3. m := make(map[string]string)
  4. go func() {
  5. m["1"] = "a" // First conflicting access.
  6. c <- true
  7. }()
  8. m["2"] = "b" // Second conflicting access.
  9. <-c
  10. for k, v := range m {
  11. fmt.Println(k, v)
  12. }
  13. }

Usage

このようなバグを診断するために、Goには組み込みのデータ競合検出器が含まれています。これを使用するには、goコマンドに-raceフラグを追加します:

  1. $ go test -race mypkg // to test the package
  2. $ go run -race mysrc.go // to run the source file
  3. $ go build -race mycmd // to build the command
  4. $ go install -race mypkg // to install the package

Report Format

競合検出器がプログラム内でデータ競合を見つけると、レポートを印刷します。レポートには、競合するアクセスのスタックトレースと、関与するゴルーチンが作成されたスタックが含まれます。以下はその例です:

  1. WARNING: DATA RACE
  2. Read by goroutine 185:
  3. net.(*pollServer).AddFD()
  4. src/net/fd_unix.go:89 +0x398
  5. net.(*pollServer).WaitWrite()
  6. src/net/fd_unix.go:247 +0x45
  7. net.(*netFD).Write()
  8. src/net/fd_unix.go:540 +0x4d4
  9. net.(*conn).Write()
  10. src/net/net.go:129 +0x101
  11. net.func·060()
  12. src/net/timeout_test.go:603 +0xaf
  13. Previous write by goroutine 184:
  14. net.setWriteDeadline()
  15. src/net/sockopt_posix.go:135 +0xdf
  16. net.setDeadline()
  17. src/net/sockopt_posix.go:144 +0x9c
  18. net.(*conn).SetDeadline()
  19. src/net/net.go:161 +0xe3
  20. net.func·061()
  21. src/net/timeout_test.go:616 +0x3ed
  22. Goroutine 185 (running) created at:
  23. net.func·061()
  24. src/net/timeout_test.go:609 +0x288
  25. Goroutine 184 (running) created at:
  26. net.TestProlongTimeout()
  27. src/net/timeout_test.go:618 +0x298
  28. testing.tRunner()
  29. src/testing/testing.go:301 +0xe8

Options

  1. ``````bash
  2. GORACE="option1=val1 option2=val2"
  3. `

オプションは次のとおりです:

  • log_path (デフォルト stderr): 競合検出器は、log_path.pidという名前のファイルにレポートを書き込みます。特別な名前stdoutおよびstderrは、それぞれ標準出力および標準エラーにレポートを書き込む原因となります。
  • exitcode (デフォルト 66): 検出された競合後に終了する際に使用する終了ステータス。
  • strip_path_prefix (デフォルト ""): すべての報告されたファイルパスからこのプレフィックスを削除し、レポートをより簡潔にします。
  • history_size (デフォルト 1): ゴルーチンごとのメモリアクセス履歴は32K * 2**history_size elementsです。この値を増やすことで、レポート内の「スタックの復元に失敗しました」というエラーを回避できますが、メモリ使用量が増加します。
  • halt_on_error (デフォルト 0): 最初のデータ競合を報告した後にプログラムが終了するかどうかを制御します。
  • atexit_sleep_ms (デフォルト 1000): 終了する前にメインゴルーチンがスリープするミリ秒数。

    例:

  1. $ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race

Excluding Tests

  1. ``````bash
  2. // +build !race
  3. package foo
  4. // The test contains a data race. See issue 123.
  5. func TestFoo(t *testing.T) {
  6. // ...
  7. }
  8. // The test fails under the race detector due to timeouts.
  9. func TestBar(t *testing.T) {
  10. // ...
  11. }
  12. // The test takes too long under the race detector.
  13. func TestBaz(t *testing.T) {
  14. // ...
  15. }
  16. `

How To Use

まず、競合検出器(go test -race)を使用してテストを実行します。競合検出器は、実行時に発生する競合のみを検出するため、実行されないコードパスの競合を見つけることはできません。テストのカバレッジが不完全な場合、-raceでビルドされたバイナリを現実的なワークロードの下で実行することで、より多くの競合を見つけることができます。

Typical Data Races

ここにいくつかの典型的なデータ競合があります。すべての競合は競合検出器で検出できます。

Race on loop counter

  1. func main() {
  2. var wg sync.WaitGroup
  3. wg.Add(5)
  4. var i int
  5. for i = 0; i < 5; i++ {
  6. go func() {
  7. fmt.Println(i) // Not the 'i' you are looking for.
  8. wg.Done()
  9. }()
  10. }
  11. wg.Wait()
  12. }

関数リテラル内の変数iはループで使用される同じ変数であるため、ゴルーチン内の読み取りはループのインクリメントと競合します。(このプログラムは通常55555を印刷し、01234は印刷しません。)変数のコピーを作成することでプログラムを修正できます:

  1. func main() {
  2. var wg sync.WaitGroup
  3. wg.Add(5)
  4. var i int
  5. for i = 0; i < 5; i++ {
  6. go func(j int) {
  7. fmt.Println(j) // Good. Read local copy of the loop counter.
  8. wg.Done()
  9. }(i)
  10. }
  11. wg.Wait()
  12. }

Accidentally shared variable

  1. // ParallelWrite writes data to file1 and file2, returns the errors.
  2. func ParallelWrite(data []byte) chan error {
  3. res := make(chan error, 2)
  4. f1, err := os.Create("file1")
  5. if err != nil {
  6. res <- err
  7. } else {
  8. go func() {
  9. // This err is shared with the main goroutine,
  10. // so the write races with the write below.
  11. _, err = f1.Write(data)
  12. res <- err
  13. f1.Close()
  14. }()
  15. }
  16. f2, err := os.Create("file2") // The second conflicting write to err.
  17. if err != nil {
  18. res <- err
  19. } else {
  20. go func() {
  21. _, err = f2.Write(data)
  22. res <- err
  23. f2.Close()
  24. }()
  25. }
  26. return res
  27. }

修正方法は、ゴルーチン内に新しい変数を導入することです(:=の使用に注意してください):

  1. ...
  2. _, err := f1.Write(data)
  3. ...
  4. _, err := f2.Write(data)
  5. ...

Unprotected global variable

以下のコードが複数のゴルーチンから呼び出されると、serviceマップで競合が発生します。同じマップの同時読み取りと書き込みは安全ではありません:

  1. var service map[string]net.Addr
  2. func RegisterService(name string, addr net.Addr) {
  3. service[name] = addr
  4. }
  5. func LookupService(name string) net.Addr {
  6. return service[name]
  7. }

コードを安全にするには、ミューテックスでアクセスを保護します:

  1. var (
  2. service map[string]net.Addr
  3. serviceMu sync.Mutex
  4. )
  5. func RegisterService(name string, addr net.Addr) {
  6. serviceMu.Lock()
  7. defer serviceMu.Unlock()
  8. service[name] = addr
  9. }
  10. func LookupService(name string) net.Addr {
  11. serviceMu.Lock()
  12. defer serviceMu.Unlock()
  13. return service[name]
  14. }

Primitive unprotected variable

データ競合は、原始型の変数でも発生する可能性があります(boolintint64など)、この例のように:

  1. type Watchdog struct{ last int64 }
  2. func (w *Watchdog) KeepAlive() {
  3. w.last = time.Now().UnixNano() // First conflicting access.
  4. }
  5. func (w *Watchdog) Start() {
  6. go func() {
  7. for {
  8. time.Sleep(time.Second)
  9. // Second conflicting access.
  10. if w.last < time.Now().Add(-10*time.Second).UnixNano() {
  11. fmt.Println("No keepalives for 10 seconds. Dying.")
  12. os.Exit(1)
  13. }
  14. }
  15. }()
  16. }

このような「無邪気な」データ競合でも、メモリアクセスの非原子的性、コンパイラ最適化との干渉、またはプロセッサメモリへのアクセスの順序付けの問題によって、デバッグが難しい問題を引き起こす可能性があります。

この競合の一般的な修正方法は、チャネルまたはミューテックスを使用することです。ロックフリーの動作を維持するために、sync/atomicパッケージを使用することもできます。

  1. type Watchdog struct{ last int64 }
  2. func (w *Watchdog) KeepAlive() {
  3. atomic.StoreInt64(&w.last, time.Now().UnixNano())
  4. }
  5. func (w *Watchdog) Start() {
  6. go func() {
  7. for {
  8. time.Sleep(time.Second)
  9. if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
  10. fmt.Println("No keepalives for 10 seconds. Dying.")
  11. os.Exit(1)
  12. }
  13. }
  14. }()
  15. }

Unsynchronized send and close operations

この例が示すように、同じチャネルでの非同期の送信およびクローズ操作も競合条件になる可能性があります:

  1. c := make(chan struct{}) // or buffered channel
  2. // The race detector cannot derive the happens before relation
  3. // for the following send and close operations. These two operations
  4. // are unsynchronized and happen concurrently.
  5. go func() { c <- struct{}{} }()
  6. close(c)

Goメモリモデルによれば、チャネルへの送信は、そのチャネルからの対応する受信が完了する前に発生します。送信とクローズ操作を同期させるには、送信が完了することを保証する受信操作を使用します:

  1. c := make(chan struct{}) // or buffered channel
  2. go func() { c <- struct{}{} }()
  3. <-c
  4. close(c)

Requirements

競合検出器はcgoを有効にする必要があり、非DarwinシステムではインストールされたCコンパイラが必要です。競合検出器はlinux/amd64linux/ppc64lelinux/arm64linux/s390xfreebsd/amd64netbsd/amd64darwin/amd64darwin/arm64、およびwindows/amd64をサポートしています。

Windowsでは、競合検出器のランタイムはインストールされたCコンパイラのバージョンに敏感です。Go 1.21以降、-raceでプログラムをビルドするには、mingw-w64ランタイムライブラリのバージョン8以降を組み込んだCコンパイラが必要です。Cコンパイラを--print-file-name libsynchronization.aの引数で呼び出すことで、Cコンパイラをテストできます。準拠した新しいCコンパイラはこのライブラリの完全なパスを印刷しますが、古いCコンパイラは引数をそのままエコーします。

Runtime Overhead

競合検出のコストはプログラムによって異なりますが、典型的なプログラムでは、メモリ使用量が5〜10倍、実行時間が2〜20倍増加する可能性があります。

競合検出器は現在、deferおよびrecoverステートメントごとに追加の8バイトを割り当てています。これらの追加の割り当てはゴルーチンが終了するまで回収されません。これは、定期的にdeferおよびrecover呼び出しを行う長時間実行されるゴルーチンがある場合、プログラムのメモリ使用量が無限に増加する可能性があることを意味します。これらのメモリ割り当ては、runtime.ReadMemStatsまたはruntime/pprofの出力には表示されません。