Goroutine

レッスン13:Goroutine

レストランを想像してください:ウェイターが1人だけ(シングルスレッド)の場合、注文を受け付け、料理を提供し、各テーブルの会計を終えてから次のテーブルに移る必要があります。_MANY_のウェイターを雇う(マルチスレッド)のはコストがかかります。Goのソリューションは、同じレストランシステム(スケジューラ)を共有する軽量なパートタイムウェイター(goroutine)のグループを雇うことです——空いている人が次に対応し、最小限のリソースで大量の顧客を処理します。


コアコンセプト


基本構文と使い方

Goroutineの起動

関数呼び出しの前にgoキーワードを追加して、新しいgoroutineを起動します:

GO
go functionName(args)
// または
go func() {
    // 匿名関数の本体
}()

sync.WaitGroupでgoroutineを待機する

メインgoroutineは子goroutineを自動的に待機しないため、sync.WaitGroupを使用して同期します:

GO
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の起動(難易度⭐)

GO
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が完了しました")
}
▶ 試してみよう

出力順序は不定です(並列実行のため):

TEXT
こんにちは、私はgoroutine #3です
こんにちは、私はgoroutine #1です
こんにちは、私はgoroutine #5です
こんにちは、私はgoroutine #2です
こんにちは、私はgoroutine #4です
すべてのgoroutineが完了しました

例:並列計算と結果集約(難易度⭐⭐)

GO
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)
}
▶ 試してみよう

出力:

TEXT
元のデータ:[1 2 3 4 5 6 7 8 9 10]
二乗の結果:[1 4 9 16 25 36 49 64 81 100]
💡 ヒント: Go 1.22以降、forループ変数は各反復で新しいコピーを作成するため、明示的なパラメータ渡しは不要になりました。ただし、互換性と可読性のために、明示的な渡しを推奨します。


例:並列タスクプールとgoroutineリーク防止(難易度⭐⭐⭐)

GO
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("すべてのタスクが処理されました")
}
▶ 試してみよう

重要なポイント:


実践的な使用例

ケース1:並列HTTPリクエスト

GO
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:非同期ログ書き込み

GO
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を強制終了します。一般的な原因:

GO
// ❌ 誤り:メイン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が終了できず、メモリとスケジューラリソースを継続的に占有することです。一般的な原因:

GO
// ❌ リーク:チャネルに受信側がない
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
// ❌ 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の数を取得できますか?

GO
import "runtime"

fmt.Println("現在のgoroutine数:", runtime.NumGoroutine())

これはgoroutineリークのデバッグに非常に便利です。正常なアイドル状態のプログラムは通常1〜2個のgoroutineしか持たず、数が増加し続ける場合はリークが発生している可能性があります。


📖 まとめ

コンセプト 説明
goキーワード goroutineを起動、構文はgo func()
スケジューリングモデル G-M-Pモデル、Goランタイムがスケジューリングを処理、ユーザーには透過的
軽量 初期スタックは約2KB、数十万個作成可能
WaitGroup グループのgoroutineの完了を待機するために使用
Context キャンセルシグナルの伝播とタイムアウト制御に使用
リーク防止 すべてのgoroutineに退出パスがあることを保証

コア原則:

  1. 常にgoroutineに明確な退出条件があることを保証します。
  2. sync.WaitGroupまたはチャネルを使用して、メインgoroutineと子goroutineを同期します。
  3. contextを使用してgoroutineのライフサイクルを制御し、リークを防止します。
  4. クロージャとループ変数の相互作用に注意してください(明示的なパラメータ渡しを推奨)。

📝 演習

演習1:並列素数チェッカー

goroutineを使用して一連の数値が素数かどうかを並列にチェックし、結果を集約して出力するプログラムを書いてください。

GO
// ヒント:
// - isPrime(n int) bool 関数を定義
// - 各数値に対してgoroutineを起動
// - WaitGroupですべての完了を待機
// - ミューテックスまたはチャネルで結果を収集

演習2:並列ダウンロードシミュレーター

並列ファイルダウンロードをシミュレートします:3つのワーカーgoroutineを起動し、タスクキューからタスクを取得し、各ダウンロードはランダムな100〜1000msかかり、合計時間と各ワーカーが完了したタスク数を報告します。

GO
// ヒント:
// - チャネルをタスクキューとして使用
// - 各ワーカーがチャネルからタスクを読み取る
// - context.WithTimeoutで総合タイムアウトを制御
// - sync.Mapまたはミューテックスで各ワーカーの完了数をカウント

演習3:goroutineリーク検出器

タスク関数をパラメータとして受け取り、実行し、goroutine数を監視するユーティリティ関数を書いてください。タスク完了後のgoroutine数が実行前より多い場合は、リークの可能性があることを示す警告を出力します。

GO
// ヒント:
// - runtime.NumGoroutine()でgoroutine数を取得
// - タスク実行前後のカウントを記録
// - 実行後、短い時間(例:100ms)待ってからチェック
// - 検出結果を出力

次のレッスン:チャネル →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%