データ競合検出器

はじめに

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

以下は、クラッシュやメモリ破損につながる可能性のあるデータ競合の例です。

func main() {
	c := make(chan bool)
	m := make(map[string]string)
	go func() {
		m["1"] = "a" // First conflicting access.
		c <- true
	}()
	m["2"] = "b" // Second conflicting access.
	<-c
	for k, v := range m {
		fmt.Println(k, v)
	}
}

使い方

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

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

レポート形式

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

WARNING: DATA RACE
Read by goroutine 185:
  net.(*pollServer).AddFD()
      src/net/fd_unix.go:89 +0x398
  net.(*pollServer).WaitWrite()
      src/net/fd_unix.go:247 +0x45
  net.(*netFD).Write()
      src/net/fd_unix.go:540 +0x4d4
  net.(*conn).Write()
      src/net/net.go:129 +0x101
  net.func·060()
      src/net/timeout_test.go:603 +0xaf

Previous write by goroutine 184:
  net.setWriteDeadline()
      src/net/sockopt_posix.go:135 +0xdf
  net.setDeadline()
      src/net/sockopt_posix.go:144 +0x9c
  net.(*conn).SetDeadline()
      src/net/net.go:161 +0xe3
  net.func·061()
      src/net/timeout_test.go:616 +0x3ed

Goroutine 185 (running) created at:
  net.func·061()
      src/net/timeout_test.go:609 +0x288

Goroutine 184 (running) created at:
  net.TestProlongTimeout()
      src/net/timeout_test.go:618 +0x298
  testing.tRunner()
      src/testing/testing.go:301 +0xe8

オプション

GORACE 環境変数は、競合検出器のオプションを設定します。形式は以下の通りです。

GORACE="option1=val1 option2=val2"

オプションは以下の通りです。

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

テストの除外

-race フラグを付けてビルドすると、go コマンドは追加のビルドタグ race を定義します。このタグを使用して、競合検出器を実行するときに一部のコードとテストを除外できます。いくつかの例を以下に示します。

// +build !race

package foo

// The test contains a data race. See issue 123.
func TestFoo(t *testing.T) {
	// ...
}

// The test fails under the race detector due to timeouts.
func TestBar(t *testing.T) {
	// ...
}

// The test takes too long under the race detector.
func TestBaz(t *testing.T) {
	// ...
}

使い方

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

典型的なデータ競合

以下に、いくつかの典型的なデータ競合を示します。これらはすべて競合検出器で検出できます。

ループカウンターでの競合

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	var i int
	for i = 0; i < 5; i++ {
		go func() {
			fmt.Println(i) // Not the 'i' you are looking for.
			wg.Done()
		}()
	}
	wg.Wait()
}

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

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	var i int
	for i = 0; i < 5; i++ {
		go func(j int) {
			fmt.Println(j) // Good. Read local copy of the loop counter.
			wg.Done()
		}(i)
	}
	wg.Wait()
}

誤って共有された変数

// ParallelWrite writes data to file1 and file2, returns the errors.
func ParallelWrite(data []byte) chan error {
	res := make(chan error, 2)
	f1, err := os.Create("file1")
	if err != nil {
		res <- err
	} else {
		go func() {
			// This err is shared with the main goroutine,
			// so the write races with the write below.
			_, err = f1.Write(data)
			res <- err
			f1.Close()
		}()
	}
	f2, err := os.Create("file2") // The second conflicting write to err.
	if err != nil {
		res <- err
	} else {
		go func() {
			_, err = f2.Write(data)
			res <- err
			f2.Close()
		}()
	}
	return res
}

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

			...
			_, err := f1.Write(data)
			...
			_, err := f2.Write(data)
			...

保護されていないグローバル変数

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

var service map[string]net.Addr

func RegisterService(name string, addr net.Addr) {
	service[name] = addr
}

func LookupService(name string) net.Addr {
	return service[name]
}

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

var (
	service   map[string]net.Addr
	serviceMu sync.Mutex
)

func RegisterService(name string, addr net.Addr) {
	serviceMu.Lock()
	defer serviceMu.Unlock()
	service[name] = addr
}

func LookupService(name string) net.Addr {
	serviceMu.Lock()
	defer serviceMu.Unlock()
	return service[name]
}

プリミティブな保護されていない変数

この例のように、プリミティブ型 (boolintint64 など) の変数でもデータ競合が発生する可能性があります。

type Watchdog struct{ last int64 }

func (w *Watchdog) KeepAlive() {
	w.last = time.Now().UnixNano() // First conflicting access.
}

func (w *Watchdog) Start() {
	go func() {
		for {
			time.Sleep(time.Second)
			// Second conflicting access.
			if w.last < time.Now().Add(-10*time.Second).UnixNano() {
				fmt.Println("No keepalives for 10 seconds. Dying.")
				os.Exit(1)
			}
		}
	}()
}

このような「無害な」データ競合でさえ、メモリアクセスの非アトミック性、コンパイラ最適化との干渉、またはプロセッサメモリへのアクセスにおける順序変更の問題によって引き起こされるデバッグ困難な問題につながる可能性があります。

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

type Watchdog struct{ last int64 }

func (w *Watchdog) KeepAlive() {
	atomic.StoreInt64(&w.last, time.Now().UnixNano())
}

func (w *Watchdog) Start() {
	go func() {
		for {
			time.Sleep(time.Second)
			if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
				fmt.Println("No keepalives for 10 seconds. Dying.")
				os.Exit(1)
			}
		}
	}()
}

非同期の送信およびクローズ操作

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

c := make(chan struct{}) // or buffered channel

// The race detector cannot derive the happens before relation
// for the following send and close operations. These two operations
// are unsynchronized and happen concurrently.
go func() { c <- struct{}{} }()
close(c)

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

c := make(chan struct{}) // or buffered channel

go func() { c <- struct{}{} }()
<-c
close(c)

要件

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

Windows では、競合検出器ランタイムはインストールされている C コンパイラのバージョンに影響を受けます。Go 1.21 の時点では、-race を使用してプログラムをビルドするには、mingw-w64 ランタイムライブラリのバージョン 8 以降を組み込んだ C コンパイラが必要です。引数 --print-file-name libsynchronization.a を指定して C コンパイラを呼び出すことで、C コンパイラをテストできます。新しい準拠の C コンパイラは、このライブラリのフルパスを出力しますが、古い C コンパイラは引数をそのままエコーします。

実行時オーバーヘッド

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

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