Goのゴルーチンを解き放つ:詳細解説
Min-jun Kim
Dev Intern · Leapcell

Goが強力な並行システム構築言語としての評判を確立しているのは、その基本的なプリミティブの一つであるゴルーチンに大きく依存しています。ゴルーチンは単なるバズワードではなく、開発者が驚くほど簡単に、高並列で高性能なアプリケーションを書けるようにする、コアな設計思想です。この記事では、ゴルーチンの世界を深く掘り下げ、それが何であるか、どのように機能するか、そして効果的に作成・使用する方法を解説します。
ゴルーチンとは何か?並行処理の軽量チャンピオン
その核心において、ゴルーチンとは軽量で独立して実行される関数であり、同じアドレス空間内で他のゴルーチンと並行して実行されます。これらは、Goランタイムによって管理される協調的なユーザースレッドと考えることができます。通常、スタック領域にメガバイトを消費し、高価なコンテキストスイッチを伴う従来のオペレーティングシステムスレッドとは異なり、ゴルーチンは信じられないほどミニマリストです。
- **小さなスタックサイズ:**ゴルーチンは通常、非常に小さいスタック(数キロバイト、しばしば2KB)で開始され、必要に応じて動的に成長・縮小できます。これにより、Goプログラムは単一のマシン上で数千、さらには数十万ものゴルーチンを並行して実行できます。
- **Goランタイムによる管理:**Goランタイムスケジューラは、少数のオペレーティングシステムスレッドに多数のゴルーチンを多重化します。これは、OSスレッドを直接スケジュールするのではなく、Goランタイムにどの関数を並行して実行するかを指示し、ランタイムが低レベルの詳細を効率的に処理することを意味します。
- **協調的スケジューリング:**Goスケジューラはプリエンプティブ(ゴルーチンを中断できる)ですが、ゴルーチンは一般的に協調的に動作するように設計されています。可能な限り、ロックや共有メモリに依存するのではなく、チャネルのようなGoの組み込み並行プリミティブを使用して通信および同期することが理想的です。
鍵となるのは、ゴルーチンはOSスレッドよりも著しく低いオーバーヘッドを提供し、システムを遅延させることなく、膨大な数の並行操作を起動することを可能にするという点です。
最初のゴルーチンを作成する:go
キーワード
Goでゴルーチンを作成するのは驚くほど簡単です。関数呼び出しの前にgo
キーワードを付けるだけです。これにより、Goランタイムにその関数を新しいゴルーチンとして並行して実行するよう指示します。
基本的な例を見てみましょう。
package main import ( "fmt" "time" ) func sayHello(name string) { time.Sleep(100 * time.Millisecond) // いくらかの作業をシミュレート fmt.Printf("Hello, %s!\n", name) } func main() { fmt.Println("Main goroutine started.") // sayHelloを新しいゴルーチンとして起動 go sayHello("Alice") // 別のsayHelloを新しいゴルーチンとして起動 go sayHello("Bob") fmt.Println("Main goroutine continues...") // メインゴルーチンは他のゴルーチンが終了するのを待つ必要があります // そうしないと、プログラムはそれらが完了する前に終了します。 time.Sleep(200 * time.Millisecond) // ゴルーチンに実行時間を与える fmt.Println("Main goroutine finished.") }
このコードを実行すると、次のような出力が表示されるはずです。
Main goroutine started.
Main goroutine continues...
Hello, Alice!
Hello, Bob!
Main goroutine finished.
いくつかの点に注意してください。
"Main goroutine continues..."
は、sayHello
ゴルーチンを起動した直後にほぼ印刷されます。これは、main
ゴルーチンがsayHello
の完了を待たないことを示しています。- "Hello, Alice!"と"Hello, Bob!"の順序は変わる可能性があるのは、それらの実行が並行であり、スケジューラに依存するためです。
main
にtime.Sleep
を追加しました。これがないと、main
ゴルーチンはsayHello
ゴルーチンを起動した直後に終了します。main
ゴルーチンが終了すると、他のゴルーチンが実行を完了したかどうかに関わらず、プログラム全体が終了します。これは重要な点を強調しています:ゴルーチンは、仕事が終わるかプログラムが終了するまで実行され続けます。
sync.WaitGroup
を使用したゴルーチン同期
待機のためのtime.Sleep
トリックは、ハック的で信頼性がありません。実際のアプリケーションでは、1つ以上のゴルーチンが作業を終えたことを知るための堅牢な方法が必要です。ここでsync.WaitGroup
が登場します。
sync.WaitGroup
は一般的な同期プリミティブで、ゴルーチンのコレクションが完了するのを待つことができます。カウンタのように機能します。
- **
Add(delta int)
:**カウンタをdelta
だけインクリメントします。通常、ゴルーチンを起動する前にこれを呼び出して、WaitGroup
に新しいタスクがあることを通知します。 - **
Done()
:**カウンタをデクリメントします。通常、ゴルーチンの実行が終了するときにそれを呼び出して、作業が完了したことを示します。 - **
Wait()
:**カウンタがゼロになるまでブロックします。メインゴルーチンは、すべての登録済みゴルーチンが終了するのを待つためにこれを呼び出します。
以前の例をsync.WaitGroup
を使用してリファクタリングしてみましょう。
package main import ( "fmt" "sync" "time" ) func sayGoodbye(name string, wg *sync.WaitGroup) { defer wg.Done() // 関数終了時にカウンタをデクリメント time.Sleep(50 * time.Millisecond) // いくらかの作業をシミュレート fmt.Printf("Goodbye, %s!\n", name) } func main() { var wg sync.WaitGroup // WaitGroupを宣言 fmt.Println("Main goroutine started.") names := []string{"Charlie", "Diana", "Eve"} // 起動予定のゴルーチンの数を追加 wg.Add(len(names)) for _, name := range names { go sayGoodbye(name, &wg) // WaitGroupをポインタで渡す } fmt.Println("Main goroutine launched goroutines...") // すべてのゴルーチンが完了するのを待つ wg.Wait() fmt.Println("All goroutines finished.") fmt.Println("Main goroutine finished.") }
出力:
Main goroutine started.
Main goroutine launched goroutines...
Goodbye, Charlie!
Goodbye, Diana!
Goodbye, Eve!
All goroutines finished.
Main goroutine finished.
これで、main
ゴルーチンは、"All goroutines finished."と印刷する前に、すべてのsayGoodbye
ゴルーチンが完了したことを確実に待ちます。defer wg.Done()
パターンは、ゴルーチンがパニックを起こした場合でもDone()
が呼び出されることを保証するため、堅牢です。
チャネルを使ったゴルーチン間通信:Goの並行処理のスーパーパワー
sync.WaitGroup
は同期(ゴルーチンがいつ終了したかを知る)には優れていますが、通信(ゴルーチン間でデータを共有する)には役立ちません。ここでチャネルが輝きます。チャネルは、Goでゴルーチン間で通信および同期データをやり取りするための慣用的な方法です。チャネルは、値の送受信が可能な型付きのパイプです。これらは基本的に、並行実行される関数間でデータを安全に渡す方法です。
make(chan type)
:指定した型のバッファなしチャネルを作成します。make(chan type, capacity)
:指定した容量を持つバッファ付きチャネルを作成します。ch <- value
:チャネルch
にvalue
を送信します。value := <-ch
:チャネルch
からvalue
を受信します。close(ch)
:チャネルを閉じ、それ以上値が送信されないことを示します。
バッファなしチャネル:同期通信
バッファなしチャネルの容量はゼロです。バッファなしチャネルへの送信は、受信者が準備できるまでブロックされ、受信は送信者が準備できるまでブロックされます。これにより、同期通信に最適であり、値が送信者と受信者の両方が準備できたときにのみ渡されることが保証されます。
package main import ( "fmt" "time" ) func producer(ch chan int) { for i := 0; i < 5; i++ { fmt.Printf("Producer: Sending %d\n", i) ch <- i // チャネルに値を送信 time.Sleep(50 * time.Millisecond) } close(ch) // 送信終了時にチャネルを閉じる } func consumer(ch chan int) { for val := range ch { // チャネルが閉じられ、空になるまでループ fmt.Printf("Consumer: Received %d\n", val) time.Sleep(100 * time.Millisecond) // 処理時間をシミュレート } fmt.Println("Consumer: Channel closed and no more values.") } func main() { dataChannel := make(chan int) // バッファなしチャネル go producer(dataChannel) // プロデューサゴルーチンを開始 go consumer(dataChannel) // コンシューマゴルーチンを開始 // ゴルーチンに作業完了時間を与える // 本番環境ではWaitGroupやより高度なシグナルを使用します。 time.Sleep(700 * time.Millisecond) fmt.Println("Main goroutine finished.") }
出力(スケジューリングにより若干異なる場合があります):
Producer: Sending 0
Consumer: Received 0
Producer: Sending 1
Consumer: Received 1
Producer: Sending 2
Consumer: Received 2
Producer: Sending 3
Consumer: Received 3
Producer: Sending 4
Consumer: Received 4
Consumer: Channel closed and no more values.
Main goroutine finished.
プロデューサとコンシューマが交互に動作するのに気づくでしょう。送信者は受信者が準備できるまでブロックされ、逆も同様で、データの直接的な手渡しが保証されます。
バッファ付きチャネル:非同期通信
容量がゼロより大きいバッファ付きチャネル。送信はバッファがいっぱいの場合にのみブロックされ、受信はバッファが空の場合にのみブロックされます。これにより、非同期通信が可能になり、送信者はバッファが枯渇しない限り受信者を待つ必要がありません。
package main import ( "fmt" "time" ) func bufferedProducer(ch chan int) { for i := 0; i < 5; i++ { fmt.Printf("Buffered Producer: Sending %d\n", i) ch <- i // チャネルに値を送信 } close(ch) } func bufferedConsumer(ch chan int) { for { val, ok := <-ch // チャネルから値を受信、チャネルが閉じられ空の場合okはfalse if !ok { fmt.Println("Buffered Consumer: Channel closed and no more values.") break } fmt.Printf("Buffered Consumer: Received %d\n", val) time.Sleep(100 * time.Millisecond) // より遅い処理をシミュレート } } func main() { bufferedDataChannel := make(chan int, 3) // 容量3のバッファ付きチャネル go bufferedProducer(bufferedDataChannel) go bufferedConsumer(bufferedDataChannel) time.Sleep(1 * time.Second) // ゴルーチンに時間を与える fmt.Println("Main goroutine finished.") }
出力:
Buffered Producer: Sending 0
Buffered Producer: Sending 1
Buffered Producer: Sending 2
Buffered Producer: Sending 3
Buffered Consumer: Received 0
Buffered Producer: Sending 4
Buffered Consumer: Received 1
Buffered Consumer: Received 2
Buffered Consumer: Received 3
Buffered Consumer: Received 4
Buffered Consumer: Channel closed and no more values.
Main goroutine finished.
プロデューサがコンシューマの受信開始前にいくつかの値を連続して送信し、バッファを埋めることに注目してください。プロデューサはバッファがいっぱいの場合にのみブロックされます。
select
ステートメントとゴルーチン:複数のチャネルの処理
select
ステートメントは、複数のチャネル操作を処理するためのGoの強力な構文です。これにより、ゴルーチンは複数の通信操作を同時に待機し、それらのうちの1つが準備できたときに進行できます。これはUnixのselect
(またはpoll
)に似ていますが、チャネル用です。
package main import ( "fmt" "time" ) func generator(name string, interval time.Duration) <-chan string { ch := make(chan string) go func() { for i := 1; ; i++ { time.Sleep(interval) ch <- fmt.Sprintf("%s event %d", name, i) } }() return ch } func main() { // 2つのイベントストリームを作成 stream1 := generator("Fast", 100*time.Millisecond) stream2 := generator("Slow", 300*time.Millisecond) // クワイエットシグナル用のチャネル quit := make(chan bool) // 一定時間後にクワイエットシグナルを送信するゴルーチンを開始 go func() { time.Sleep(1 * time.Second) quit <- true }() fmt.Println("Listening for events...") for { select { case msg := <-stream1: fmt.Println(msg) case msg := <-stream2: fmt.Println(msg) case <-quit: fmt.Println("Quit signal received. Exiting.") return // メインループを終了 case <-time.After(500 * time.Millisecond): // タイムアウトケース fmt.Println("No activity for 500ms, still waiting...") } } }
この例では:
- 異なる速度でメッセージを送信する2つの
generator
ゴルーチンがあります。 main
ゴルーチンはselect
を使用してstream1
とstream2
の両方にリスンします。quit
チャネルは、別のゴルーチンからループを正常に終了するために使用されます。time.After
ケースはタイムアウトとして機能し、指定された時間内に他のチャネル操作が準備できない場合に実行されます。
select
は、そのケースのいずれかが進行可能になるまでブロックします。複数のケースが準備できている場合、select
はランダムに1つを選択し、公平性を保証します。
ベストプラクティスと考慮事項
- **ゴルーチンを過剰に生成しない:**ゴルーチンは安価ですが、不必要に数百万ものゴルーチンを作成すると、依然としてリソースを消費する可能性があります。真に独立した並行タスクがある場合に、新しいゴルーチンを起動します。
- **通信にはチャネルを好む:**Goの哲学は「メモリ共有による通信ではなく、通信によるメモリ共有をしない」ことです。チャネルは、ゴルーチン間でデータを渡すための最も安全で慣用的な方法であり、競合状態のような一般的な並行バグを防ぎます。
- **ゴルーチンリークを処理する:**ゴルーチンが最終的に終了することを保証します。ゴルーチンが書き込みも閉じもされないチャネルを待っている場合、それは決して終了せず、「ゴルーチンリーク」につながります。キャンセルとタイムアウトには
context
を使用します。 sync.Mutex
を sparinglyに使用する:sync.Mutex
とsync.RWMutex
は共有メモリ保護のために利用可能ですが、ロックで共有状態を保護するのではなく、チャネル経由でデータの所有権を渡すように並行コードを構造化してみてください。共有状態が避けられない場合は、sync.Mutex
またはsync.RWMutex
を慎重に使用します。- **スケジューラを理解する:**Goスケジューラは、利用可能なOSスレッド(
GOMAXPROCS
で決定される)にゴルーチンを分散させます。通常はCPUコアごとに1つです。しかし、ゴルーチンは特定のOSスレッドに結びついていません。スケジューラはそれらを移行できます。
結論
ゴルーチンはGoの並行処理モデルの基盤であり、並行プログラムの記述を信じられないほど簡単かつ効率的にします。go
キーワードを理解し、同期のためのsync.WaitGroup
を習得し、通信のためのチャネルの力を活用することで、現代のマルチコアプロセッサを最大限に活用する、スケーラブルで高性能なアプリケーションを構築できます。ゴルーチンを採用して、Goでの並行プログラミングの真の可能性を解き放ちましょう。