[インデックス 19096] ファイルの概要
このコミットは、Go言語のsync
パッケージにおけるWaitGroup
のWait
メソッドで発生する「スプリアスウェイクアップ(Spurious Wakeup)」という競合状態を修正します。これにより、WaitGroup
が意図せず早期に待機中のゴルーチンを解放してしまう問題を解決し、WaitGroup
の正確な動作を保証します。
コミット
commit e9347c781be66056bbc724f4d70d4b8b9bc0288c
Author: Rui Ueyama <ruiu@google.com>
Date: Thu Apr 10 18:44:44 2014 +0400
sync: fix spurious wakeup from WaitGroup.Wait
There is a race condition that causes spurious wakeup from Wait
in the following case:
G1: decrement wg.counter, observe the counter is now 0
(should unblock goroutines queued *at this moment*)
G2: increment wg.counter
G2: call Wait() to add itself to the wait queue
G1: acquire wg.m, unblock all waiting goroutines
In the last step G2 is spuriously woken up by G1.
Fixes #7734.
LGTM=rsc, dvyukov
R=dvyukov, 0xjnml, rsc
CC=golang-codereviews
https://golang.org/cl/85580043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/e9347c781be66056bbc724f4d70d4b8b9bc0288c
元コミット内容
sync: WaitGroup.Wait からのスプリアスウェイクアップを修正
以下のケースで、Wait
からのスプリアスウェイクアップを引き起こす競合状態が存在します。
- G1:
wg.counter
をデクリメントし、カウンタが0になったことを確認する(この時点でキューに入っているゴルーチンをアンブロックすべき) - G2:
wg.counter
をインクリメントする - G2:
Wait()
を呼び出し、自身を待機キューに追加する - G1:
wg.m
(ミューテックス)を取得し、待機中のすべてのゴルーチンをアンブロックする
最後のステップで、G2はG1によってスプリアスにウェイクアップされます。 Issue #7734 を修正します。
変更の背景
このコミットは、Go言語の標準ライブラリであるsync.WaitGroup
において、特定の競合状態下で発生する「スプリアスウェイクアップ」というバグを修正するために導入されました。
WaitGroup
は、複数のゴルーチンが完了するまで待機するために使用される同期プリミティブです。通常、Add
メソッドでカウンタを増やし、Done
メソッドでカウンタを減らし、Wait
メソッドでカウンタがゼロになるまで待機します。
問題は、WaitGroup
のカウンタがゼロになった際に、待機中のゴルーチンを解放するロジックと、新たなゴルーチンがWait
を呼び出して待機キューに追加されるロジックの間に発生する競合状態にありました。
コミットメッセージに示されている具体的なシナリオは以下の通りです。
- G1がカウンタをゼロにする: あるゴルーチン(G1)が
Done()
を呼び出し、WaitGroup
の内部カウンタがゼロになります。この時、G1は待機中のゴルーチンをすべて解放しようとします。 - G2がカウンタをインクリメントし、待機する: ほぼ同時に、別のゴルーチン(G2)が
Add()
を呼び出してカウンタをインクリメントし、その後すぐにWait()
を呼び出して待機キューに自身を追加します。 - G1が待機中のゴルーチンを解放: G1はロックを取得し、待機中のゴルーチンを解放する処理を進めます。この際、G2が待機キューに追加された直後であるにもかかわらず、G1が解放処理を行うため、G2が意図せず解放されてしまう可能性がありました。
この「意図しない解放」がスプリアスウェイクアップです。WaitGroup
のカウンタがまだゼロではないにもかかわらず、G2がWait
から戻ってしまうため、プログラムのロジックが破綻する可能性がありました。これは、WaitGroup
のセマンティクス(カウンタがゼロになるまで待機する)に反する動作であり、アプリケーションのデッドロックや不正な状態を引き起こす原因となります。
このバグはIssue #7734として報告されており、このコミットはその問題を解決することを目的としています。
前提知識の解説
このコミットを理解するためには、以下の概念について深く理解しておく必要があります。
1. sync.WaitGroup
Go言語のsync
パッケージが提供する同期プリミティブの一つで、複数のゴルーチンが特定のタスクを完了するまで、メインゴルーチン(または他のゴルーチン)が待機するために使用されます。
Add(delta int)
:WaitGroup
のカウンタにdelta
を加算します。通常、新しいゴルーチンを起動する前にAdd(1)
を呼び出します。カウンタが負の値になるとパニックします。Done()
:WaitGroup
のカウンタを1減らします。これはAdd(-1)
のショートカットです。通常、ゴルーチンがタスクを完了した際に呼び出されます。Wait()
:WaitGroup
のカウンタがゼロになるまで呼び出し元のゴルーチンをブロックします。カウンタがゼロになると、Wait
はブロックを解除し、呼び出し元のゴルーチンは実行を再開します。
WaitGroup
の内部では、カウンタと、待機中のゴルーチンを管理するためのセマフォ(または類似のメカニズム)が使用されています。
2. セマフォ (Semaphore)
セマフォは、並行プログラミングにおいて共有リソースへのアクセスを制御するための同期メカニズムです。ここでは、特に「カウンティングセマフォ」の概念が重要です。
P
操作 (Wait/Acquire): セマフォのカウンタをデクリメントします。カウンタが負になる場合、呼び出し元のプロセス/スレッドはブロックされます。V
操作 (Signal/Release): セマフォのカウンタをインクリメントします。これにより、待機中のプロセス/スレッドが一つ解放される可能性があります。
Goのsync.WaitGroup
では、内部的にruntime_Semacquire
とruntime_Semrelease
という低レベルな関数(Goランタイムが提供するセマフォ操作)が使用されています。Wait()
はruntime_Semacquire
を呼び出して待機し、Done()
がカウンタをゼロにした際にruntime_Semrelease
を呼び出して待機中のゴルーチンを解放します。
3. スプリアスウェイクアップ (Spurious Wakeup)
スプリアスウェイクアップとは、条件変数を待機しているスレッド(またはゴルーチン)が、その待機条件が満たされていないにもかかわらず、OSやランタイムによって予期せずウェイクアップされてしまう現象を指します。
これは、マルチプロセッサシステムにおけるスケジューリングの複雑さや、セマフォの実装の詳細に起因することがあります。セマフォのV
操作が呼び出された際に、複数の待機スレッドが存在する場合、意図したスレッド以外がウェイクアップされる、あるいは、ウェイクアップのシグナルが送られた直後に条件が再び無効になる、といった状況で発生し得ます。
スプリアスウェイクアップは、並行プログラミングにおいて考慮すべき重要な問題です。これを防ぐためには、ウェイクアップされたスレッドは必ずループ内で条件を再確認する必要があります。つまり、while (condition_not_met) { wait(); }
のようなパターンで待機することが推奨されます。
WaitGroup
の文脈では、カウンタがゼロになったときにのみWait
から戻るべきですが、スプリアスウェイクアップが発生すると、カウンタがゼロではないのにWait
から戻ってしまうという問題が生じます。
4. sync/atomic
パッケージ
sync/atomic
パッケージは、低レベルなアトミック操作(不可分操作)を提供します。アトミック操作は、複数のゴルーチンから同時にアクセスされても、その操作全体が単一の不可分な単位として実行されることを保証します。これにより、競合状態を回避し、データの整合性を保つことができます。
atomic.LoadInt32(&addr)
: 指定されたメモリアドレスaddr
からint32
の値をアトミックに読み込みます。この操作は、他のゴルーチンによる書き込みと競合することなく、常に最新の値を読み込むことを保証します。
このコミットでは、WaitGroup
のカウンタの状態をアトミックに読み取るためにatomic.LoadInt32
が使用されています。
技術的詳細
このコミットの技術的な核心は、WaitGroup
のAdd
メソッド内でのセマフォ解放ロジックの変更にあります。
変更前のコードでは、Add
メソッドがdelta
を加算した結果、wg.counter
がゼロになった場合、wg.m.Lock()
でロックを取得した後、無条件にwg.waiters
の数だけruntime_Semrelease(wg.sema)
を呼び出して待機中のゴルーチンを解放していました。その後、wg.waiters
とwg.sema
をリセットしていました。
// 変更前 (簡略化)
func (wg *WaitGroup) Add(delta int) {
// ...カウンタの更新...
wg.m.Lock()
// ここでwg.counterが0になったと仮定
for i := int32(0); i < wg.waiters; i++ {
runtime_Semrelease(wg.sema) // 待機中のゴルーチンを解放
}
wg.waiters = 0
wg.sema = nil
wg.m.Unlock()
}
このロジックの問題点は、wg.m.Lock()
を取得した直後、かつruntime_Semrelease
を呼び出す前に、別のゴルーチンがAdd(1)
を呼び出してカウンタを再びインクリメントし、その後すぐにWait()
を呼び出して待機キューに自身を追加する可能性がある点です。
コミットメッセージのシナリオを再確認します。
- G1:
wg.counter
をデクリメントし、0になったことを確認- G1は
Done()
を呼び出し、カウンタが0になったため、待機中のゴルーチンを解放する準備をします。 - G1は
wg.m.Lock()
を試みます。
- G1は
- G2:
wg.counter
をインクリメント- G2は
Add(1)
を呼び出し、カウンタを1増やします。
- G2は
- G2:
Wait()
を呼び出して待機キューに追加- G2は
Wait()
を呼び出し、wg.counter
が0ではないため、自身を待機キューに追加し、ブロックされます。
- G2は
- G1:
wg.m
を取得し、待機中のゴルーチンをアンブロック- G1がロックを取得します。この時、G2は既に待機キューにいます。
- 変更前のコードでは、G1はロックを取得した時点で
wg.counter
が0であるという「過去の事実」に基づいて、無条件にwg.waiters
の数だけruntime_Semrelease
を呼び出していました。 - この結果、G2が待機キューに追加されたばかりであるにもかかわらず、G1によってスプリアスにウェイクアップされてしまう可能性がありました。
この問題を解決するため、修正後のコードでは、wg.m.Lock()
を取得した後、再度wg.counter
が本当にゼロであるかを確認する条件分岐が追加されました。
// 変更後 (簡略化)
func (wg *WaitGroup) Add(delta int) {
// ...カウンタの更新...
wg.m.Lock()
// ロック取得後、再度カウンタが0か確認
if atomic.LoadInt32(&wg.counter) == 0 { // ここが変更点
for i := int32(0); i < wg.waiters; i++ {
runtime_Semrelease(wg.sema)
}
wg.waiters = 0
wg.sema = nil
}
wg.m.Unlock()
}
atomic.LoadInt32(&wg.counter) == 0
という条件が追加されたことで、以下のようになります。
- G1がロックを取得した時点で、もしG2が既にカウンタをインクリメントしていた場合(つまり
wg.counter
が0ではない場合)、atomic.LoadInt32(&wg.counter)
は0ではない値を返すため、runtime_Semrelease
のループは実行されません。 - これにより、G2はカウンタが再び0になるまで正しく待機し続けることが保証され、スプリアスウェイクアップが防止されます。
atomic.LoadInt32
を使用しているのは、wg.counter
が複数のゴルーチンによって同時に読み書きされる可能性があるため、競合状態を避けて正確な最新の値を読み取るためです。通常の読み込みでは、コンパイラやCPUの最適化によって古い値が読み込まれる可能性があり、正確な判断ができません。アトミック操作は、このようなメモリバリアの役割も果たし、可視性を保証します。
この修正により、WaitGroup
のWait
メソッドは、カウンタが実際にゼロになった場合にのみゴルーチンを解放するようになり、より堅牢で予測可能な動作が実現されました。
コアとなるコードの変更箇所
src/pkg/sync/waitgroup.go
--- a/src/pkg/sync/waitgroup.go
+++ b/src/pkg/sync/waitgroup.go
@@ -67,11 +67,13 @@ func (wg *WaitGroup) Add(delta int) {
return
}
wg.m.Lock()
- for i := int32(0); i < wg.waiters; i++ {
- runtime_Semrelease(wg.sema)
+ if atomic.LoadInt32(&wg.counter) == 0 {
+ for i := int32(0); i < wg.waiters; i++ {
+ runtime_Semrelease(wg.sema)
+ }
+ wg.waiters = 0
+ wg.sema = nil
}
- wg.waiters = 0
- wg.sema = nil
wg.m.Unlock()
}
src/pkg/sync/waitgroup_test.go
--- a/src/pkg/sync/waitgroup_test.go
+++ b/src/pkg/sync/waitgroup_test.go
@@ -6,6 +6,7 @@ package sync_test
import (
. "sync"
+ "sync/atomic"
"testing"
)
@@ -59,6 +60,31 @@ func TestWaitGroupMisuse(t *testing.T) {
t.Fatal("Should panic")
}
+func TestWaitGroupRace(t *testing.T) {
+ // Run this test for about 1ms.
+ for i := 0; i < 1000; i++ {
+ wg := &WaitGroup{}
+ n := new(int32)
+ // spawn goroutine 1
+ wg.Add(1)
+ go func() {
+ atomic.AddInt32(n, 1)
+ wg.Done()
+ }()
+ // spawn goroutine 2
+ wg.Add(1)
+ go func() {
+ atomic.AddInt32(n, 1)
+ wg.Done()
+ }()
+ // Wait for goroutine 1 and 2
+ wg.Wait()
+ if atomic.LoadInt32(n) != 2 {
+ t.Fatal("Spurious wakeup from Wait")
+ }
+ }
+}
+
func BenchmarkWaitGroupUncontended(b *testing.B) {
type PaddedWaitGroup struct {
WaitGroup
コアとなるコードの解説
src/pkg/sync/waitgroup.go
の変更点
WaitGroup
構造体のAdd
メソッド内の変更です。
-
削除された行:
- for i := int32(0); i < wg.waiters; i++ { - runtime_Semrelease(wg.sema) - } - wg.waiters = 0 - wg.sema = nil
これらの行は、
wg.m.Lock()
を取得した後に、無条件に待機中のゴルーチンを解放し、waiters
とsema
をリセットしていました。 -
追加された行:
+ if atomic.LoadInt32(&wg.counter) == 0 { + for i := int32(0); i < wg.waiters; i++ { + runtime_Semrelease(wg.sema) + } + wg.waiters = 0 + wg.sema = nil + }
この変更の核心は、
if atomic.LoadInt32(&wg.counter) == 0
という条件文の追加です。wg.m.Lock()
でミューテックスをロックした後、再度wg.counter
が本当にゼロであるかをatomic.LoadInt32
を使ってアトミックに確認します。atomic.LoadInt32
を使用することで、他のゴルーチンによるwg.counter
の変更が正しく可視化され、最新の正確な値が読み込まれることが保証されます。- この条件が真(つまり、ロックを取得した時点でもカウンタがゼロである)の場合にのみ、
runtime_Semrelease
を呼び出して待機中のゴルーチンを解放し、waiters
とsema
をリセットする処理が実行されます。
この変更により、コミットメッセージで説明されている競合状態が回避されます。G1がロックを取得した直後にG2がカウンタをインクリメントした場合、G1はatomic.LoadInt32(&wg.counter)
が0ではないことを検出し、誤ってG2をウェイクアップすることなく、解放処理をスキップします。これにより、G2はカウンタが再び0になるまで正しく待機し続けることができます。
src/pkg/sync/waitgroup_test.go
の変更点
このファイルには、TestWaitGroupRace
という新しいテスト関数が追加されています。
import "sync/atomic"
の追加: 新しいテストでatomic
パッケージを使用するため、インポートが追加されています。TestWaitGroupRace
関数:- このテストは、スプリアスウェイクアップの競合状態を再現し、修正が正しく機能することを確認するために設計されています。
for i := 0; i < 1000; i++
ループにより、テストが1000回繰り返され、競合状態が発生する可能性を高めています。- 各イテレーションで新しい
WaitGroup
とカウンタn
(int32
型でアトミック操作用)が初期化されます。 - ゴルーチン1の起動:
wg.Add(1)
でカウンタを1増やします。- 新しいゴルーチンを起動し、その中で
atomic.AddInt32(n, 1)
でn
をインクリメントし、wg.Done()
を呼び出します。
- ゴルーチン2の起動:
wg.Add(1)
でカウンタを1増やします。- 新しいゴルーチンを起動し、その中で
atomic.AddInt32(n, 1)
でn
をインクリメントし、wg.Done()
を呼び出します。
wg.Wait()
: メインゴルーチンは、上記2つのゴルーチンが完了するまでwg.Wait()
で待機します。- 検証:
if atomic.LoadInt32(n) != 2
- 2つのゴルーチンがそれぞれ
n
を1ずつインクリメントするため、wg.Wait()
から戻った時点でn
の値は必ず2になっているはずです。 - もしスプリアスウェイクアップが発生し、いずれかのゴルーチンが
Wait
から意図せず早く戻ってしまった場合、n
の値が2にならない可能性があります(例えば、片方のゴルーチンがまだatomic.AddInt32
を呼び出す前にWait
が解除されてしまうなど)。 t.Fatal("Spurious wakeup from Wait")
は、n
が2でなければテストを失敗させ、スプリアスウェイクアップがまだ発生していることを示します。
- 2つのゴルーチンがそれぞれ
このテストは、修正が導入されたことで、以前は発生し得たスプリアスウェイクアップが確実に防止されることを検証するための重要な役割を果たしています。
関連リンク
- GitHubコミットページ: https://github.com/golang/go/commit/e9347c781be66056bbc724f4d70d4b8b9bc0288c
- Go CL (Code Review): https://golang.org/cl/85580043
- 関連するIssue: https://github.com/golang/go/issues/7734
参考にした情報源リンク
- Go言語の
sync.WaitGroup
に関する公式ドキュメントや解説記事 - セマフォとスプリアスウェイクアップに関する並行プログラミングの一般的な概念を説明する資料
- Go言語の
sync/atomic
パッケージに関するドキュメントI have generated the detailed technical explanation in Markdown format, following all the specified instructions and chapter structure. I have included the commit details, background, prerequisite knowledge, technical details of the fix, code changes, and a detailed explanation of the code. I also included the relevant links.
I will now output the generated explanation to standard output.
# [インデックス 19096] ファイルの概要
このコミットは、Go言語の`sync`パッケージにおける`WaitGroup`の`Wait`メソッドで発生する「スプリアスウェイクアップ(Spurious Wakeup)」という競合状態を修正します。これにより、`WaitGroup`が意図せず早期に待機中のゴルーチンを解放してしまう問題を解決し、`WaitGroup`の正確な動作を保証します。
## コミット
commit e9347c781be66056bbc724f4d70d4b8b9bc0288c Author: Rui Ueyama ruiu@google.com Date: Thu Apr 10 18:44:44 2014 +0400
sync: fix spurious wakeup from WaitGroup.Wait
There is a race condition that causes spurious wakeup from Wait
in the following case:
G1: decrement wg.counter, observe the counter is now 0
(should unblock goroutines queued *at this moment*)
G2: increment wg.counter
G2: call Wait() to add itself to the wait queue
G1: acquire wg.m, unblock all waiting goroutines
In the last step G2 is spuriously woken up by G1.
Fixes #7734.
LGTM=rsc, dvyukov
R=dvyukov, 0xjnml, rsc
CC=golang-codereviews
https://golang.org/cl/85580043
## GitHub上でのコミットページへのリンク
[https://github.com/golang/go/commit/e9347c781be66056bbc724f4d70d4b8b9bc0288c](https://github.com/golang/go/commit/e9347c781be66056bbc724f4d70d4b8b9bc0288c)
## 元コミット内容
`sync: WaitGroup.Wait からのスプリアスウェイクアップを修正`
以下のケースで、`Wait`からのスプリアスウェイクアップを引き起こす競合状態が存在します。
1. G1: `wg.counter`をデクリメントし、カウンタが0になったことを確認する(この時点でキューに入っているゴルーチンをアンブロックすべき)
2. G2: `wg.counter`をインクリメントする
3. G2: `Wait()`を呼び出し、自身を待機キューに追加する
4. G1: `wg.m`(ミューテックス)を取得し、待機中のすべてのゴルーチンをアンブロックする
最後のステップで、G2はG1によってスプリアスにウェイクアップされます。
Issue #7734 を修正します。
## 変更の背景
このコミットは、Go言語の標準ライブラリである`sync.WaitGroup`において、特定の競合状態下で発生する「スプリアスウェイクアップ」というバグを修正するために導入されました。
`WaitGroup`は、複数のゴルーチンが完了するまで待機するために使用される同期プリミティブです。通常、`Add`メソッドでカウンタを増やし、`Done`メソッドでカウンタを減らし、`Wait`メソッドでカウンタがゼロになるまで待機します。
問題は、`WaitGroup`のカウンタがゼロになった際に、待機中のゴルーチンを解放するロジックと、新たなゴルーチンが`Wait`を呼び出して待機キューに追加されるロジックの間に発生する競合状態にありました。
コミットメッセージに示されている具体的なシナリオは以下の通りです。
1. **G1がカウンタをゼロにする**: あるゴルーチン(G1)が`Done()`を呼び出し、`WaitGroup`の内部カウンタがゼロになります。この時、G1は待機中のゴルーチンをすべて解放しようとします。
2. **G2がカウンタをインクリメントし、待機する**: ほぼ同時に、別のゴルーチン(G2)が`Add()`を呼び出してカウンタをインクリメントし、その後すぐに`Wait()`を呼び出して待機キューに自身を追加します。
3. **G1が待機中のゴルーチンを解放**: G1はロックを取得し、待機中のゴルーチンを解放する処理を進めます。この際、G2が待機キューに追加された直後であるにもかかわらず、G1が解放処理を行うため、G2が意図せず解放されてしまう可能性がありました。
この「意図しない解放」がスプリアスウェイクアップです。`WaitGroup`のカウンタがまだゼロではないにもかかわらず、G2が`Wait`から戻ってしまうため、プログラムのロジックが破綻する可能性がありました。これは、`WaitGroup`のセマンティクス(カウンタがゼロになるまで待機する)に反する動作であり、アプリケーションのデッドロックや不正な状態を引き起こす原因となります。
このバグはIssue #7734として報告されており、このコミットはその問題を解決することを目的としています。
## 前提知識の解説
このコミットを理解するためには、以下の概念について深く理解しておく必要があります。
### 1. `sync.WaitGroup`
Go言語の`sync`パッケージが提供する同期プリミティブの一つで、複数のゴルーチンが特定のタスクを完了するまで、メインゴルーチン(または他のゴルーチン)が待機するために使用されます。
* **`Add(delta int)`**: `WaitGroup`のカウンタに`delta`を加算します。通常、新しいゴルーチンを起動する前に`Add(1)`を呼び出します。カウンタが負の値になるとパニックします。
* **`Done()`**: `WaitGroup`のカウンタを1減らします。これは`Add(-1)`のショートカットです。通常、ゴルーチンがタスクを完了した際に呼び出されます。
* **`Wait()`**: `WaitGroup`のカウンタがゼロになるまで呼び出し元のゴルーチンをブロックします。カウンタがゼロになると、`Wait`はブロックを解除し、呼び出し元のゴルーチンは実行を再開します。
`WaitGroup`の内部では、カウンタと、待機中のゴルーチンを管理するためのセマフォ(または類似のメカニズム)が使用されています。
### 2. セマフォ (Semaphore)
セマフォは、並行プログラミングにおいて共有リソースへのアクセスを制御するための同期メカニズムです。ここでは、特に「カウンティングセマフォ」の概念が重要です。
* **`P`操作 (Wait/Acquire)**: セマフォのカウンタをデクリメントします。カウンタが負になる場合、呼び出し元のプロセス/スレッドはブロックされます。
* **`V`操作 (Signal/Release)**: セマフォのカウンタをインクリメントします。これにより、待機中のプロセス/スレッドが一つ解放される可能性があります。
Goの`sync.WaitGroup`では、内部的に`runtime_Semacquire`と`runtime_Semrelease`という低レベルな関数(Goランタイムが提供するセマフォ操作)が使用されています。`Wait()`は`runtime_Semacquire`を呼び出して待機し、`Done()`がカウンタをゼロにした際に`runtime_Semrelease`を呼び出して待機中のゴルーチンを解放します。
### 3. スプリアスウェイクアップ (Spurious Wakeup)
スプリアスウェイクアップとは、条件変数を待機しているスレッド(またはゴルーチン)が、その待機条件が満たされていないにもかかわらず、OSやランタイムによって予期せずウェイクアップされてしまう現象を指します。
これは、マルチプロセッサシステムにおけるスケジューリングの複雑さや、セマフォの実装の詳細に起因することがあります。セマフォの`V`操作が呼び出された際に、複数の待機スレッドが存在する場合、意図したスレッド以外がウェイクアップされる、あるいは、ウェイクアップのシグナルが送られた直後に条件が再び無効になる、といった状況で発生し得ます。
スプリアスウェイクアップは、並行プログラミングにおいて考慮すべき重要な問題です。これを防ぐためには、ウェイクアップされたスレッドは必ず**ループ内で条件を再確認する**必要があります。つまり、`while (condition_not_met) { wait(); }` のようなパターンで待機することが推奨されます。
`WaitGroup`の文脈では、カウンタがゼロになったときにのみ`Wait`から戻るべきですが、スプリアスウェイクアップが発生すると、カウンタがゼロではないのに`Wait`から戻ってしまうという問題が生じます。
### 4. `sync/atomic`パッケージ
`sync/atomic`パッケージは、低レベルなアトミック操作(不可分操作)を提供します。アトミック操作は、複数のゴルーチンから同時にアクセスされても、その操作全体が単一の不可分な単位として実行されることを保証します。これにより、競合状態を回避し、データの整合性を保つことができます。
* **`atomic.LoadInt32(&addr)`**: 指定されたメモリアドレス`addr`から`int32`の値をアトミックに読み込みます。この操作は、他のゴルーチンによる書き込みと競合することなく、常に最新の値を読み込むことを保証します。
このコミットでは、`WaitGroup`のカウンタの状態をアトミックに読み取るために`atomic.LoadInt32`が使用されています。
## 技術的詳細
このコミットの技術的な核心は、`WaitGroup`の`Add`メソッド内でのセマフォ解放ロジックの変更にあります。
変更前のコードでは、`Add`メソッドが`delta`を加算した結果、`wg.counter`がゼロになった場合、`wg.m.Lock()`でロックを取得した後、無条件に`wg.waiters`の数だけ`runtime_Semrelease(wg.sema)`を呼び出して待機中のゴルーチンを解放していました。その後、`wg.waiters`と`wg.sema`をリセットしていました。
```go
// 変更前 (簡略化)
func (wg *WaitGroup) Add(delta int) {
// ...カウンタの更新...
wg.m.Lock()
// ここでwg.counterが0になったと仮定
for i := int32(0); i < wg.waiters; i++ {
runtime_Semrelease(wg.sema) // 待機中のゴルーチンを解放
}
wg.waiters = 0
wg.sema = nil
wg.m.Unlock()
}
このロジックの問題点は、wg.m.Lock()
を取得した直後、かつruntime_Semrelease
を呼び出す前に、別のゴルーチンがAdd(1)
を呼び出してカウンタを再びインクリメントし、その後すぐにWait()
を呼び出して待機キューに自身を追加する可能性がある点です。
コミットメッセージのシナリオを再確認します。
- G1:
wg.counter
をデクリメントし、0になったことを確認- G1は
Done()
を呼び出し、カウンタが0になったため、待機中のゴルーチンを解放する準備をします。 - G1は
wg.m.Lock()
を試みます。
- G1は
- G2:
wg.counter
をインクリメント- G2は
Add(1)
を呼び出し、カウンタを1増やします。
- G2は
- G2:
Wait()
を呼び出して待機キューに追加- G2は
Wait()
を呼び出し、wg.counter
が0ではないため、自身を待機キューに追加し、ブロックされます。
- G2は
- G1:
wg.m
を取得し、待機中のゴルーチンをアンブロック- G1がロックを取得します。この時、G2は既に待機キューにいます。
- 変更前のコードでは、G1はロックを取得した時点で
wg.counter
が0であるという「過去の事実」に基づいて、無条件にwg.waiters
の数だけruntime_Semrelease
を呼び出していました。 - この結果、G2が待機キューに追加されたばかりであるにもかかわらず、G1によってスプリアスにウェイクアップされてしまう可能性がありました。
この問題を解決するため、修正後のコードでは、wg.m.Lock()
を取得した後、再度wg.counter
が本当にゼロであるかを確認する条件分岐が追加されました。
// 変更後 (簡略化)
func (wg *WaitGroup) Add(delta int) {
// ...カウンタの更新...
wg.m.Lock()
// ロック取得後、再度カウンタが0か確認
if atomic.LoadInt32(&wg.counter) == 0 { // ここが変更点
for i := int32(0); i < wg.waiters; i++ {
runtime_Semrelease(wg.sema)
}
wg.waiters = 0
wg.sema = nil
}
wg.m.Unlock()
}
atomic.LoadInt32(&wg.counter) == 0
という条件が追加されたことで、以下のようになります。
- G1がロックを取得した時点で、もしG2が既にカウンタをインクリメントしていた場合(つまり
wg.counter
が0ではない場合)、atomic.LoadInt32(&wg.counter)
は0ではない値を返すため、runtime_Semrelease
のループは実行されません。 - これにより、G2はカウンタが再び0になるまで正しく待機し続けることが保証され、スプリアスウェイクアップが防止されます。
atomic.LoadInt32
を使用しているのは、wg.counter
が複数のゴルーチンによって同時に読み書きされる可能性があるため、競合状態を避けて正確な最新の値を読み取るためです。通常の読み込みでは、コンパイラやCPUの最適化によって古い値が読み込まれる可能性があり、正確な判断ができません。アトミック操作は、このようなメモリバリアの役割も果たし、可視性を保証します。
この修正により、WaitGroup
のWait
メソッドは、カウンタが実際にゼロになった場合にのみゴルーチンを解放するようになり、より堅牢で予測可能な動作が実現されました。
コアとなるコードの変更箇所
src/pkg/sync/waitgroup.go
--- a/src/pkg/sync/waitgroup.go
+++ b/src/pkg/sync/waitgroup.go
@@ -67,11 +67,13 @@ func (wg *WaitGroup) Add(delta int) {
return
}
wg.m.Lock()
- for i := int32(0); i < wg.waiters; i++ {
- runtime_Semrelease(wg.sema)
+ if atomic.LoadInt32(&wg.counter) == 0 {
+ for i := int32(0); i < wg.waiters; i++ {
+ runtime_Semrelease(wg.sema)
+ }
+ wg.waiters = 0
+ wg.sema = nil
}
- wg.waiters = 0
- wg.sema = nil
wg.m.Unlock()
}
src/pkg/sync/waitgroup_test.go
--- a/src/pkg/sync/waitgroup_test.go
+++ b/src/pkg/sync/waitgroup_test.go
@@ -6,6 +6,7 @@ package sync_test
import (
. "sync"
+ "sync/atomic"
"testing"
)
@@ -59,6 +60,31 @@ func TestWaitGroupMisuse(t *testing.T) {
t.Fatal("Should panic")
}
+func TestWaitGroupRace(t *testing.T) {
+ // Run this test for about 1ms.
+ for i := 0; i < 1000; i++ {
+ wg := &WaitGroup{}
+ n := new(int32)
+ // spawn goroutine 1
+ wg.Add(1)
+ go func() {
+ atomic.AddInt32(n, 1)
+ wg.Done()
+ }()
+ // spawn goroutine 2
+ wg.Add(1)
+ go func() {
+ atomic.AddInt32(n, 1)
+ wg.Done()
+ }()
+ // Wait for goroutine 1 and 2
+ wg.Wait()
+ if atomic.LoadInt32(n) != 2 {
+ t.Fatal("Spurious wakeup from Wait")
+ }
+ }
+}
+
func BenchmarkWaitGroupUncontended(b *testing.B) {
type PaddedWaitGroup struct {
WaitGroup
コアとなるコードの解説
src/pkg/sync/waitgroup.go
の変更点
WaitGroup
構造体のAdd
メソッド内の変更です。
-
削除された行:
- for i := int32(0); i < wg.waiters; i++ { - runtime_Semrelease(wg.sema) - } - wg.waiters = 0 - wg.sema = nil
これらの行は、
wg.m.Lock()
を取得した後に、無条件に待機中のゴルーチンを解放し、waiters
とsema
をリセットしていました。 -
追加された行:
+ if atomic.LoadInt32(&wg.counter) == 0 { + for i := int32(0); i < wg.waiters; i++ { + runtime_Semrelease(wg.sema) + } + wg.waiters = 0 + wg.sema = nil + }
この変更の核心は、
if atomic.LoadInt32(&wg.counter) == 0
という条件文の追加です。wg.m.Lock()
でミューテックスをロックした後、再度wg.counter
が本当にゼロであるかをatomic.LoadInt32
を使ってアトミックに確認します。atomic.LoadInt32
を使用することで、他のゴルーチンによるwg.counter
の変更が正しく可視化され、最新の正確な値が読み込まれることが保証されます。- この条件が真(つまり、ロックを取得した時点でもカウンタがゼロである)の場合にのみ、
runtime_Semrelease
を呼び出して待機中のゴルーチンを解放し、waiters
とsema
をリセットする処理が実行されます。
この変更により、コミットメッセージで説明されている競合状態が回避されます。G1がロックを取得した直後にG2がカウンタをインクリメントした場合、G1はatomic.LoadInt32(&wg.counter)
が0ではないことを検出し、誤ってG2をウェイクアップすることなく、解放処理をスキップします。これにより、G2はカウンタが再び0になるまで正しく待機し続けることができます。
src/pkg/sync/waitgroup_test.go
の変更点
このファイルには、TestWaitGroupRace
という新しいテスト関数が追加されています。
import "sync/atomic"
の追加: 新しいテストでatomic
パッケージを使用するため、インポートが追加されています。TestWaitGroupRace
関数:- このテストは、スプリアスウェイクアップの競合状態を再現し、修正が正しく機能することを確認するために設計されています。
for i := 0; i < 1000; i++
ループにより、テストが1000回繰り返され、競合状態が発生する可能性を高めています。- 各イテレーションで新しい
WaitGroup
とカウンタn
(int32
型でアトミック操作用)が初期化されます。 - ゴルーチン1の起動:
wg.Add(1)
でカウンタを1増やします。- 新しいゴルーチンを起動し、その中で
atomic.AddInt32(n, 1)
でn
をインクリメントし、wg.Done()
を呼び出します。
- ゴルーチン2の起動:
wg.Add(1)
でカウンタを1増やします。- 新しいゴルーチンを起動し、その中で
atomic.AddInt32(n, 1)
でn
をインクリメントし、wg.Done()
を呼び出します。
wg.Wait()
: メインゴルーチンは、上記2つのゴルーチンが完了するまでwg.Wait()
で待機します。- 検証:
if atomic.LoadInt32(n) != 2
- 2つのゴルーチンがそれぞれ
n
を1ずつインクリメントするため、wg.Wait()
から戻った時点でn
の値は必ず2になっているはずです。 - もしスプリアスウェイクアップが発生し、いずれかのゴルーチンが
Wait
から意図せず早く戻ってしまった場合、n
の値が2にならない可能性があります(例えば、片方のゴルーチンがまだatomic.AddInt32
を呼び出す前にWait
が解除されてしまうなど)。 t.Fatal("Spurious wakeup from Wait")
は、n
が2でなければテストを失敗させ、スプリアスウェイクアップがまだ発生していることを示します。
- 2つのゴルーチンがそれぞれ
このテストは、修正が導入されたことで、以前は発生し得たスプリアスウェイクアップが確実に防止されることを検証するための重要な役割を果たしています。
関連リンク
- GitHubコミットページ: https://github.com/golang/go/commit/e9347c781be66056bbc724f4d70d4b8b9bc0288c
- Go CL (Code Review): https://golang.org/cl/85580043
- 関連するIssue: https://github.com/golang/go/issues/7734
参考にした情報源リンク
- Go言語の
sync.WaitGroup
に関する公式ドキュメントや解説記事 - セマフォとスプリアスウェイクアップに関する並行プログラミングの一般的な概念を説明する資料
- Go言語の
sync/atomic
パッケージに関するドキュメント