はじめに (対象読者・この記事でわかること)
この記事は、Go言語でプログラミングを始めたばかりの初心者から、既に基礎文法は理解しているが並行処理に踏み込めていないエンジニアを対象としています。
読者は本稿を読むことで、以下ができるようになります。
- Goroutine の基本概念と内部実装の概要が把握できる
goキーワードで軽量スレッドを起動し、sync.WaitGroupやチャネルで安全に同期する方法が身につく- 実際のコード例を通じて、典型的な落とし穴(レースコンディションやゴルーチンリーク)を回避できる
本記事を書いた背景は、Go の公式チュートリアルが概念的にはわかりやすいものの、実務で直面する具体的な問題への対処法が散在している点にあります。実践的な手順をひとまとめにすることで、読者がすぐにコードに落とし込めるようにしました。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- Go の基本的な文法(変数宣言、関数、パッケージ構造)
- 標準入出力や簡単なエラーハンドリングの経験
Goroutine の概要と背景
Go が他言語と差別化できる最大の特徴は、Goroutine と呼ばれる軽量スレッド機構です。OS のスレッドに比べてメモリ消費が数KB程度と非常に小さく、数万・数十万単位の Goroutine を同時に走らせても実用的に扱える点が魅力です。
内部では、ランタイムが「M(OSスレッド)」「P(実行コンテキスト)」「G(Goroutine)」という三層構造でスケジューリングを行います。P が実行可能な G をキューから取得し、M に割り当てて実行するというモデルです。この仕組みにより、ブロッキング操作は自動的に別の P にスイッチされ、プログラマはスレッド管理を意識せずに済むよう設計されています。
Goroutine は go キーワードで関数呼び出しを非同期化するだけで生成できますが、データ競合やゴルーチンの終了待ちといった問題が潜在的に発生します。したがって、sync.WaitGroup やチャネルを駆使した同期・通信が必須です。
実装例で学ぶ Goroutine の基本と応用
以下では、実際にコードを書きながら Goroutine の作成、終了待ち、チャネルによるデータ受け渡し、そしてよく遭遇する落とし穴とその対策を順を追って解説します。
ステップ1:シンプルな Goroutine の起動
Gopackage main import ( "fmt" "time" ) func hello() { fmt.Println("Hello from Goroutine!") } func main() { go hello() // ← ここで Goroutine が非同期に開始 fmt.Println("Main function") time.Sleep(1 * time.Second) // ゴルーチンが終わるまで待機(簡易的) }
ポイントは go hello() の一行です。hello 関数は別スレッドで実行され、main は即座に次の行へ進みます。time.Sleep はデモ用に「ゴルーチンが完了するまで」待つ手段ですが、本番では sync.WaitGroup が推奨されます。
ステップ2:WaitGroup で安全に終了待ち
Gopackage main import ( "fmt" "sync" ) func worker(id int, wg *sync.WaitGroup) { defer wg.Done() // 終了時にカウントをデクリメント fmt.Printf("Worker %d starts\n", id) // ここに実際の処理を書く fmt.Printf("Worker %d ends\n", id) } func main() { var wg sync.WaitGroup for i := 1; i <= 5; i++ { wg.Add(1) // カウントを増やす go worker(i, &wg) // Goroutine を起動 } wg.Wait() // すべての Goroutine が終了するまで待つ fmt.Println("All workers completed") }
sync.WaitGroup はカウント方式で実装されます。Add で起動する Goroutine の数だけカウントし、各 Goroutine が Done を呼び出すことで減算します。Wait が呼ばれた時点でカウントが 0 になるまでブロックされるため、プログラムは安全に全タスクの完了を待ちます。
ステップ3:チャネルでデータをやり取りする
Gopackage main import ( "fmt" ) func generator(nums []int, out chan<- int) { for _, n := range nums { out <- n // データを送信 } close(out) // 送信終了を示す } func main() { numbers := []int{1, 2, 3, 4, 5} ch := make(chan int) go generator(numbers, ch) for v := range ch { // ch が閉じられるまで受信 fmt.Println("Received:", v) } fmt.Println("All numbers processed") }
チャネルは 型安全 なキューとして機能し、<- 演算子で送受信を行います。close で送信側が終了したことを通知し、受信側は range で自動的に終了判定できます。これにより、プロデューサ―・コンシューマーパターンを簡潔に実装可能です。
ハマった点やエラー解決
| 現象 | 原因 | 解決策 |
|---|---|---|
| データ競合(race condition) | 複数 Goroutine が同じ変数を同時に書き換えている | sync.Mutex で排他制御、またはチャネルでデータの所有権を移譲 |
| Goroutine が終了しない(リーク) | WaitGroup の Add と Done の数が合わない、チャネルが閉じられない |
defer wg.Done() を必ず記述し、チャネルは送信側で close |
| チャネルのブロック | 受信側が存在しないまま送信し続ける | バッファ付きチャネル make(chan T, N) を利用、もしくは受信側のループを確実に走らせる |
具体的な例:レースコンディションの検出
Bash$ go run -race main.go
-race フラグを付けて実行すると、競合が検出された箇所がレポートされます。開発時に必ずこのオプションでテストすることを推奨します。
解決策まとめ
- 排他制御:共有データは
sync.Mutexかsync.RWMutexで保護する。 - 正しい WaitGroup 操作:
Add→Doneの対が必ず一致するようにdefer wg.Done()を使う。 - チャネルの寿命管理:送信側は必ず
close、受信側はrangeかokチェックで終了判定。 - レース検出:
go run -raceで定期的に検査し、問題を早期に捕捉。
まとめ
本記事では、Go 言語の Goroutine の概念・内部構造から、go キーワードでの起動、sync.WaitGroup とチャネルを用いた安全な同期・通信、そして実装時に陥りやすいレースコンディションや Goroutine リークの対策までを体系的に解説しました。
- Goroutine は軽量スレッドであり、数万単位の同時実行が可能
- WaitGroup とチャネルを組み合わせることで、終了待ち・データ共有を安全に実現
- レースコンディションは
-raceフラグで検出し、Mutex で防止
この記事を通じて、読者は 「Go でスケーラブルな並行処理を自信を持って書ける」 というスキルを身につけました。次回は、context.Context を用いたキャンセル可能な Goroutine パターンや、sync.Pool によるオブジェクト再利用テクニックを取り上げる予定です。
参考資料
- Go Official Documentation – Goroutine
- Go Concurrency Patterns: Pipelines and Cancellation
- 《プログラミング言語 Go(第2版)》 – 斎藤康毅、オライリー・ジャパン
