Introduction
データ競合は、並行システムにおいて最も一般的でデバッグが難しいバグの一種です。データ競合は、2つのゴルーチンが同じ変数に同時にアクセスし、少なくとも1つのアクセスが書き込みである場合に発生します。詳細については、The Go Memory Modelを参照してください。
ここに、クラッシュやメモリ破損を引き起こす可能性のあるデータ競合の例があります:
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)
}
}
Usage
このようなバグを診断するために、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
Report Format
競合検出器がプログラム内でデータ競合を見つけると、レポートを印刷します。レポートには、競合するアクセスのスタックトレースと、関与するゴルーチンが作成されたスタックが含まれます。以下はその例です:
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
Options
``````bash
GORACE="option1=val1 option2=val2"
`
オプションは次のとおりです:
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
): 終了する前にメインゴルーチンがスリープするミリ秒数。例:
$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race
Excluding Tests
``````bash
// +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) {
// ...
}
`
How To Use
まず、競合検出器(go test -race
)を使用してテストを実行します。競合検出器は、実行時に発生する競合のみを検出するため、実行されないコードパスの競合を見つけることはできません。テストのカバレッジが不完全な場合、-race
でビルドされたバイナリを現実的なワークロードの下で実行することで、より多くの競合を見つけることができます。
Typical Data Races
ここにいくつかの典型的なデータ競合があります。すべての競合は競合検出器で検出できます。
Race on loop counter
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
はループで使用される同じ変数であるため、ゴルーチン内の読み取りはループのインクリメントと競合します。(このプログラムは通常55555を印刷し、01234は印刷しません。)変数のコピーを作成することでプログラムを修正できます:
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()
}
Accidentally shared variable
// 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)
...
Unprotected global variable
以下のコードが複数のゴルーチンから呼び出されると、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]
}
Primitive unprotected variable
データ競合は、原始型の変数でも発生する可能性があります(bool
、int
、int64
など)、この例のように:
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)
}
}
}()
}
Unsynchronized send and close operations
この例が示すように、同じチャネルでの非同期の送信およびクローズ操作も競合条件になる可能性があります:
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)
Requirements
競合検出器はcgoを有効にする必要があり、非DarwinシステムではインストールされたCコンパイラが必要です。競合検出器はlinux/amd64
、linux/ppc64le
、linux/arm64
、linux/s390x
、freebsd/amd64
、netbsd/amd64
、darwin/amd64
、darwin/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
の出力には表示されません。