[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()メソッドは:
- 自動的に
wg.Add(1)を呼び出す - goroutineを起動
- goroutine内で
defer wg.Done()を設定 - 渡された関数
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
ポイント:
- ループは超高速(数マイクロ秒)で完了
- goroutineの起動には時間がかかる(数十〜数百マイクロ秒)
- goroutineが実行される頃には、ループが終わって
iは最後の値になっている - すべての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)
}
}()
// 処理
})
まとめ
重要ポイント
-
Go 1.25の
WaitGroup.Go()は並行処理を簡潔に書ける-
wg.Add(1)とdefer wg.Done()が不要 - ボイラープレートコードを削減
-
-
関数シグネチャに注意
-
WaitGroup.Go()はfunc()型の関数のみ受け取る - 引数を渡すパターンは使えない
-
-
Go 1.22のループ変数変更が安全性を保証
- 各イテレーションで新しい変数が作られる
- クロージャで直接ループ変数を参照できる
-
移行は段階的に
- Go 1.22以上であることを確認
- テストでデータ競合をチェック(
go test -race) - パニックリカバリが必要な場合は独自実装
Discussion