ゴルーチンを開始する
関数呼び出しの前に go を付けると、その呼び出しが並行に実行されます。文はすぐに戻り、呼び出し側は待ちません。
4つのゴルーチンが同時に shout を実行します。終わる順番は決まっていませんが、各ゴルーチンは自分のインデックスに書き込み、main は wg.Wait() が戻った後でしかスライスを読まないので、出力は常に元の順番になります。
この例には、ほぼすべてのゴルーチンのプログラムに必要な3つのものがすでに入っています。処理を開始する方法(go)、それを待つ方法(sync.WaitGroup)、2つのゴルーチンが同じメモリに触れずに結果を受け取る方法(それぞれに1つのスライスの要素)です。
mainは待たない
main が戻るとプログラムは終了します。まだ動いているゴルーチンは、その場で止められます。それらを待つものは何もありません。
これはたいてい from main しか表示しません。ゴルーチンがたまたま間に合ってスケジューリングされ、両方の行が見えることもあります。この「たいてい」が問題です。自分のマシンでは動き、負荷の高いサーバーでは失敗するコードになります。
main の最後に time.Sleep を置くとデモは両方の行を表示しますが、これは間違った直し方です。処理にかかる時間を推測しているだけです。WaitGroup(WaitGroupのページで詳しく扱っています)かチャネルで、処理そのものを待ちます。
結果を受け取る
go 文は関数の戻り値を捨てます。x := go f() はコンパイルできません。データを返す標準的な方法は2つあります。
ゴルーチンごとに1つの枠。最初の例と同じです。スライスのサイズを事前に決め、各ゴルーチンにインデックスを与え、Wait の後で読みます。2つのゴルーチンが同じ要素に書き込むことはないので、順序が保たれ、ロックも要りません。
チャネル。各ゴルーチンが結果を送り、受信側がそれを集めます。結果は開始順ではなく完了順に届きます。
ちょうど len(nums) 個の値を受信することが待機も兼ねています。すべてのゴルーチンが送信し終えるまで、main はループを抜けられません。届く順番は実行のたびに変わるので、プログラムは順序に依存するものを表示する前にソートしています。バッファ付きチャネル、クローズ、チャネルに対する range はチャネルのページで扱っています。
ループ変数とクロージャ(Go 1.22の変更)
Go 1.22以降、for ループの各反復でループ変数の新しいコピーが作られます。ゴルーチンで開始したクロージャはその反復の値を捕捉するので、次のコードは正しく動きます。
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
Go 1.22より前は、すべての反復が1つの i と1つの w を共有しており、どのゴルーチンも最後の値を見がちでした。古いコードでは、値を引数として渡す(go func(i int, w string) { ... }(i, w))か、シャドーイングする(i := i)ことで回避しています。どちらもGo 1.22以降では無害で、既存のコードではまだ見かけます。新しい挙動は、モジュールの go.mod が go 1.22 以上を指定している場合に適用されます。
ゴルーチンは軽い
ゴルーチンは小さなスタック(数キロバイト)で始まり、ランタイムが必要に応じて伸縮させます。GoのスケジューラはOSスレッドのプールでゴルーチンを実行し、同時にGoのコードを実行するのは最大 GOMAXPROCS 個で、デフォルトの GOMAXPROCS はCPU数です。チャネル、ミューテックス、スリープ、ネットワークI/Oでブロックすると、ゴルーチンは待機状態になり、スレッドは別のゴルーチンのために解放されます。
ですから、タスクごとにゴルーチンを開始するのは、数が多くても問題ありません。
10万個のゴルーチンが1秒もかからずに終わります。atomic.Int64 が各加算を不可分にするので、合計は常に 4999950000 です。ただし、軽いことは無料を意味しません。まだブロックしているゴルーチンはそれぞれ、自分のスタックと参照しているすべてのものを生かし続けます。
| OSスレッド | ゴルーチン | |
|---|---|---|
| 作るもの | カーネル | Goのランタイム |
| 初期スタック | 固定、多くは1MB以上 | 数KB、必要に応じて伸びる |
| 切り替え | カーネルのコンテキストスイッチ | ユーザー空間のGoスケジューラ |
| 識別子 | スレッドIDがある | 読み取れるIDはない(設計上) |
| 典型的な数 | 数百 | 数千から数百万 |
データ競合
2つのゴルーチンが同じ変数に同時にアクセスし、少なくとも一方が書き込むのがデータ競合です。結果は「少しずれる」程度ではなく予測できません。更新が失われ、文字列、スライス、マップ、インターフェースの値での競合はプログラムをクラッシュさせたりメモリを壊したりします。
マルチコアのマシンでは、たいていの実行で10000より小さい、毎回違う数が表示されます。2つのゴルーチンが同じ古い値を読み、どちらもその値に1を足して書き戻すからです。シングルコアでは10000と表示されることもあり、そのほうが悪い結果です。バグがテストを通り抜け、本番で現れます。
Goには競合検出器が付属しています。プログラムやテストを -race 付きで実行します。
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
正確な行(counter++)と両方のゴルーチンを指し示します。報告されるのは実行中に実際に起きた競合だけなので、並行処理の経路を通るテストで実行してください。プログラムが数倍遅くなるので、本番ではなくテストとステージングのためのものです。
直し方を、単純なものから一般的なものの順に並べます。
- 共有しない。各ゴルーチンに独自のデータを持たせ、最後にまとめる(ゴルーチンごとの枠のパターン)。
- 1つのカウンタやフラグには
sync/atomicを使う:var n atomic.Int64; n.Add(1)。 - マップや複数のフィールドを持つ構造体のような大きなものには
sync.Mutexを使う。ミューテックスのページではRWMutexとsync.Onceも扱っています。 - 一度に1つのゴルーチンだけがデータを所有するよう、チャネルで送る。
ゴルーチンのpanicはプログラムを落とす
ゴルーチンがpanicし、そのゴルーチンの中で何も回復しなければ、main や他のすべてのゴルーチンを含め、プログラム全体がクラッシュします。recover は自分のゴルーチンのpanicしか捕まえないので、main の中の recover は役に立ちません。
このような回復が意味を持つのは、1つの不正なリクエストで残りを落としてはならない、長時間動くサーバーの境界です。普通のコードの中では、panicはたいていバグを意味し、派手にクラッシュするのが正しい結果です。
ゴルーチンのリーク
永遠にブロックするゴルーチンは決して終了せず、メモリも解放しません。典型的な原因は、誰も受信しない送信です。
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
呼び出すたびに len(urls) - 1 個のゴルーチンがリークします。このリクエストを何千回も処理するサーバーでは、プロセスが死ぬまでメモリが増え続けます。直し方は2つあります。すべての送信者が終われるだけの大きさのチャネルにする(make(chan string, len(urls)))か、ゴルーチンにあきらめる手段を与えるかで、後者はたいてい context.Context と ctx.Done() に対する select です。テストでは runtime.NumGoroutine() でリークを監視できます。
同時に動く数を制限する
「要素ごとに1つのゴルーチン」は、1万個の軽い計算なら問題ありません。同じサーバーへの1万個のHTTPリクエストや、1万個の開いたファイルでは問題になります。セマフォとして使うバッファ付きチャネルで並行数を制限します。
バッファ付きチャネルは最大3個のトークンしか保持しないので、sem <- の行を通過しているゴルーチンはどの瞬間も最大3個です。ピークは3を超えることがなく、それぞれスリープする12個のタスクでは実際に3に達します。もう1つのよくある形は、ジョブのチャネルから読む固定数のワーカーゴルーチンのプールで、WaitGroupのページで作っています。
標準ライブラリの外では、golang.org/x/sync/errgroup が、WaitGroup、最初のエラー、コンテキストのキャンセル、並行数の制限(g.SetLimit(n))を1つの型にまとめています。4つすべてが必要な本番のコードでは、これがよく選ばれます。
よくある間違い
- 待つのを忘れる。
mainが戻り、処理は黙って実行されないままになります。どのgo文にも、それが終わったことを知る方法が対になって必要です。 - ゴルーチンの中で
wg.Addを呼ぶ。WaitがAddより先に実行され、カウンタがゼロに見えて早く戻ることがあります。Addはgo文の前に呼びます。 - 同期せずに変数を共有する。 よくあるのはマップです。マップへの並行した書き込みはたいていランタイムが検出し、
fatal error: concurrent map writesでプログラムをクラッシュさせます。これはrecoverでも捕まえられません。 - 順序を仮定する。 ゴルーチンはスケジューラが選んだ順に実行されます。出力に順序が必要なら、集めてソートするか、インデックス付きの枠に書き込みます。
- 同期に
time.Sleepを使う。 テストが遅くなり、それでも不安定なままです。推測ではなく出来事を待ちます。 - 止める手段のないゴルーチンを開始する。 ループしたりI/Oを待ったりするものはすべて、呼び出し側がキャンセルできるよう
context.Contextを受け取るべきです。
よくある質問
Goのゴルーチンとは何ですか?
ゴルーチンは、プログラムの他の部分と並行して動く関数呼び出しです。呼び出しの前に go を付けると開始します:go work()。ゴルーチンはOSではなくGoのランタイムが管理し、ランタイムは多数のゴルーチンを少数のOSスレッドに多重化するので、何千個も開始するのはごく普通のことです。
Goでゴルーチンの終了を待つには?
sync.WaitGroup を使います。各 go 文の前に wg.Add(1) を呼び、ゴルーチンの先頭で defer wg.Done() とし、すべての終了が必要な場所で wg.Wait() を呼びます。ゴルーチンが値を生み出すなら、チャネルからゴルーチンごとに1つの値を受信することも待機になります。
ゴルーチンとスレッドの違いは何ですか?
OSのスレッドは固定のスタック(多くは1MB以上)を持ち、カーネルがスケジューリングします。ゴルーチンは数キロバイトのスタックで始まって必要に応じて伸び、Goのスケジューラがユーザー空間でゴルーチンを切り替えます。ランタイムは同時に最大 GOMAXPROCS 個(デフォルトはCPU数)のスレッドでゴルーチンを実行します。
ゴルーチンから戻り値を受け取るには?
go 文は関数の戻り値を捨てます。結果をチャネルで送る(results <- compute(x))か、事前にサイズを決めたスライスの自分の枠に書き込み(out[i] = compute(x))、wg.Wait() の後で読みます。
なぜゴルーチンが何かを表示する前にGoのプログラムが終了するのですか?
main が戻るとプログラムは終了し、他のすべてのゴルーチンは残りのコードを実行せずに止められます。ゴルーチンを自動的に待つものは何もありません。WaitGroup かチャネルの受信で、処理が終わるまで main をブロックします。time.Sleep を加えても問題が隠れるだけです。