🔄

[Go 1.25] WaitGroup.Go()を使って既存コードを書き換える際の注意点

に公開

この記事でわかること

  • Go 1.25で追加されたWaitGroup.Go()メソッドの使い方
  • 従来の並行処理コードからWaitGroup.Go()への移行方法と注意点
  • Go 1.22のループ変数仕様変更がもたらす安全性の向上
  • 実践的な移行パターンとコード例
  • 移行時のトラブルシューティング

対象読者

  • Goで並行処理を実装している開発者
  • Go 1.25へのアップグレードを検討している方
  • sync.WaitGroupを使った並行処理パターンを理解したい方

概要

Go 1.25で追加されたWaitGroup.Go()メソッドは、並行処理のコードをより簡潔で安全に書けるようにする重要な機能です。しかし、従来の書き方から移行する際には、関数シグネチャの違いやGo 1.22のループ変数の仕様変更を理解する必要があります。本記事では、実際の移行経験をもとに、安全に移行するための具体的な手順を解説します。


Go 1.25のWaitGroup.Go()メソッドとは

新機能の概要

Go 1.25でsync.WaitGroupに新しくGo()メソッドが追加されました。このメソッドは、goroutineの生成と完了を集計する一般的なパターンをより便利に記述できるようにします。

公式ドキュメント: https://go.dev/doc/go1.25#syncpkgsync

従来の書き方

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)  // goroutine起動前にカウンタをインクリメント
    go func(i int) {
        defer wg.Done()  // goroutine終了時にカウンタをデクリメント
        fmt.Printf("Task %d\n", i)
    }(i)
}

wg.Wait()
fmt.Println("All tasks completed")

Go 1.25での書き方

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Go(func() {  // Add()とDefer wg.Done()が不要
        fmt.Printf("Task %d\n", i)
    })
}

wg.Wait()
fmt.Println("All tasks completed")

WaitGroup.Go()の内部実装

実際の実装は非常にシンプルです:

func (wg *WaitGroup) Go(f func()) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        f()
    }()
}

この実装から分かる通り、Go()メソッドは:

  1. 自動的にwg.Add(1)を呼び出す
  2. goroutineを起動
  3. goroutine内でdefer wg.Done()を設定
  4. 渡された関数fを実行

つまり、開発者が手動で書いていたボイラープレートコードを内部で処理してくれます。


移行時の重要な注意点

関数シグネチャの違い

WaitGroup.Go()メソッドのシグネチャは以下の通りです:

func (wg *WaitGroup) Go(f func())

重要: 引数として渡せるのは引数を取らない関数 func() のみです。

よくあるエラー

従来の書き方をそのまま置き換えようとすると、コンパイルエラーが発生します:

// ❌ コンパイルエラー
for i := 0; i < 5; i++ {
    wg.Go(func(i int) {  // func(int)は受け取れない
        fmt.Printf("Task %d\n", i)
    }(i))
}

エラーメッセージ例:

cannot use func(i int) literal (value of type func(int)) as func() value in argument to wg.Go

正しい移行方法

Go 1.22以降では、ループ変数を直接クロージャでキャプチャできます:

// ✅ 正しい書き方 (Go 1.22+)
for i := 0; i < 5; i++ {
    wg.Go(func() {  // 引数なし
        fmt.Printf("Task %d\n", i)  // 外側のiを参照
    })
}

Go 1.22のループ変数の変更が鍵

なぜ安全に移行できるのか

Go 1.25のWaitGroup.Go()への移行が安全に行えるのは、Go 1.22でループ変数の扱いが変更されたからです。

公式ドキュメント:

Go 1.21以前の問題

Go 1.21以前では、ループ変数は1つのメモリアドレスを使い回していました。

// Go 1.21以前
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i)  // すべてのgoroutineが同じメモリアドレスを参照
    }()
}
time.Sleep(time.Second)
// 出力: 3 3 3 (すべて最後の値)

なぜこうなるのか?

タイムライン形式で説明します:

時間 →

メインスレッド:
[i=0][i=1][i=2][i=3, ループ終了]
0μs  1μs  2μs  3μs

メモリアドレス 0x100 の i の値:
0 → 1 → 2 → 3 → 3 → 3 →
                ↑
            ここでgoroutine実行開始

goroutine1: [起動待ち...] [実行: 3を出力]
goroutine2: [起動待ち...] [実行: 3を出力]
goroutine3: [起動待ち...] [実行: 3を出力]
           100μs        200μs

