Goで複数のgoroutineを動かして終わるのを待つとき、sync.WaitGroup と errgroup.Group(golang.org/x/sync/errgroup)のどちらを使うかを、待ち方・エラーの伝わり方・取消しの違いで整理しました。同じ3つの処理を両方で動かし、いつ Wait が戻るか、どのエラーが返るか、取消しで何が止まるかを経過時間つきで比べています。Go 1.25で追加された WaitGroup.Go と、同時に動かす数を絞る SetLimit も使います。
結論
A(300msで成功)、B(100msで失敗)、C(200msで失敗)の3つの処理を同時に動かしたときの結果です。
| やりたいこと | 使うもの | Wait が戻った時間 | 返ったエラー |
|---|---|---|---|
| 終わるのを待つだけ | sync.WaitGroup(Go 1.25以降は wg.Go) | 300ms | なし(返す仕組みがない) |
| 全部のエラーを知りたい | sync.WaitGroup と、エラーを入れるスライス、errors.Join | 300ms | BとCの両方 |
| 最初のエラーがわかればよい | errgroup.Group | 300ms | Bだけ |
| 1つが失敗したら残りをやめたい | errgroup.WithContext と、ctxを見て終わる処理 | 100ms | Bだけ |
| 同時に動かす数を絞りたい | errgroup.Group の SetLimit | (下の章) | (下の章) |
errgroup.GroupのWaitも、全部の処理が終わるまで待ちます。最初のエラーが起きても、ほかの処理は止まりません。WithContextがするのは、最初のエラーで ctx を取り消すことだけです。処理を途中で終わらせるのは、ctx を見ている処理自身のコードです。ctx を見ない処理は、WithContextを使っても300msまで動き続けました。SetLimitで上限に達しているあいだは、g.Goの呼び出しそのものが、空きが出るまで戻りません。
動作確認した環境
- Go 1.27.3(Dockerイメージ
golang:1.27.3。digestはsha256:e519d3b0a5b285e5aa53ab9eef02bc6bbb9cb04608389b743a136a9ce75f0d1e) golang.org/x/syncv0.24.0- Docker 29.7.2(Windows 11 の Docker Desktop、Linuxコンテナの設定、x86_64)
- 確認日: 2026年11月9日
この記事は、MacとWindowsの両方の読者を対象にしています。Goのコードとgoのコマンドは、MacでもWindowsでも同じです。この記事のコマンドは、Claude(AI)が実行して確認しました。本文の出力は、Windows 11 のGit BashからDockerのコンテナを起動し、その中で上から順に実行した結果です。WindowsのPowerShell 7 では、下の起動のコマンドから -it と bash を除いてコマンドを直接渡し、フォルダが見えることと、go run . eg-ctx の結果が本文と同じことを確かめました。Macでは確認していません。
経過時間は10ms単位に丸めて表示しています。同時に動く処理の表示の順番や、ぴったり同じ時間に起きたことの順番は、実行のたびに変わることがあります。
準備
空のフォルダに、共通の部分を書いた main.go を作ります。各章のファイル(wg.go など)は、章ごとに同じフォルダに追加していきます。それぞれのファイルの init で、go run . wg-add のように名前で呼べるように登録します。
run は ctx を見ない処理、runCtx は ctx を見て、取り消されたら途中で終わる処理です。どちらも tasks の3つ(A、B、C)を使います。
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
)
// demos には、各ファイルの init で「名前 → 関数」を登録する
var demos = map[string]func(){}
var (
start time.Time
logMu sync.Mutex
)
// logf は、開始からの経過時間(10ミリ秒単位に丸める)をつけて1行表示する
func logf(format string, args ...any) {
logMu.Lock()
defer logMu.Unlock()
ms := time.Since(start).Round(10 * time.Millisecond).Milliseconds()
fmt.Printf("%4dms %s\n", ms, fmt.Sprintf(format, args...))
}
// task は、時間のかかる処理(API の呼び出しなど)の代わり
type task struct {
name string
d time.Duration // かかる時間
fail bool // true なら最後にエラーを返す
}
// A は300ms で成功、B は100ms で失敗、C は200ms で失敗する
var tasks = []task{
{"A", 300 * time.Millisecond, false},
{"B", 100 * time.Millisecond, true},
{"C", 200 * time.Millisecond, true},
}
// run は ctx を見ない。始めたら、d が過ぎるまで終わらない
func run(t task) error {
time.Sleep(t.d)
return finish(t)
}
// runCtx は ctx を見る。d が過ぎる前に取り消されたら、そこで終わる
func runCtx(ctx context.Context, t task) error {
timer := time.NewTimer(t.d)
defer timer.Stop()
select {
case <-timer.C:
return finish(t)
case <-ctx.Done():
logf("%s: 取り消されたので終了", t.name)
return ctx.Err()
}
}
func finish(t task) error {
if t.fail {
logf("%s: エラー", t.name)
return fmt.Errorf("%s failed", t.name)
}
logf("%s: 完了", t.name)
return nil
}
func main() {
if len(os.Args) < 2 || demos[os.Args[1]] == nil {
fmt.Println("usage: go run . <demo>")
os.Exit(2)
}
start = time.Now()
demos[os.Args[1]]()
}
このフォルダで、Goのコンテナを起動します。フォルダをコンテナの /src に見せるので、ホストのエディタでファイルを追加すると、コンテナからも見えます。Macのターミナルでは次のとおりです(Macでは未確認。$(pwd) はDockerの公式ドキュメントのバインドマウントの例と同じ書き方です)。
$ docker run --rm -it -v "$(pwd):/src" -w /src golang:1.27.3 bash
WindowsのPowerShellでは、$(pwd) を ${PWD} にします。
PS> docker run --rm -it -v "${PWD}:/src" -w /src golang:1.27.3 bash
WindowsのGit Bashから起動する場合は、Git Bashが /src のような引数をWindowsのパスに変換しないようにします(Dockerの公式ドキュメントのトラブルシューティングにある MSYS_NO_PATHCONV=1)。本文の確認では、export MSYS_NO_PATHCONV=1 をしたうえで、フォルダを -v "$(pwd -W):/src" で指定しました。
ここから先の /src# で始まる行は、コンテナの中のシェルで打つコマンドです。モジュールを作ります。
/src# go version
go version go1.27.3 linux/amd64
/src# go mod init example.com/errgroup-demo
go: creating new go.mod: module example.com/errgroup-demo
go: to add module requirements and sums:
go mod tidy
手元にGoを入れている場合は、コンテナを使わずに同じ go のコマンドで動かせるはずです(確認はしていません)。WaitGroup.Go にはGo 1.25以降、この記事の golang.org/x/sync v0.24.0 にはGo 1.26以降が必要です(v0.24.0 の go.mod の指定)。
sync.WaitGroup:終わるのを待つ
wg.go です。Add と Done で数える書き方、Go 1.25で追加された wg.Go を使う書き方、エラーを自分で集める書き方の3つです。
package main
import (
"errors"
"fmt"
"sync"
)
func init() {
demos["wg-add"] = wgAdd
demos["wg-go"] = wgGo
demos["wg-errors"] = wgErrors
}
// Add と Done で数える書き方(Go 1.24 までの定番)
func wgAdd() {
var wg sync.WaitGroup
for _, t := range tasks {
wg.Add(1) // goroutine を起動する前に数を増やす
go func() {
defer wg.Done()
_ = run(t) // WaitGroup には、エラーを返す仕組みがない
}()
}
wg.Wait()
logf("Wait が戻った")
}
// Go 1.25 で追加された WaitGroup.Go を使う書き方
func wgGo() {
var wg sync.WaitGroup
for _, t := range tasks {
wg.Go(func() {
_ = run(t)
})
}
wg.Wait()
logf("Wait が戻った")
}
// WaitGroup で、全部のエラーを集める
func wgErrors() {
var wg sync.WaitGroup
errs := make([]error, len(tasks))
for i, t := range tasks {
wg.Go(func() {
errs[i] = run(t) // goroutine ごとに書き込む場所を分ける
})
}
wg.Wait()
logf("Wait が戻った")
fmt.Println("err:", errors.Join(errs...))
}
/src# go run . wg-add
100ms B: エラー
200ms C: エラー
300ms A: 完了
300ms Wait が戻った
/src# go run . wg-go
100ms B: エラー
200ms C: エラー
300ms A: 完了
300ms Wait が戻った
どちらも、いちばん遅いAが終わった300msで Wait が戻りました。BとCはエラーを返していますが、WaitGroup にはエラーを受け取る仕組みがないので、呼び出し側には伝わりません。
wg.Go(f) は、Add(1)、goroutineの起動、f が戻ったときの Done をまとめたものです。公式ドキュメントでは、Add と Done より Go を使うよう勧めています。Add を使う場合は、数が0のときの Add を Wait より前に呼ぶ必要があり、ふつうはgoroutineを起動する文の前に書きます。wg.Go に渡す関数は、panicしてはいけないとされています。
ループの中の関数で t や i をそのまま使っているのは、Go 1.22から for のループ変数が繰り返しごとに別の変数になったためです。
全部のエラーを集める
wgErrors は、goroutineごとに errs の別の要素へ結果を書き込み、Wait の後で errors.Join にまとめます。公式ドキュメントによると、wg.Go に渡した関数が戻ることは、それによって戻る Wait より前に起きます(Goのメモリモデルの「synchronizes before」)。書き込む場所が重ならず、読むのは Wait の後なので、ロックは使っていません。
/src# go run . wg-errors
100ms B: エラー
200ms C: エラー
300ms A: 完了
300ms Wait が戻った
err: B failed
C failed
BとCの両方のエラーが返りました(errors.Join は改行でつないで表示します)。全部の処理を最後まで動かし、失敗をすべて知りたいときは、この形が使えます。
errgroup.Group:最初のエラーを返す
eg.go です。ゼロ値の errgroup.Group で、同じ3つの処理を動かします。g.Go には、error を返す関数を渡します。
package main
import (
"fmt"
"golang.org/x/sync/errgroup"
)
func init() {
demos["eg-first"] = egFirst
}
// ゼロ値の errgroup.Group。取消しも同時数の制限もない
func egFirst() {
var g errgroup.Group
for _, t := range tasks {
g.Go(func() error {
return run(t)
})
}
err := g.Wait()
logf("Wait が戻った")
fmt.Println("err:", err)
}
errgroup は標準ライブラリではないので、版を指定して追加し、go mod tidy で整えます。
/src# go get golang.org/x/sync@v0.24.0
go: downloading golang.org/x/sync v0.24.0
go: added golang.org/x/sync v0.24.0
/src# go mod tidy
/src# cat go.mod
module example.com/errgroup-demo
go 1.27.3
require golang.org/x/sync v0.24.0
/src# go run . eg-first
100ms B: エラー
200ms C: エラー
300ms A: 完了
300ms Wait が戻った
err: B failed
Bが100msで失敗した後も、Cは200msまで、Aは300msまで動き、Wait は300msで戻りました。返ったのは最初のエラー(B)だけで、Cのエラーは返りません。公式ドキュメントでも、Wait は全部の関数が戻るまで待ち、最初のnilでないエラーを返す、とされています。また、ゼロ値の Group は、エラーで取り消しをしません。
errgroup.WithContext:残りを取り消す
ctx.go です。どちらも errgroup.WithContext を使いますが、egCtxIgnore の処理は ctx を見ず、egCtx の処理は ctx を見ます(main.go の runCtx)。
package main
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
func init() {
demos["eg-ctx-ignore"] = egCtxIgnore
demos["eg-ctx"] = egCtx
}
// WithContext を使うが、各処理が ctx を見ない
func egCtxIgnore() {
g, _ := errgroup.WithContext(context.Background())
for _, t := range tasks {
g.Go(func() error {
return run(t)
})
}
err := g.Wait()
logf("Wait が戻った")
fmt.Println("err:", err)
}
// WithContext を使い、各処理が ctx を見て終わる
func egCtx() {
g, ctx := errgroup.WithContext(context.Background())
for _, t := range tasks {
g.Go(func() error {
return runCtx(ctx, t)
})
}
err := g.Wait()
logf("Wait が戻った")
fmt.Println("err:", err)
fmt.Println("ctx.Err():", ctx.Err())
fmt.Println("context.Cause(ctx):", context.Cause(ctx))
}
ctx を見ない処理は止まらない
/src# go run . eg-ctx-ignore
100ms B: エラー
200ms C: エラー
300ms A: 完了
300ms Wait が戻った
err: B failed
ゼロ値の Group と同じく、300msまで全部が動きました。Bの失敗で ctx は取り消されていますが、run は time.Sleep で待っていて ctx を見ないので、止まりません。
ctx を見る処理は、取消しで終わる
/src# go run . eg-ctx
100ms B: エラー
100ms C: 取り消されたので終了
100ms A: 取り消されたので終了
100ms Wait が戻った
err: B failed
ctx.Err(): context canceled
context.Cause(ctx): B failed
Bが失敗した100msで、AとCが取り消しに気づいて終わり、Wait も100msで戻りました。公式ドキュメントによると、WithContext が返す ctx は、g.Go に渡した関数が初めてnilでないエラーを返したとき、または Wait が初めて戻ったときの、どちらか早いほうで取り消されます。
AとCは ctx.Err()(context canceled)を返していますが、Wait が返したのは最初のエラーの B failed です。また、context.Cause(ctx) で取消しの原因として B failed が取れました。v0.24.0 のソースでは、WithContext は context.WithCancelCause を使い、最初のエラーを原因にして取り消しています。
エラーがなくても、Wait が戻った時点でこの ctx は取り消されます。Wait の後の処理には、この ctx を使い回さないようにします。
SetLimit:同時に動かす数を絞る
limit.go です。egLimit は、100msかかる6個の処理を、SetLimit(2) で同時に2個までにして動かします。実行中の数と、その最大値を記録しています。egLimitCtx は、WithContext と組み合わせ、task1が50msで失敗するようにしたものです。
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
func init() {
demos["eg-limit"] = egLimit
demos["eg-limit-ctx"] = egLimitCtx
}
// 6個の処理(各100ms)を、同時に2個までで動かす
func egLimit() {
var g errgroup.Group
g.SetLimit(2)
var mu sync.Mutex
active, maxActive := 0, 0
for i := 1; i <= 6; i++ {
g.Go(func() error {
mu.Lock()
active++
maxActive = max(maxActive, active)
n := active
mu.Unlock()
logf("task%d: 開始(このとき %d 個が実行中)", i, n)
time.Sleep(100 * time.Millisecond)
mu.Lock()
active--
mu.Unlock()
return nil
})
}
logf("6回目の g.Go から戻った")
err := g.Wait()
logf("Wait が戻った")
fmt.Println("err:", err, "/ 同時に動いた最大の数:", maxActive)
}
// SetLimit と WithContext を組み合わせ、task1 が50ms で失敗する
func egLimitCtx() {
g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(2)
for i := 1; i <= 6; i++ {
if ctx.Err() != nil { // 取り消されたら、残りは g.Go に渡さない
logf("task%d から後は始めない", i)
break
}
t := task{fmt.Sprintf("task%d", i), 100 * time.Millisecond, false}
if i == 1 {
t.d, t.fail = 50*time.Millisecond, true
}
g.Go(func() error {
logf("%s: 開始", t.name)
return runCtx(ctx, t)
})
}
err := g.Wait()
logf("Wait が戻った")
fmt.Println("err:", err)
}
/src# go run . eg-limit
0ms task2: 開始(このとき 1 個が実行中)
0ms task1: 開始(このとき 2 個が実行中)
100ms task4: 開始(このとき 1 個が実行中)
100ms task3: 開始(このとき 2 個が実行中)
200ms task5: 開始(このとき 2 個が実行中)
200ms 6回目の g.Go から戻った
200ms task6: 開始(このとき 2 個が実行中)
300ms Wait が戻った
err: <nil> / 同時に動いた最大の数: 2
0ms、100ms、200msに2個ずつ始まり、全体は300msかかりました。同時に動いた数の最大は2です。「6回目の g.Go から戻った」が200msなので、ループの中の g.Go は、空きが出るまで戻らずに待っていたことがわかります。公式ドキュメントでも、上限に達しているあいだ g.Go はブロックするとされています。待たずに、空きがなければあきらめたいときは TryGo があります(起動できたかを bool で返します)。
SetLimit と WithContext を組み合わせる
/src# go run . eg-limit-ctx
0ms task2: 開始
0ms task1: 開始
50ms task1: エラー
50ms task4 から後は始めない
50ms task3: 開始
50ms task3: 取り消されたので終了
50ms task2: 取り消されたので終了
50ms Wait が戻った
err: task1 failed
task1が50msで失敗すると、実行中だったtask2は取り消しで終わりました。task3は、ループの中の g.Go で空きを待っていたので、取り消しの後に始まり、すぐに取り消しに気づいて終わりました。g.Go は、ctx が取り消された後でも、渡された関数を起動するということです。
残りを始めたくないときは、この例のように、g.Go を呼ぶ前に ctx.Err() を見てループを抜けます。今回はtask4の前で抜けました。ただし、この確認で止められるのは、確認した時点ですでに取り消されている場合だけです。task3のように、確認の後や、g.Go で空きを待っているあいだに取り消されたものは起動されるので、処理の側でも ctx を見る必要があります。「task4 から後は始めない」が「task3: 開始」より先に表示されているのは、g.Go が空きを取った時点で戻り、task3のgoroutineが表示するより先に、ループが次に進んだためです。
go test -race で競合を見つける
複数のgoroutineが、同期(sync.Mutex など)なしに同じ変数を読み書きし、少なくとも1つが書き込みのとき、データ競合になります。race_test.go で、100個のgoroutineから count を増やすテストを書きます。
package main
import (
"sync"
"testing"
)
func TestCount(t *testing.T) {
var wg sync.WaitGroup
count := 0
for range 100 {
wg.Go(func() {
count++ // 複数の goroutine が同じ変数を書き換える
})
}
wg.Wait()
if count != 100 {
t.Errorf("count = %d, want 100", count)
}
}
-race をつけてテストを実行します。出力は長いので、1つ目の警告の先頭と、最後の部分だけを載せます(途中を「(略)」としました)。
/src# go test -race .
==================
WARNING: DATA RACE
Read at 0x00c000194178 by goroutine 9:
example.com/errgroup-demo.TestCount.func1()
/src/race_test.go:13 +0x2e
Previous write at 0x00c000194178 by goroutine 19:
example.com/errgroup-demo.TestCount.func1()
/src/race_test.go:13 +0x44
(略)
--- FAIL: TestCount (0.00s)
testing.go:1867: race detected during execution of test
FAIL
FAIL example.com/errgroup-demo 0.010s
FAIL
今回の実行では count は100になったので、t.Errorf のメッセージは出ていません。それでも -race は、13行目(count++)の読み書きの競合を検出し、テストを失敗にしました。公式ドキュメントによると、race detector が見つけられるのは実行中に実際に起きた競合だけで、実行されなかったコードの競合は見つけられません。
sync.Mutex で守るように直します。
package main
import (
"sync"
"testing"
)
func TestCount(t *testing.T) {
var wg sync.WaitGroup
var mu sync.Mutex
count := 0
for range 100 {
wg.Go(func() {
mu.Lock()
count++
mu.Unlock()
})
}
wg.Wait()
if count != 100 {
t.Errorf("count = %d, want 100", count)
}
}
/src# go test -race .
ok example.com/errgroup-demo 1.011s
-race にはcgoが必要で、Mac以外ではCコンパイラも必要です(公式ドキュメント)。今回の golang:1.27.3 のイメージでは、そのまま実行できました。Windowsで直接 -race を使う場合は、mingw-w64 のランタイムライブラリの版8以降を含むCコンパイラが必要とされています(確認していません)。また、公式ドキュメントでは、ふつうのプログラムでメモリの使用量が5〜10倍、実行時間が2〜20倍になることがあるとされています。
ほかに気をつけること
errgroup.Groupは、別の仕事のために使い回さないこととされています(公式ドキュメント)。SetLimitの上限は、グループのgoroutineが動いているあいだに変えてはいけないとされています(公式ドキュメント)。この記事のコードでは、g.Goを呼ぶ前に設定しています。- v0.24.0 の
errgroupのソースには、関数の中のpanicをWaitを呼んだ側へ伝えない理由がコメントで書かれています。panicの扱いは、この記事では試していません。
参考
- sync package – sync – Go Packages(WaitGroup)
- errgroup package – golang.org/x/sync/errgroup – Go Packages
- errgroup.go(golang.org/x/sync v0.24.0 のソース)
- context package – context – Go Packages(Cause)
- Go 1.25 Release Notes(WaitGroup.Go)
- Go 1.22 Release Notes(ループ変数)
- Data Race Detector – The Go Programming Language
- Bind mounts | Docker Docs
- Troubleshoot topics for Docker Desktop | Docker Docs(Git Bashのパス変換)

コメント