ゴルーチン(Goroutine)とは?Go言語の並行処理を支える軽量スレッドの仕組みと使い方
ゴルーチン(Goroutine)は、Go言語のランタイムが管理する軽量な実行単位で、go という1つのキーワードだけで並行処理を起動できる仕組みです。OSスレッドが1本あたり1MB前後(環境依存)のメモリを必要とするのに対し、ゴルーチンの初期スタックは2KBしかなく、1プロセスで数十万〜数百万を同時に走らせられます。この記事では、ゴルーチンの定義とOSスレッドとの違い、go文での起動とチャネルによる同期、GMPモデルによるスケジューラの仕組み、そして実務でつまずきやすいリーク・デッドロック・データ競合の回避策までを、実装例つきで整理します。
まとめ:ゴルーチンの要点
- ゴルーチンはOSスレッドではない。Goランタイムが少数のOSスレッド上に多数のゴルーチンを載せて動かす(M:Nスケジューリング)。初期スタック2KBで生成・切り替えが安く、大量に作れる。
- 起動は
go 関数呼び出しだけ。ただしmain関数が終わると実行中のゴルーチンは道連れで止まるため、sync.WaitGroupやチャネルで終了を待つ。 - データのやり取りはチャネルで行う。「メモリを共有して通信するな、通信してメモリを共有せよ」がGoの原則。共有変数を直接触るとデータ競合になる。
- 危険なのは作りすぎと止め忘れ。キャンセルを渡さないゴルーチンはリークし、無制限生成はメモリを食い潰す。
contextとワーカープールで数を制御する。
ゴルーチンとは:Goランタイムが管理する軽量な実行単位
ゴルーチンは、関数やメソッドを他の処理と並行して走らせるための実行単位です。OSが直接スケジュールするスレッドと違い、ゴルーチンをスケジュールするのはOSではなくGoランタイムです。ランタイムが自前で用意した少数のOSスレッドの上に、多数のゴルーチンを乗せ替えながら実行します。この「アプリ側で管理する軽い並行単位」という性格が、後述する低コストと大量生成を可能にしています。
OSスレッドとの違い
ゴルーチンとOSスレッドの差は、生成コストとスケジューリングの主体に集約されます。
| 観点 | ゴルーチン | OSスレッド |
|---|---|---|
| 初期スタック | 2KB(動的に伸縮) | 1MB前後(環境依存) |
| スケジューラ | Goランタイム | OSカーネル |
| 切り替えコスト | 低(カーネルをまたがない) | 高(コンテキストスイッチ) |
| 同時生成数の目安 | 数十万〜数百万 | 数千程度 |
| 起動方法 | go f() |
スレッドAPI呼び出し |
ゴルーチンの初期スタックが小さいのは、必要になった時点でランタイムが自動的にスタックを拡張(コピーして付け替え)するためです。最初から大きなスタックを確保しないので、1本あたりの固定コストが数KBで済み、大量に立ち上げても破綻しません。
並行(concurrency)と並列(parallelism)は別物
ゴルーチンを理解するうえで混同しやすいのが並行と並列です。並行は「複数の処理を切り替えながら同時に進行させる」設計上の考え方で、CPUが1コアでも成立します。並列は「複数のCPUコアで物理的に同時実行する」実行上の状態です。ゴルーチンは並行処理を書くための道具であり、実際に何本を並列で走らせるかはランタイムのスケジューラとコア数(後述のGOMAXPROCS)が決めます。1コア環境でもゴルーチンは動きますが、その場合は並行であって並列ではありません。
ゴルーチンの起動と基本的な使い方
ゴルーチンは、関数呼び出しの前にgoを付けるだけで起動します。呼び出し元はゴルーチンの完了を待たずに次の行へ進みます。
package main
import (
"fmt"
"time"
)
func say(s string) {
fmt.Println(s)
}
func main() {
go say("別のゴルーチンで実行") // ここで新しいゴルーチンが起動
say("main側で実行")
time.Sleep(100 * time.Millisecond) // ゴルーチンの完了を雑に待つ例
}
ここで重要なのは、main関数が終了すると、まだ実行中のゴルーチンも問答無用で終了する点です。上の例でtime.Sleepを外すと、say("別のゴルーチンで実行")が出力される前にmainが終わってしまう可能性があります。Sleepで待つのは時間に依存する不安定な書き方なので、実務では次のsync.WaitGroupで明示的に完了を待ちます。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1) // 待つ数を1増やす
go func(n int) {
defer wg.Done() // 終わったら1減らす
fmt.Println("worker", n)
}(i)
}
wg.Wait() // カウンタが0になるまでブロック
}
ループ変数iは引数nとして値渡ししています。Go 1.22以降はループごとに変数が作られるため素直に書けますが、古いコードとの互換や意図の明確化のために引数で渡す書き方は今でも安全です。
チャネルによるゴルーチン間の同期とデータ受け渡し
複数のゴルーチンで値をやり取りするとき、共有変数を直接読み書きするとデータ競合になります。Goが推奨するのはチャネル(channel)を使った通信です。チャネルは型付きの通り道で、送信側と受信側が揃うまで自動的に待ち合わせるため、ロックを書かずに同期できます。
package main
import "fmt"
func main() {
ch := make(chan int) // int型のチャネル
go func() {
ch <- 42 // チャネルへ送信(受信側が来るまで待つ)
}()
v := <-ch // チャネルから受信
fmt.Println(v) // 42
}
複数の値を順に流す場合は、送信側が送り終えたらclose(ch)でチャネルを閉じ、受信側はfor v := range chで閉じるまで受け取ります。複数のチャネルを同時に待ちたいときはselectを使い、準備できたケースから処理します。「共有メモリで通信するのではなく、通信でメモリを共有する」というGoの設計思想は、この待ち合わせの仕組みに支えられています。
Goランタイムのスケジューラ:GMPモデルとM:Nスケジューリング
ゴルーチンが軽い理由は、Goランタイムのスケジューラ設計にあります。ランタイムはGMPモデルと呼ばれる3種類の要素でゴルーチンを回します。
- G(Goroutine):実行すべき処理そのもの。私たちが
goで作る単位。 - M(Machine):実際にコードを実行するOSスレッド。
- P(Processor):GをMに割り当てる論理プロセッサ。実行可能なGのキューを持つ。
多数のG(ゴルーチン)を少数のM(OSスレッド)に載せ替えて動かすため、この方式をM:Nスケジューリングと呼びます。各Pは自分のキューにあるGを順に実行し、キューが空になると他のPからGを奪ってくる(ワークスティーリング)ため、コア間で負荷が偏りにくくなっています。ブロッキングのシステムコールでMが止まっても、ランタイムがPを別のMに付け替えるので、他のゴルーチンは実行を続けられます。
並列で走るゴルーチンの本数はGOMAXPROCS(同時にGoコードを実行できるPの数)で決まり、既定値は論理CPU数です。Go 1.25(2025年8月)からは、Linuxのcgroupに設定されたCPU制限も考慮してGOMAXPROCSが自動決定されるようになりました。これにより、KubernetesなどでCPU上限を絞ったコンテナでも、ホストの全コア数を前提に過剰なスレッドを起こしてスロットリングを招く問題が緩和されています。使用中のバージョンや挙動はGoの最新バージョンの確認方法と変更点まとめで確認してください。
ゴルーチンで陥りやすい失敗と対策
ゴルーチンは書くのが簡単な分、止め忘れと作りすぎで事故を起こしやすい機能です。実務で繰り返し出る4つの落とし穴を、対策とセットで押さえておきます。
ゴルーチンリーク:終了経路のない待ちっぱなし
受信側が来ないチャネルを待ち続けるゴルーチンは、いつまでも終わらずメモリとスケジューラ枠を占有します。これがゴルーチンリークです。対策はcontext.Contextでキャンセルを配ること。呼び出し側がcancel()を呼べば、ゴルーチン側は<-ctx.Done()で確実に抜けられます。「起動したゴルーチンには必ず終了経路を用意する」を原則にします。
デッドロック:全ゴルーチンが互いを待つ
バッファなしチャネルに受信側のいないまま送信したり、複数のロックを別々の順序で取り合ったりすると、全ゴルーチンが停止してプログラムがfatal error: all goroutines are asleep - deadlock!で落ちます。送受信の対応を明確にし、ロックの取得順序をコード全体で統一するのが基本の予防策です。
データ競合:共有変数への同時アクセス
複数のゴルーチンが同じ変数を同時に読み書きすると、結果が不定になるデータ競合が起きます。前述のチャネルで受け渡しを一本化するか、避けられない共有にはsync.Mutexやsync/atomicを使います。競合はテストで再現しにくいため、go test -raceやgo run -raceでレースディテクタを回して検出するのが定石です。
無制限生成:作りすぎでリソースを食い潰す
リクエストや入力の数だけ無条件にgoすると、ゴルーチン自体は軽くても、それぞれが握るDB接続やメモリで資源が枯渇します。大量のタスクを流すときはゴルーチン数に上限を設けるのが正解で、バッファ付きチャネルをセマフォ代わりにするか、固定数のワーカーでタスクを消費するワーカープールを組みます。「数百万作れる」は上限であって、常に上限まで作ってよいという意味ではありません。
よくある質問
ゴルーチンとスレッドの違いは何ですか?
スレッドはOSが管理する実行単位で1本あたり1MB前後(環境依存)のメモリを使うのに対し、ゴルーチンはGoランタイムが管理し初期スタックは2KBです。ランタイムが少数のスレッド上に多数のゴルーチンを載せ替える(M:N)ため、切り替えが軽く大量に作れます。
ゴルーチンはいくつまで作れますか?
1本あたり数KBと軽いため、数十万〜数百万規模でも生成できます。ただし各ゴルーチンが握るメモリや接続などの資源には限りがあるので、実務ではcontextやワーカープールで同時実行数を制御します。
並行処理と並列処理はどう違いますか?
並行は複数の処理を切り替えながら進める設計上の考え方で1コアでも成立し、並列は複数コアで物理的に同時実行する状態を指します。ゴルーチンは並行を記述する道具で、実際の並列度はGOMAXPROCSとコア数で決まります。
ゴルーチンが実行される前にプログラムが終わってしまうのはなぜですか?
main関数が終了すると実行中のゴルーチンも一緒に終了するためです。sync.WaitGroupのWait()やチャネルの受信で、ゴルーチンの完了を明示的に待ってからmainを終えてください。
GOMAXPROCSとは何ですか?
同時にGoコードを実行できる論理プロセッサ(P)の数を指定する設定で、既定値は論理CPU数です。Go 1.25以降はコンテナのcgroup CPU制限も考慮して自動調整されるため、多くの場合は手動設定せずそのまま使えます。