Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 19096] ファイルの概要

このコミットは、Go言語のsyncパッケージにおけるWaitGroupWaitメソッドで発生する「スプリアスウェイクアップ(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からのスプリアスウェイクアップを引き起こす競合状態が存在します。

  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_Semacquireruntime_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が使用されています。

技術的詳細

このコミットの技術的な核心は、WaitGroupAddメソッド内でのセマフォ解放ロジックの変更にあります。

変更前のコードでは、Addメソッドがdeltaを加算した結果、wg.counterがゼロになった場合、wg.m.Lock()でロックを取得した後、無条件にwg.waitersの数だけruntime_Semrelease(wg.sema)を呼び出して待機中のゴルーチンを解放していました。その後、wg.waiterswg.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()を呼び出して待機キューに自身を追加する可能性がある点です。

コミットメッセージのシナリオを再確認します。

  1. G1: wg.counterをデクリメントし、0になったことを確認
    • G1はDone()を呼び出し、カウンタが0になったため、待機中のゴルーチンを解放する準備をします。
    • G1はwg.m.Lock()を試みます。
  2. G2: wg.counterをインクリメント
    • G2はAdd(1)を呼び出し、カウンタを1増やします。
  3. G2: Wait()を呼び出して待機キューに追加
    • G2はWait()を呼び出し、wg.counterが0ではないため、自身を待機キューに追加し、ブロックされます。
  4. 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の最適化によって古い値が読み込まれる可能性があり、正確な判断ができません。アトミック操作は、このようなメモリバリアの役割も果たし、可視性を保証します。

この修正により、WaitGroupWaitメソッドは、カウンタが実際にゼロになった場合にのみゴルーチンを解放するようになり、より堅牢で予測可能な動作が実現されました。

コアとなるコードの変更箇所

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()を取得した後に、無条件に待機中のゴルーチンを解放し、waiterssemaをリセットしていました。

  • 追加された行:

    +	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という条件文の追加です。

    1. wg.m.Lock()でミューテックスをロックした後、再度wg.counterが本当にゼロであるかをatomic.LoadInt32を使ってアトミックに確認します。
    2. atomic.LoadInt32を使用することで、他のゴルーチンによるwg.counterの変更が正しく可視化され、最新の正確な値が読み込まれることが保証されます。
    3. この条件が真(つまり、ロックを取得した時点でもカウンタがゼロである)の場合にのみ、runtime_Semreleaseを呼び出して待機中のゴルーチンを解放し、waiterssemaをリセットする処理が実行されます。

この変更により、コミットメッセージで説明されている競合状態が回避されます。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とカウンタnint32型でアトミック操作用)が初期化されます。
    • ゴルーチン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でなければテストを失敗させ、スプリアスウェイクアップがまだ発生していることを示します。

このテストは、修正が導入されたことで、以前は発生し得たスプリアスウェイクアップが確実に防止されることを検証するための重要な役割を果たしています。

関連リンク

参考にした情報源リンク

  • 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()を呼び出して待機キューに自身を追加する可能性がある点です。

コミットメッセージのシナリオを再確認します。

  1. G1: wg.counterをデクリメントし、0になったことを確認
    • G1はDone()を呼び出し、カウンタが0になったため、待機中のゴルーチンを解放する準備をします。
    • G1はwg.m.Lock()を試みます。
  2. G2: wg.counterをインクリメント
    • G2はAdd(1)を呼び出し、カウンタを1増やします。
  3. G2: Wait()を呼び出して待機キューに追加
    • G2はWait()を呼び出し、wg.counterが0ではないため、自身を待機キューに追加し、ブロックされます。
  4. 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の最適化によって古い値が読み込まれる可能性があり、正確な判断ができません。アトミック操作は、このようなメモリバリアの役割も果たし、可視性を保証します。

この修正により、WaitGroupWaitメソッドは、カウンタが実際にゼロになった場合にのみゴルーチンを解放するようになり、より堅牢で予測可能な動作が実現されました。

コアとなるコードの変更箇所

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()を取得した後に、無条件に待機中のゴルーチンを解放し、waiterssemaをリセットしていました。

  • 追加された行:

    +	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という条件文の追加です。

    1. wg.m.Lock()でミューテックスをロックした後、再度wg.counterが本当にゼロであるかをatomic.LoadInt32を使ってアトミックに確認します。
    2. atomic.LoadInt32を使用することで、他のゴルーチンによるwg.counterの変更が正しく可視化され、最新の正確な値が読み込まれることが保証されます。
    3. この条件が真(つまり、ロックを取得した時点でもカウンタがゼロである)の場合にのみ、runtime_Semreleaseを呼び出して待機中のゴルーチンを解放し、waiterssemaをリセットする処理が実行されます。

この変更により、コミットメッセージで説明されている競合状態が回避されます。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とカウンタnint32型でアトミック操作用)が初期化されます。
    • ゴルーチン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でなければテストを失敗させ、スプリアスウェイクアップがまだ発生していることを示します。

このテストは、修正が導入されたことで、以前は発生し得たスプリアスウェイクアップが確実に防止されることを検証するための重要な役割を果たしています。

関連リンク

参考にした情報源リンク

  • Go言語のsync.WaitGroupに関する公式ドキュメントや解説記事
  • セマフォとスプリアスウェイクアップに関する並行プログラミングの一般的な概念を説明する資料
  • Go言語のsync/atomicパッケージに関するドキュメント