📚 背景知識(読んでから問題へ)
goroutineは、Goのランタイムが管理する軽量な実行単位です。関数呼び出しの前にgoキーワードを付けるだけで、その関数を新しいgoroutineとして非同期に実行できます。
go doSomething()
これだけでdoSomething()は「呼び出し元とは独立した別の実行の流れ」として動き始めます。OSのスレッドと似た働きをしますが、決定的に違うのはそのコストです。
- OSスレッド: 生成に数MB(デフォルトで1〜8MB程度)の固定スタックを必要とし、生成・切り替え(コンテキストスイッチ)のコストも大きい
- goroutine: 初期スタックはわずか2KB程度で、必要に応じてGoランタイムが自動的に伸縮させる。数千〜数百万個のgoroutineを同時に起動しても現実的なメモリで動く
この軽さを実現しているのが、GoランタイムによるM:Nスケジューリングです。M個のgoroutineをN個のOSスレッドの上で多重化して実行します(NはGOMAXPROCSで制御され、デフォルトはCPUコア数)。つまりgoroutineはOSスレッドに直接対応するのではなく、Goランタイムが独自にスケジューリングする「ユーザー空間の軽量スレッド」です。この仕組みの内部詳細(GMPモデル)はPhase 4で扱いますが、今の段階では「goroutineは安価に大量生成できる」という感覚を持てば十分です。
もう1つ重要なのは、goroutineの実行順序もタイミングも保証されないという点です。
func main() {
go fmt.Println("A")
go fmt.Println("B")
fmt.Println("C")
}
このコードは"A" "B" "C"がどんな順序で出力されるか(あるいはA・Bが全く出力されないままmainが終了してしまうか)を一切保証しません。main関数はそれ自体が1つのgoroutine(メインgoroutine)であり、メインgoroutineが終了すると、他のgoroutineが実行途中であってもプログラム全体が終了します。これはGoが並行処理を学ぶ上で最初にぶつかる重要な特性です。
📝 問題
以下のコードは、1〜5の数値を並行に処理するworker関数を5つのgoroutineとして起動しようとしたものです。
package main
import "fmt"
func worker(id int) {
fmt.Printf("worker %d: done\n", id)
}
func main() {
for i := 1; i <= 5; i++ {
go worker(i)
}
}
要件:
- 概念理解: このコードを
go run main.goで実行すると、何も出力されない・あるいは5行未満しか出力されないことがあります。なぜそうなるのか、メインgoroutineと他のgoroutineの関係を踏まえて説明してください - 概念理解: 仮に
go worker(i)の代わりに、無名関数のクロージャでiをキャプチャする以下のような書き方をした場合、
Go 1.22より前のバージョンと、Go 1.22以降のバージョンとで、出力されるgo func() { fmt.Printf("worker %d: done\n", i) }()idの値の傾向がどう異なる可能性があるか説明してください(forループの変数スコープの仕様変更に関する問題です) - 実装: 元のコードを、
time.Sleepのような「とりあえず待つ」誤魔化しを使わずに、channelを使って全てのgoroutineの完了を待ち合わせるように修正してください。5つのworkerが確実に全て完了してからmainが終了するようにし、go run main.goで毎回5行すべてが出力されることを確認してください(出力の順序は保証されなくて構いません)
go run main.goでそのまま実行できる、完全な1つのプログラムとして提出してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
go worker(i)は「新しいgoroutineを起動する予約」をするだけで、それがいつ実際にCPU上で実行されるかはGoランタイムのスケジューラ次第です。一方main関数自体も1つのgoroutineであり、mainが最後の行に到達した時点で他のgoroutineの状態に関係なくプログラムは終了します。「起動すること」と「完了すること」は別物だと考えてください。
ヒント2 — アプローチ
「全てのgoroutineが完了するまでmainを止めておく」ためには、goroutine側から「完了しました」という信号をmain側に送る仕組みが必要です。Goではchannelという型がゴルーチン間の通信・同期に使われます(次回のテーマで詳しく扱いますが、ここではchan struct{}を「空の合図を送るための通路」として使ってみましょう)。workerが仕事を終えたらchannelに何かを送信し(ch <- struct{}{})、main側はworkerの数だけchannelから受信する(<-ch)ことでブロックし、待ち合わせを実現します。
ヒント3 — コード骨格
package main
import "fmt"
func worker(id int, done chan<- struct{}) {
fmt.Printf("worker %d: done\n", id)
done <- struct{}{} // 完了を通知
}
func main() {
done := make(chan struct{})
for i := 1; i <= 5; i++ {
go worker(i, done)
}
for i := 0; i < 5; i++ {
<-done // 1つ完了するたびに受信。5回受信するまでブロックする
}
}
done chan<- struct{}は「送信専用」のchannel型を表す記法です(worker側は送信しかしないという意図を型で明示しています)。
✅ 模範解答
package main
import "fmt"
// worker は id を受け取り、処理完了後に done チャネルへ完了シグナルを送る。
// done は送信専用チャネル型(chan<- struct{})として宣言し、
// worker がこのチャネルに対して「送信しかしない」という意図を型で明示している。
func worker(id int, done chan<- struct{}) {
fmt.Printf("worker %d: done\n", id)
done <- struct{}{}
}
func main() {
const n = 5
done := make(chan struct{})
for i := 1; i <= n; i++ {
go worker(i, done)
}
// n 個の完了シグナルを受信するまで main はここでブロックする。
// これにより、5つの goroutine が全て完了してから main が終了することが保証される。
for i := 0; i < n; i++ {
<-done
}
fmt.Println("all workers finished")
}
▶ 実行結果を見る(go run main.go)
worker 3: done
worker 1: done
worker 5: done
worker 2: done
worker 4: done
all workers finished
※ 何度実行しても必ず5行のworker N: doneとall workers finishedが出力されますが、worker N: doneの順序は実行のたびに変わり得ます(バグではなく、スケジューリングが非決定的であることの正常な現れです)。
🪜 Step-by-Step 解説
go worker(i)は「goroutineを起動する」だけの命令で、それ自体は一瞬で完了して次の行に進みます。forループが5回go worker(i)を実行し終えた時点で、main関数はもうそれ以上やることがないため、main(=メインgoroutine)はそのまま終了処理に入ります。Goではメインgoroutineが終了すると、他のgoroutineがまだ実行中でも、プログラム全体(プロセス)が即座に終了します。5つのworkergoroutineがCPUに割り当てられてfmt.Printfを実行し終える前にプロセスが終了してしまえば、その分の出力は失われます。実行環境やタイミングによって0行〜5行のどこかで出力が止まるため、実行結果が不安定に見えるのです。
Go 1.21以前の仕様では、
for i := 1; i <= 5; i++のiはループ全体を通して1つの変数でした。クロージャfunc() { fmt.Printf(..., i) }はこの「1つの変数i」への参照をキャプチャするため、goroutineが実際に実行されるタイミングによっては、複数のgoroutineが同じ(多くの場合ループ終了後の最終値に近い)iの値を参照してしまうバグが頻発しました。対策としてi := iのようにループ内で変数をシャドーイングして「イテレーションごとの独立したコピー」を作る書き方が広く使われていました。Go 1.22以降では言語仕様が変更され、
forループの各イテレーションでiは新しい変数として扱われるようになりました。そのためGo 1.22以降では、クロージャでiをそのままキャプチャしても、i := iのシャドーイングをしなくても、各goroutineは自分のイテレーションのiの値を正しく参照します。ただし本問の模範解答では、クロージャではなくworker(id int, ...)のように値渡しの引数としてiを渡しているため、Goのバージョンに関係なく元々この問題は発生しません。値を引数として渡すのは、この種の落とし穴を根本的に避けられる書き方でもあります。
done := make(chan struct{})
chan struct{}は「空の構造体を送受信するchannel」です。struct{}{}はメモリを一切消費しないゼロサイズの値なので、「何かのデータを運ぶ」のではなく「イベントが起きたことだけを伝える」用途に最適です。
for i := 1; i <= n; i++ {
go worker(i, done)
}
for i := 0; i < n; i++ {
<-done
}
1つ目のループで5つのgoroutineを起動します。2つ目のループの<-doneは、channelから値を受信するまでそのgoroutine(ここではmain)をブロックするという性質を持ちます。5回受信するということは、5つのworkerそれぞれがdone <- struct{}{}を実行し終えるまで待つ、ということです。これにより「全goroutineの完了を待ってからmainが終了する」という制御が実現できます。time.Sleep(time.Second)のような書き方で「たぶんこれくらい待てば終わるだろう」と時間で誤魔化す方法もありますが、それは処理時間が伸びれば簡単に破綻する脆い方法です。channelによる待ち合わせは、実際の完了イベントに基づいた決定的で信頼できる同期です。
💡 設計思想・なぜこう書くのか
donechannelはまさにその最小の例で、「workerが完了した」という事実そのものをメッセージとして送ることで、ロックや共有フラグ変数を使わずに安全な同期を実現しています。goを付ける」というスタイルが最初から前提とされています(もちろん無制限に起動すればメモリやスケジューリングのコストは積み上がるため、Phase 3で学ぶWorker Poolのような制御は依然として重要です)。🌐 他言語との比較
| 観点 | Go | Java | Python | Node.js |
|---|---|---|---|---|
| 並行実行の単位 | goroutine(Goランタイムが管理する軽量スレッド) | Thread(基本的にOSスレッドに1:1対応。仮想スレッドは近年追加) | threading.Thread(GILにより実質1コアしかCPUバウンド処理は並列化されない) | シングルスレッドのイベントループ+非同期I/O(async/await) |
| 生成コスト | 初期2KB程度、自動で伸縮。数十万個でも現実的 | 数百KB〜数MBのスタック。数千個が実用上の上限になりやすい | OSスレッドに準ずるためGoより重い | 「スレッド」ではなくコールバック/Promiseの登録なのでコストは別次元 |
| 完了の待ち合わせ | channelやsync.WaitGroupで明示的に同期する | Thread.join()やExecutorService+Future | thread.join()、concurrent.futures | await Promise.all([...]) |
| 並行モデルの思想 | CSP(channelでメッセージを送り合う) | 共有メモリ+ロック(synchronized等)が伝統的な主流 | 共有メモリ+ロックだが、GILがあるためCPUバウンドは実質並行にならない | シングルスレッドなのでそもそもデータ競合が起きにくい設計 |
Java/Pythonのスレッド経験がある人は「スレッド=コストの高い希少資源」という感覚を持ちがちですが、goroutineは「必要な場所に気軽に使う軽量な道具」という感覚に近いです。一方Node.js経験者は「並行処理といえばシングルスレッド+非同期I/O」という前提を持ちがちですが、Goのgoroutineは実際に複数のOSスレッド上で真に並列に実行され得る点が異なります(GOMAXPROCSが1より大きい場合)。
🏆 実務での使いどころ
- 並行I/O処理: 複数のAPIへのリクエストや複数ファイルの読み込みを、goroutineで並行に発行してレイテンシを短縮する(例: 5つの外部APIを直列に呼べば合計500msかかるところを、並行に呼べば最も遅い1つの時間で済む)
- バックグラウンドタスク: HTTPリクエストへの応答を返しつつ、ログ送信や集計処理などを別goroutineで裏側に流す
- サーバーのリクエストハンドリング:
net/httpのサーバーは、リクエストごとに自動でgoroutineを1つ割り当てて処理する。これによりGoの標準HTTPサーバーは追加の設定なしに高い並行性を持つ - 完了待ち合わせパターンの基礎: 今回のchannelによる待ち合わせは、次回以降に学ぶ
sync.WaitGroup(複数goroutineの完了を待つための専用の道具)の理解の土台になる
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
goを付けて呼び出せば、mainが自動的にその完了を待ってくれると思い込む | 他言語の同期処理に慣れていると「関数呼び出し=完了するまで戻ってこない」という前提を無意識に持ち込む | goは「起動の予約」でしかない。完了を待ちたいならchannelやsync.WaitGroupなど明示的な同期手段が必須 |
| goroutineの出力順序が起動した順序と同じだと思い込む | シングルスレッドで書いてきたコードの実行順の感覚をそのまま持ち込む | goroutineの実行順序・タイミングはGoランタイムのスケジューラに委ねられており、保証されない。順序に依存するロジックを書いてはいけない |
forループ内のクロージャで変数をキャプチャする際、常に「意図した値」が渡ると思い込む | Go 1.22以降のデフォルト挙動を前提に考えてしまう、あるいは他言語のクロージャの挙動と混同する | Go 1.21以前のコードベースやドキュメントを参照する際は、ループ変数が「イテレーションごとの新しい変数」でない古い仕様の可能性を意識する必要がある |
| goroutineは「タダ」だから無制限に起動しても問題ないと思い込む | 「軽量」という説明だけを見て、コストがゼロだと誤解する | 1つ1つは軽量でも、大量に起動すればメモリやスケジューリングのオーバーヘッドは積み上がる。特に「起動したまま誰も待たない・終わらない」goroutineはgoroutineリークとして本番障害の原因になり得る(Phase 2後半のcontextパッケージで対処法を学ぶ) |
🚀 次のステップ
- 発展: 今回は
chan struct{}で「完了した」という事実だけを伝えましたが、workerの処理結果(例えば計算結果のint)をchan intでmain側に送り返し、5つの結果を集計するように改造してみましょう - 次回予告: Day 010 — channelの基礎(バッファなし/バッファあり)(実装問題)。今回何気なく使った
make(chan struct{})(バッファなしchannel)の正確な同期の仕組みと、バッファ付きchannelとの違いを深掘りします