ポイント:

  1. ループは超高速(数マイクロ秒)で完了
  2. goroutineの起動には時間がかかる(数十〜数百マイクロ秒)
  3. goroutineが実行される頃には、ループが終わってiは最後の値になっている
  4. すべてのgoroutineが同じメモリアドレスを参照

Go 1.21以前の回避策

// 回避策1: 引数で値をコピー
for i := 0; i < 3; i++ {
    go func(i int) {  // 引数で受け取る
        fmt.Println(i)
    }(i)  // その時点の値をコピー
}

// 回避策2: 新しい変数を作成
for i := 0; i < 3; i++ {
    i := i  // シャドーイング
    go func() {
        fmt.Println(i)
    }()
}

Go 1.22以降の改善

Go 1.22では、各イテレーションで新しいメモリ領域を確保するように変更されました。

公式の説明:

Previously, the variables declared by a "for" loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.

// Go 1.22以降
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i)  // 各goroutineが独自のメモリを参照
    }()
}
time.Sleep(time.Second)
// 出力: 0 1 2 (正しい値)

メモリの違い

Go 1.21以前:

メモリアドレス 0x100 に変数 i を確保

イテレーション1: 0x100 の値を 0 に更新
イテレーション2: 0x100 の値を 1 に更新
イテレーション3: 0x100 の値を 2 に更新

→ すべてのgoroutineが 0x100 を参照

Go 1.22以降:

イテレーション1: 0x100 に変数 i を確保、値は 0
イテレーション2: 0x200 に変数 i を確保、値は 1
イテレーション3: 0x300 に変数 i を確保、値は 2

→ 各goroutineが別々のメモリアドレスを参照

実践的な移行パターン

パターン1: シンプルなループ

移行前 (Go 1.21以前の安全な書き方)

var wg sync.WaitGroup
urls := []string{"url1", "url2", "url3"}

for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        response, err := http.Get(u)
        if err != nil {
            log.Printf("Error fetching %s: %v", u, err)
            return
        }
        defer response.Body.Close()
        // 処理...
    }(url)
}

wg.Wait()

移行後 (Go 1.25 with Go 1.22+)

var wg sync.WaitGroup
urls := []string{"url1", "url2", "url3"}

for _, url := range urls {
    wg.Go(func() {
        response, err := http.Get(url)  // 直接参照
        if err != nil {
            log.Printf("Error fetching %s: %v", url, err)
            return
        }
        defer response.Body.Close()
        // 処理...
    })
}

wg.Wait()

パターン2: インデックスを使用する場合

移行前

var wg sync.WaitGroup
items := []Item{...}
results := make([]Result, len(items))

for i := range items {
    wg.Add(1)
    go func(idx int) {
        defer wg.Done()
        results[idx] = processItem(items[idx])
    }(i)
}

wg.Wait()

移行後

var wg sync.WaitGroup
items := []Item{...}
results := make([]Result, len(items))

for i := range items {
    wg.Go(func() {
        results[i] = processItem(items[i])  // iを直接参照
    })
}

wg.Wait()

注意が必要なケース

ケース1: Go 1.21以前をサポートする必要がある場合
→ 従来の書き方を維持してください

ケース2: wg.Done()を条件付きで呼ぶ場合
WaitGroup.Go()は常にDone()を呼ぶため、条件分岐がある場合は注意

// ❌ このパターンは移行できない
wg.Add(1)
go func() {
    if someCondition {
        defer wg.Done()
        // 処理
    } else {
        wg.Done()
        return
    }
}()

ケース3: パニックリカバリが必要な場合
WaitGroup.Go()のドキュメントには「関数fはパニックしてはいけない」と記載されています

// パニックリカバリが必要な場合は独自に実装
wg.Go(func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Recovered from panic: %v", r)
        }
    }()
    // 処理
})

まとめ

重要ポイント

  1. Go 1.25のWaitGroup.Go()は並行処理を簡潔に書ける

    • wg.Add(1)defer wg.Done()が不要
    • ボイラープレートコードを削減
  2. 関数シグネチャに注意

    • WaitGroup.Go()func()型の関数のみ受け取る
    • 引数を渡すパターンは使えない
  3. Go 1.22のループ変数変更が安全性を保証

    • 各イテレーションで新しい変数が作られる
    • クロージャで直接ループ変数を参照できる
  4. 移行は段階的に

    • Go 1.22以上であることを確認
    • テストでデータ競合をチェック(go test -race
    • パニックリカバリが必要な場合は独自実装

参考リンク

Discussion