Goroutine
レッスン13:Goroutine
レストランを想像してください:ウェイターが1人だけ(シングルスレッド)の場合、注文を受け付け、料理を提供し、各テーブルの会計を終えてから次のテーブルに移る必要があります。_MANY_のウェイターを雇う(マルチスレッド)のはコストがかかります。Goのソリューションは、同じレストランシステム(スケジューラ)を共有する軽量なパートタイムウェイター(goroutine)のグループを雇うことです——空いている人が次に対応し、最小限のリソースで大量の顧客を処理します。
コアコンセプト
- Goroutine はGoランタイムによって管理される軽量な同時実行ユニットであり、
goキーワードで起動されます。 - GoroutineはOSスレッドではありません。初期スタックは約2KB בלבדで(スレッドは通常1〜8MB必要)、数十万個を簡単に作成できます。
- GoランタイムはM:Nスケジューリングモデル(G-M-Pモデル)を使用し、M個のgoroutineをN個のOSスレッドにマッピングして実行します。
- Goroutineはチャネル(後のレッスンで詳しく説明)を通じて通信し、
syncパッケージを使用して同期することもできます。 - メインgoroutineが終了すると、すべての子goroutineは完了を待たずに強制終了されます。
基本構文と使い方
Goroutineの起動
関数呼び出しの前にgoキーワードを追加して、新しいgoroutineを起動します:
go functionName(args)
// または
go func() {
// 匿名関数の本体
}()
sync.WaitGroupでgoroutineを待機する
メインgoroutineは子goroutineを自動的に待機しないため、sync.WaitGroupを使用して同期します:
var wg sync.WaitGroup
wg.Add(1) // カウンターを1増加
go func() {
defer wg.Done() // 完了時にカウンターを減少
// タスクを実行
}()
wg.Wait() // カウンターがゼロになるまでブロック
wg.Add()はgo文の前に呼び出す必要があります。そうしないと、競合状態が発生する可能性があります——メインgoroutineがAddが呼び出される前にWaitから戻るかもしれません。
wg.Add()をgoroutine内に配置しないでください。そうしないとwg.Wait()がAddの前に実行される可能性があります。
defer wg.Done()を使用してください。
例
例:複数のgoroutineの起動(難易度⭐)
package main
import (
"fmt"
"sync"
)
func sayHello(id int, wg *sync.WaitGroup) {
defer wg.Done() // 完了時にWaitGroupに通知
fmt.Printf("こんにちは、私はgoroutine #%dです\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go sayHello(i, &wg)
}
wg.Wait() // すべてのgoroutineの完了を待機
fmt.Println("すべてのgoroutineが完了しました")
}
出力順序は不定です(並列実行のため):
こんにちは、私はgoroutine #3です
こんにちは、私はgoroutine #1です
こんにちは、私はgoroutine #5です
こんにちは、私はgoroutine #2です
こんにちは、私はgoroutine #4です
すべてのgoroutineが完了しました
例:並列計算と結果集約(難易度⭐⭐)
package main
import (
"fmt"
"sync"
)
// 並列に二乗を計算し、結果を共有スライスに格納
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
results := make([]int, len(numbers))
var wg sync.WaitGroup
for i, num := range numbers {
wg.Add(1)
go func(index, val int) {
defer wg.Done()
results[index] = val * val // 各goroutineは異なるインデックスに書き込むため、ロック不要
}(i, num) // ループ変数を引数として渡し、クロージャキャプチャの問題を回避
}
wg.Wait()
fmt.Println("元のデータ:", numbers)
fmt.Println("二乗の結果:", results)
}
出力:
元のデータ:[1 2 3 4 5 6 7 8 9 10]
二乗の結果:[1 4 9 16 25 36 49 64 81 100]
forループ変数は各反復で新しいコピーを作成するため、明示的なパラメータ渡しは不要になりました。ただし、互換性と可読性のために、明示的な渡しを推奨します。
例:並列タスクプールとgoroutineリーク防止(難易度⭐⭐⭐)
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
)
// taskは処理が必要なタスクを表す
func task(ctx context.Context, id int) (string, error) {
// 時間のかかる操作をシミュレート
duration := time.Duration(rand.Intn(500)) * time.Millisecond
select {
case <-time.After(duration):
return fmt.Sprintf("タスク #%d 完了(所要時間 %v)", id, duration), nil
case <-ctx.Done():
return "", ctx.Err() // コンテキストがキャンセルされたら即座に返却し、goroutineリークを防止
}
}
func main() {
rand.Seed(time.Now().UnixNano())
// goroutineリークを防止するための総合タイムアウトを設定
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // リソース解放を保証
taskCount := 20
results := make(chan string, taskCount) // 結果を収集するバッファ付きチャネル
var wg sync.WaitGroup
// 5つのワーカーgoroutineをタスクプールとして起動
workerCount := 5
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for i := workerID; i <= taskCount; i += workerCount {
res, err := task(ctx, i)
if err != nil {
fmt.Printf("ワーカー %d:タスク #%d キャンセル\n", workerID, i)
return
}
results <- res
}
}(w)
}
// すべてのワーカーが完了したらチャネルを閉じる
go func() {
wg.Wait()
close(results)
}()
// 結果を収集
for res := range results {
fmt.Println(res)
}
fmt.Println("すべてのタスクが処理されました")
}
重要なポイント:
context.WithTimeoutを使用して総合タイムアウトを制御し、goroutineの無限待機を回避します。- タスクプールパターン(固定数のワーカー)は、タスクごとに1つのgoroutineよりも制御しやすいです。
close(results)はすべてのワーカーが完了した後にチャネルを閉じ、range resultsはチャネルが閉じられると自動的に終了します。
実践的な使用例
ケース1:並列HTTPリクエスト
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
func fetchURL(url string, wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
resp, err := http.Get(url)
if err != nil {
fmt.Printf("[エラー] %s: %v\n", url, err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
elapsed := time.Since(start)
fmt.Printf("[完了] %s — ステータス: %d、サイズ: %dバイト、時間: %v\n",
url, resp.StatusCode, len(body), elapsed)
}
func main() {
urls := []string{
"https://httpbin.org/get",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/status/200",
}
var wg sync.WaitGroup
start := time.Now()
for _, url := range urls {
wg.Add(1)
go fetchURL(url, &wg)
}
wg.Wait()
fmt.Printf("\nすべて完了、合計時間: %v\n", time.Since(start))
// 合計時間は最も遅いリクエストとほぼ等しく、すべてのリクエストの合計ではありません
}
ケース2:非同期ログ書き込み
package main
import (
"fmt"
"time"
)
type LogEntry struct {
Level string
Message string
}
// logWriterはバックグラウンドgoroutineでログを非同期的に消費する
func logWriter(entries <-chan LogEntry, done chan<- struct{}) {
for entry := range entries {
// ファイルやリモートサービスへの書き込みをシミュレート
time.Sleep(50 * time.Millisecond)
fmt.Printf("[%s] %s\n", entry.Level, entry.Message)
}
done <- struct{}{} // メインgoroutineに書き込み完了を通知
}
func main() {
logCh := make(chan LogEntry, 100) // ログキューとしてのバッファ付きチャネル
done := make(chan struct{})
// バックグラウンドのログ書き込みgoroutineを起動
go logWriter(logCh, done)
// メインプログラムは通常通り実行し、非同期的にログを記録
for i := 1; i <= 5; i++ {
logCh <- LogEntry{
Level: "INFO",
Message: fmt.Sprintf("リクエスト #%d を処理中", i),
}
fmt.Printf("ログ #%d を送信しました\n", i)
}
close(logCh) // チャネルを閉じてライターに終了を通知
<-done // ライターの完了を待機
fmt.Println("プログラムを終了します")
}
❓ よくある質問
1. なぜgoroutineが実行されないのですか?
メインgoroutineが終了すると、すべての子goroutineを強制終了します。一般的な原因:
// ❌ 誤り:メインgoroutineが即座に終了し、子goroutineに実行の機会がない
func main() {
go fmt.Println("こんにちは")
}
// ✅ 正しい:WaitGroupを使用して待機する
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("こんにちは")
}()
wg.Wait()
}
2. goroutineはいつリークしますか?
goroutineリークとは、goroutineが終了できず、メモリとスケジューラリソースを継続的に占有することです。一般的な原因:
// ❌ リーク:チャネルに受信側がない
func leaky() <-chan int {
ch := make(chan int)
go func() {
ch <- 42 // 永久にブロックされ、goroutineが終了できない
}()
return ch
}
// ✅ 安全:バッファ付きチャネルまたはコンテキストキャンセルを使用
func safe() <-chan int {
ch := make(chan int, 1) // バッファ1、送信はブロックしない
go func() {
ch <- 42
}()
return ch
}
3. ループ変数のクロージャキャプチャの古典的なトラップを回避するには?
// ❌ Go 1.21以前:すべてのgoroutineが同じ値を出力する可能性がある
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // すべて5を出力する可能性がある
}()
}
// ✅ 推奨:明示的なパラメータ渡し、すべてのGoバージョンに対応
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // 正しく0、1、2、3、4を出力
}(i)
}
4. 現在実行中のgoroutineの数を取得できますか?
import "runtime"
fmt.Println("現在のgoroutine数:", runtime.NumGoroutine())
これはgoroutineリークのデバッグに非常に便利です。正常なアイドル状態のプログラムは通常1〜2個のgoroutineしか持たず、数が増加し続ける場合はリークが発生している可能性があります。
📖 まとめ
| コンセプト | 説明 |
|---|---|
goキーワード |
goroutineを起動、構文はgo func() |
| スケジューリングモデル | G-M-Pモデル、Goランタイムがスケジューリングを処理、ユーザーには透過的 |
| 軽量 | 初期スタックは約2KB、数十万個作成可能 |
| WaitGroup | グループのgoroutineの完了を待機するために使用 |
| Context | キャンセルシグナルの伝播とタイムアウト制御に使用 |
| リーク防止 | すべてのgoroutineに退出パスがあることを保証 |
コア原則:
- 常にgoroutineに明確な退出条件があることを保証します。
sync.WaitGroupまたはチャネルを使用して、メインgoroutineと子goroutineを同期します。contextを使用してgoroutineのライフサイクルを制御し、リークを防止します。- クロージャとループ変数の相互作用に注意してください(明示的なパラメータ渡しを推奨)。
📝 演習
演習1:並列素数チェッカー
goroutineを使用して一連の数値が素数かどうかを並列にチェックし、結果を集約して出力するプログラムを書いてください。
// ヒント:
// - isPrime(n int) bool 関数を定義
// - 各数値に対してgoroutineを起動
// - WaitGroupですべての完了を待機
// - ミューテックスまたはチャネルで結果を収集
演習2:並列ダウンロードシミュレーター
並列ファイルダウンロードをシミュレートします:3つのワーカーgoroutineを起動し、タスクキューからタスクを取得し、各ダウンロードはランダムな100〜1000msかかり、合計時間と各ワーカーが完了したタスク数を報告します。
// ヒント:
// - チャネルをタスクキューとして使用
// - 各ワーカーがチャネルからタスクを読み取る
// - context.WithTimeoutで総合タイムアウトを制御
// - sync.Mapまたはミューテックスで各ワーカーの完了数をカウント
演習3:goroutineリーク検出器
タスク関数をパラメータとして受け取り、実行し、goroutine数を監視するユーティリティ関数を書いてください。タスク完了後のgoroutine数が実行前より多い場合は、リークの可能性があることを示す警告を出力します。
// ヒント:
// - runtime.NumGoroutine()でgoroutine数を取得
// - タスク実行前後のカウントを記録
// - 実行後、短い時間(例:100ms)待ってからチェック
// - 検出結果を出力



