[インデックス 16639] ファイルの概要
このコミットは、Go言語の標準ライブラリ sync パッケージ内の WaitGroup 型におけるデータ競合検出器 (race detector) の計測に関するバグ修正です。具体的には、WaitGroup.Wait() メソッド内で発生していた誤ったデータ競合レポート(false positive)を解消することを目的としています。
コミット
- コミットハッシュ:
07cb48c31fbe1c2ee6d4996b882b296e162e4464 - 作者: Dmitriy Vyukov (
dvyukov@google.com) - コミット日時: 2013年6月25日 火曜日 20:27:19 +0400
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/07cb48c31fbe1c2ee6d4996b882b296e162e4464
元コミット内容
sync: fix race instrumentation of WaitGroup
Currently more than 1 gorutine can execute raceWrite() in Wait()
in the following scenario:
1. goroutine 1 executes first check of wg.counter, sees that it's == 0
2. goroutine 2 executes first check of wg.counter, sees that it's == 0
3. goroutine 2 locks the mutex, sees that he is the first waiter and executes raceWrite()
4. goroutine 2 block on the semaphore
5. goroutine 3 executes Done() and unblocks goroutine 2
6. goroutine 1 lock the mutex, sees that he is the first waiter and executes raceWrite()
It produces the following false report:
WARNING: DATA RACE
Write by goroutine 35:
sync.raceWrite()
src/pkg/sync/race.go:41 +0x33
sync.(*WaitGroup).Wait()
src/pkg/sync/waitgroup.go:103 +0xae
command-line-arguments_test.TestNoRaceWaitGroupMultipleWait2()
src/pkg/runtime/race/testdata/waitgroup_test.go:156 +0x19a
Previous write by goroutine 36:
sync.raceWrite()
src/pkg/sync/race.go:41 +0x33
sync.(*WaitGroup).Wait()
src/pkg/sync/waitgroup.go:103 +0xae
command-line-arguments_test.func·012()
src/pkg/runtime/race/testdata/waitgroup_test.go:148 +0x4d
R=golang-dev, r
CC=golang-dev
https://golang.org/cl/10424043
変更の背景
このコミットは、Goのデータ競合検出器が sync.WaitGroup の Wait() メソッドにおいて誤ったデータ競合(false positive)を報告するという問題に対処するために行われました。
問題のシナリオは以下の通りです。
- 複数のゴルーチンが同時に
WaitGroup.Wait()を呼び出す。 - これらのゴルーチンが
wg.counterの初期チェック(wg.counter == 0)をほぼ同時に実行し、カウンタがゼロであると判断する。 - 最初のゴルーチンがミューテックスをロックし、自身が最初のウェイターであると判断して
raceWrite()を実行する。 - このゴルーチンはセマフォでブロックされる。
- 別のゴルーチンが
Done()を呼び出し、ブロックされていたゴルーチンをアンブロックする。 - その間に、別のゴルーチンがミューテックスをロックし、自身も最初のウェイターであると誤って判断して
raceWrite()を実行してしまう。
この一連の動作により、複数のゴルーチンが raceWrite() を実行することになり、データ競合検出器が sync.raceWrite() における競合を誤って報告していました。これは実際のデータ競合ではなく、WaitGroup の内部的な計測ロジックの不備によるものでした。データ競合検出器の目的は、プログラムのバグを特定することであり、このような誤検出は開発者の混乱を招き、検出器の信頼性を損なう可能性があります。したがって、この誤検出を修正することが必要でした。
前提知識の解説
1. Goの並行処理とデータ競合
Go言語はゴルーチンとチャネルによる並行処理を強力にサポートしていますが、並行処理にはデータ競合(Data Race)のリスクが伴います。データ競合とは、複数のゴルーチンが同時に同じメモリ位置にアクセスし、少なくとも1つのアクセスが書き込みである場合に発生する未定義動作です。データ競合はプログラムの予測不能な動作やクラッシュの原因となるため、Goではこれを検出するためのツールが提供されています。
2. Goのデータ競合検出器 (Race Detector)
Goには、実行時にデータ競合を検出する組み込みのツールがあります。これは、go run -race、go build -race、go test -race などのコマンドで有効にできます。競合検出器は、メモリへのアクセスを監視し、競合が発生した可能性のある場所を報告します。このツールは非常に強力ですが、誤検出(false positive)が発生することもあります。
3. sync.WaitGroup
sync.WaitGroup は、複数のゴルーチンの完了を待つための同期プリミティブです。
Add(delta int): WaitGroupのカウンタにdeltaを加算します。Done(): WaitGroupのカウンタを1減らします。これはAdd(-1)と同等です。Wait(): カウンタがゼロになるまでブロックします。
WaitGroup は、例えばメインゴルーチンが複数の子ゴルーチンを起動し、それらすべてが完了するのを待つようなシナリオでよく使用されます。
4. raceenabled と raceWrite()
raceenabled: Goのビルド時にデータ競合検出器が有効になっているかどうかを示すブール型の変数です。これがtrueの場合、競合検出のための特別なコードパスが実行されます。raceWrite(addr unsafe.Pointer): これはGoのランタイム内部で使用される関数で、指定されたメモリアドレスへの書き込み操作をデータ競合検出器に通知します。WaitGroupのような同期プリミティブは、その内部状態の変更が他のゴルーチンから見えるように、raceWriteやraceReadといった関数を使って競合検出器にヒントを与え、同期操作を正しくモデル化します。これにより、競合検出器は同期プリミティブの内部動作を理解し、誤った競合を報告しないようにします。
5. atomic.AddInt32 とセマフォ (sema)
atomic.AddInt32(&wg.waiters, 1):sync/atomicパッケージの関数で、アトミックにwg.waitersの値を1増やします。アトミック操作は、複数のゴルーチンから同時にアクセスされても、その操作が中断されずに完了することを保証します。wg.sema:WaitGroupの内部で使用されるセマフォ(ここではuint32型のポインタとして表現され、実際にはランタイムのセマフォ機能を利用)です。Wait()メソッドは、カウンタがゼロでない場合にこのセマフォでブロックし、Done()メソッドがカウンタをゼロにしたときにセマフォを解放してWait()をブロック解除します。
技術的詳細
このコミットの核心は、WaitGroup.Wait() メソッド内での raceWrite() の呼び出し位置の変更です。
元のコードでは、Wait() メソッドの冒頭で wg.m.Lock() を取得し、atomic.AddInt32(&wg.waiters, 1) でウェイター数を増やした後、すぐに raceenabled && w == 1 (つまり、競合検出が有効で、かつ自身が最初のウェイターである場合) という条件で raceWrite(unsafe.Pointer(&wg.sema)) を呼び出していました。
この配置が問題でした。コミットメッセージに示されたシナリオを再確認します。
- ゴルーチン1と2がほぼ同時に
Wait()に入る。 - 両者が
wg.counterの最初のチェック(wg.counter == 0)を通過する。 この時点ではまだカウンタはゼロではないが、Wait()のロジック上、最終的にゼロになるのを待つことになる。 - ゴルーチン2が先に
wg.m.Lock()を取得し、wg.waitersをインクリメントする。 この時、wg.waitersが1になり、ゴルーチン2は自身が最初のウェイターであると判断する。 - ゴルーチン2が
raceWrite(unsafe.Pointer(&wg.sema))を実行する。 - ゴルーチン2はセマフォでブロックされる。
- ゴルーチン3が
Done()を呼び出し、wg.counterを減らし、最終的にゴルーチン2をアンブロックする。 - その間に、ゴルーチン1が
wg.m.Lock()を取得する。 ゴルーチン1もwg.waitersをインクリメントするが、この時wg.waitersは既に1になっているため、ゴルーチン1がインクリメントした結果wは2になる。しかし、ゴルーチン1はロックを取得する前にwg.counter == 0のチェックを通過しているため、Wait()のロジックを続行する。 - ゴルーチン1も
raceWrite(unsafe.Pointer(&wg.sema))を実行する。
ここで問題が発生します。raceWrite は、WaitGroup の Wait() が Add() と同期するために、wg.sema アドレスへの「書き込み」としてモデル化されています。これは、Add() が wg.sema アドレスを「読み込む」ことで、Wait() と Add() の間の同期を競合検出器に正しく認識させるためのものです。しかし、上記のシナリオでは、複数のゴルーチンが wg.sema に対して raceWrite を実行してしまい、競合検出器はこれを実際のデータ競合と誤認して警告を発していました。
この問題は、raceWrite の呼び出しが、実際に WaitGroup が待機状態に入り、かつその待機が Add() と同期する必要がある「最初のウェイター」のコンテキストでのみ行われるべきであるにもかかわらず、ロック取得直後というタイミングで実行されていたために発生しました。
修正は、raceWrite の呼び出しを、wg.counter == 0 のチェックが完了し、かつ wg.waiters が1である(つまり、実際に待機状態に入り、かつそのゴルーチンがその時点での最初のウェイターである)という条件が満たされた後に移動することです。これにより、複数のゴルーチンが同時に Wait() に入ったとしても、実際に wg.counter がゼロになり、セマフォでブロックされる直前の、真に「最初のウェイター」だけが raceWrite を実行するようになります。
コアとなるコードの変更箇所
変更は src/pkg/sync/waitgroup.go ファイルの WaitGroup.Wait() メソッド内で行われました。
--- a/src/pkg/sync/waitgroup.go
+++ b/src/pkg/sync/waitgroup.go
@@ -95,13 +95,6 @@ func (wg *WaitGroup) Wait() {
}\n \twg.m.Lock()\n \tw := atomic.AddInt32(&wg.waiters, 1)\n-\tif raceenabled && w == 1 {\n-\t\t// Wait\'s must be synchronized with the first Add.\n-\t\t// Need to model this is as a write to race with the read in Add.\n-\t\t// As the consequence, can do the write only for the first waiter,\n-\t\t// otherwise concurrent Wait\'s will race with each other.\n-\t\traceWrite(unsafe.Pointer(&wg.sema))\n-\t}\n \t// This code is racing with the unlocked path in Add above.\n \t// The code above modifies counter and then reads waiters.\n \t// We must modify waiters and then read counter (the opposite order)\n@@ -119,6 +112,13 @@ func (wg *WaitGroup) Wait(){\n \t\t}\n \t\treturn\n \t}\n+\tif raceenabled && w == 1 {\n+\t\t// Wait must be synchronized with the first Add.\n+\t\t// Need to model this is as a write to race with the read in Add.\n+\t\t// As a consequence, can do the write only for the first waiter,\n+\t\t// otherwise concurrent Waits will race with each other.\n+\t\traceWrite(unsafe.Pointer(&wg.sema))\n+\t}\n \tif wg.sema == nil {\n \t\twg.sema = new(uint32)\n \t}\
具体的には、raceWrite の呼び出しブロックが、wg.m.Lock() と atomic.AddInt32(&wg.waiters, 1) の直後から、wg.counter == 0 のチェックとそれに続く return のブロックを過ぎた後に移動されました。
コアとなるコードの解説
修正前のコードでは、raceWrite の呼び出しは wg.m.Lock() を取得し、wg.waiters をインクリメントした直後に行われていました。この時点では、wg.counter がまだゼロになっていない可能性があり、複数のゴルーチンが Wait() に入って wg.waiters をインクリメントし、それぞれが「最初のウェイター」であると誤認して raceWrite を実行してしまう可能性がありました。
修正後のコードでは、raceWrite の呼び出しは以下の条件が満たされた後に移動されました。
wg.m.Lock()が取得されている。w := atomic.AddInt32(&wg.waiters, 1)が実行され、現在のウェイター数がwに格納されている。wg.counter == 0のチェックが行われ、もしカウンタが既にゼロであれば、Wait()はすぐにリターンする。- そして、カウンタがゼロでなかった場合にのみ、新しい位置の
raceWriteブロックに到達する。
この新しい配置により、raceWrite は、WaitGroup が実際に待機状態に入ろうとしている(つまり、wg.counter がまだゼロではない)ゴルーチンによってのみ実行されるようになります。さらに、w == 1 の条件は、そのゴルーチンが wg.sema を介して Add() と同期する必要がある「最初のウェイター」であることを保証します。
これにより、複数のゴルーチンが同時に Wait() を呼び出したとしても、実際に wg.sema を介した同期が必要となるのは、wg.counter がゼロになるのを待つ最初のゴルーチンだけになります。他のゴルーチンは、最初のゴルーチンが raceWrite を実行した後、または最初のゴルーチンがセマフォでブロックされた後に、wg.waiters が1より大きい値になるため、raceenabled && w == 1 の条件を満たさなくなり、raceWrite を実行しません。
結果として、wg.sema への raceWrite は一度しか行われなくなり、データ競合検出器の誤検出が解消されます。これは、WaitGroup の内部的な同期メカニズムと競合検出器の連携をより正確にモデル化した修正と言えます。
関連リンク
- GitHubコミットページ: https://github.com/golang/go/commit/07cb48c31fbe1c2ee6d4996b882b296e162e4464
- Go CL (Code Review) ページ: https://golang.org/cl/10424043
参考にした情報源リンク
- Go言語の公式ドキュメント:
syncパッケージ、sync/atomicパッケージ - Go Race Detector: https://go.dev/doc/articles/race_detector
- Goのソースコード:
src/sync/waitgroup.go,src/runtime/race/race.go(内部的なraceWriteの実装) - Goのデータ競合検出器に関するブログ記事や解説(一般的な理解のため)