はじめに (対象読者・この記事でわかること)

この記事は、Go言語の基本的な文法を理解している中級者を対象としています。特に並行処理に興味があり、goroutineの適切な管理方法を学びたい方に最適です。この記事を読むことで、Go言語におけるgoroutineの基本的な使い方から、contextパッケージを活用したキャンセル処理の実装方法までを理解できます。また、複数のgoroutineを効率的に管理する実践的なテクニックや、よくある問題点とその解決策も学べます。最近Go言語を学び始め、並行処理の実装に挑戦している方にとって、実践的な知識を得られる内容となっています。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - Go言語の基本的な文法と構造 - 関数とメソッドの定義方法 - チャネル(channel)の基本的な使い方 - 並行処理の基本的な概念

goroutineとキャンセルの概要・背景

Go言語は、シンプルな文法ながら強力な並行処理機能を備えており、その中心に位置するのがgoroutineです。goroutineは、非常に軽量なスレッドであり、数千から数万のgoroutineを同時に実行することができます。この特性により、I/Oバウンドな処理を効率的に並列化できます。

しかし、goroutineは起動すると明示的に停止しない限り実行を続けます。このため、適切なタイミングでgoroutineを停止させないと、以下のような問題が発生します:

  1. リソースの無駄遣い: 不要になった処理がCPU時間やメモリを消費し続ける
  2. プログラムの終了遅延: メインの処理が完了しても、goroutineが実行し続けるためプログラムが終了しない
  3. デッドロックのリスク: チャネル操作でブロックし続ける可能性がある

これらの問題を解決するために、Go言語ではcontextパッケージが提供されています。contextは、goroutine間でスコープ、キャンセル、タイムアウト、要求値を伝播させるための仕組みです。これにより、親goroutineが子goroutineのライフサイクルを制御し、リソースを適切に解放することができます。

具体的な実装方法

ステップ1:基本的なgoroutineの使い方

まずは、goroutineの基本的な使い方から見ていきましょう。goroutineを起動するには、関数呼び出しの前にgoキーワードを付けるだけです。

Go
package main import ( "fmt" "time" ) func task() { for i := 0; i < 5; i++ { fmt.Println("Task running:", i) time.Sleep(1 * time.Second) } fmt.Println("Task completed") } func main() { fmt.Println("Starting task") go task() // goroutineとしてtask関数を実行 time.Sleep(3 * time.Second) // メインgoroutineが3秒待機 fmt.Println("Main function completed") }

このコードを実行すると、メインgoroutineが3秒待機している間に、子goroutineがタスクを実行します。しかし、このコードには問題点があります。メインgoroutineが3秒後に終了しても、子goroutineはまだ実行を続けています。これがgoroutineリークの原因となります。

ステップ2:contextを使ったキャンセルの実装

次に、contextを使ったキャンセル処理を実装してみましょう。contextパッケージには、キャンセル機能を提供するWithCancel関数があります。

Go
package main import ( "context" "fmt" "time" ) func task(ctx context.Context) { for { select { case <-ctx.Done(): fmt.Println("Task cancelled") return default: fmt.Println("Task running") time.Sleep(1 * time.Second) } } } func main() { ctx, cancel := context.WithCancel(context.Background()) go task(ctx) time.Sleep(3 * time.Second) fmt.Println("Cancelling task") cancel() // キャンセルを通知 // キャンセルが伝播されるのを待つ time.Sleep(1 * time.Second) fmt.Println("Main function completed") }

このコードでは、context.WithCancelを使ってキャンセル可能なcontextを作成し、それをgoroutineに渡しています。goroutineはctx.Done()チャネルを監視し、このチャネルが閉じられたら処理を終了します。cancel()関数が呼ばれると、ctx.Done()チャネルが閉じられ、goroutineが適切に終了します。

ステップ3:タイムアウト付きのキャンセル

実用的なアプリケーションでは、処理に時間制限を設けることがよくあります。contextパッケージには、タイムアウト付きのcontextを作成するWithTimeout関数も用意されています。

Go
package main import ( "context" "fmt" "time" ) func longRunningTask(ctx context.Context) { select { case <-time.After(5 * time.Second): fmt.Println("Task completed successfully") case <-ctx.Done(): fmt.Println("Task cancelled due to timeout") } } func main() { // 3秒のタイムアウトを設定 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // 関数終了時にcancelを呼ぶ fmt.Println("Starting task with timeout") go longRunningTask(ctx) // メインgoroutineが終了するまで待機 time.Sleep(10 * time.Second) fmt.Println("Main function completed") }

このコードでは、5秒かかるはずのタスクを3秒のタイムアウト付きで実行しています。タイムアウトが経過すると、自動的にキャンセルが通知され、タスクが終了します。

ステップ4:複数のgoroutineをキャンセルする方法

実際のアプリケーションでは、複数のgoroutineを一度にキャンセルしたい場面があります。contextは親子関係を持つことができ、親のcontextがキャンセルされると、子のcontextにも自動的にキャンセルが伝播します。

Go
package main import ( "context" "fmt" "time" ) func worker(ctx context.Context, id int) { for { select { case <-ctx.Done(): fmt.Printf("Worker %d cancelled\n", id) return default: fmt.Printf("Worker %d running\n", id) time.Sleep(1 * time.Second) } } } func main() { // 親のcontextを作成 ctx, cancel := context.WithCancel(context.Background()) // 複数のworkerを起動 for i := 1; i <= 3; i++ { go worker(ctx, i) } // 5秒後にすべてのworkerをキャンセル time.Sleep(5 * time.Second) fmt.Println("Cancelling all workers") cancel() // キャンセルが伝播されるのを待つ time.Sleep(2 * time.Second) fmt.Println("Main function completed") }

このコードでは、3つのworker goroutineを起動し、親のcontextを通じて一度にキャンセルしています。親のcontextがキャンセルされると、すべての子workerにキャンセルが伝播します。

ハマった点やエラー解決

問題1:goroutineリーク

症状: プログラムが終了しない、またはメモリ使用量が増え続ける

原因: キャンセル処理を実装していない、または適切に呼び出していない

解決策: 1. すべてのgoroutineがキャンセル可能なcontextを使用していることを確認する 2. キャンセル関数を適切なタイミングで呼び出す 3. defer文を使ってリソースを確実に解放する

Go
// 良い例 func process() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 関数終了時に必ずキャンセル go worker(ctx) // ... 他の処理 }

問題2:contextの誤った使用

症状: キャンセルが期待通りに機能しない

原因: contextを複数の目的で使い回している、または不適切なcontextを使用している

解決策: 1. 各goroutineに適切なcontextを渡す 2. contextのスコープを明確にする 3. 親子関係を正しく構築する

Go
// 良い例 func parent() { // 親のcontextを作成 parentCtx, parentCancel := context.WithCancel(context.Background()) defer parentCancel() // 子のcontextを作成 childCtx, childCancel := context.WithCancel(parentCtx) defer childCancel() go childWorker(childCtx) // ... 他の処理 }

問題3:チャネルとcontextの競合

症状: デッドロックが発生する、または処理が停止する

原因: チャネル操作とcontextの監視が競合している

解決策: 1. select文を使ってチャネル操作とcontextの監視を両立させる 2. デフォルトケースを追加してブロックを避ける

Go
// 良い例 func worker(ctx context.Context, ch <-chan int) { for { select { case <-ctx.Done(): return // キャンセルされたら終了 case val, ok := <-ch: if !ok { return // チャネルが閉じられたら終了 } // 値を処理 } } }

まとめ

本記事では、Go言語におけるgoroutineとキャンセル処理の実践的な方法について解説しました。

  • goroutineは軽量なスレッドだが、適切に管理しないとリソースリークの原因となる
  • contextパッケージを使うことで、goroutineのライフサイクルを制御できる
  • WithCancel、WithTimeout、WithDeadlineを使い分けることで、様々なキャンセルシナリオに対応できる
  • 親子関係を持つcontextを利用することで、複数のgoroutineを一度にキャンセルできる

この記事を通して、Go言語の並行処理におけるgoroutineの適切な管理方法と、リソースリークを防ぐためのキャンセル処理の実装方法を理解できたことと思います。これにより、より安定し効率的なGoプログラムを開発できるようになります。

今後は、contextを使った値の伝播や、より高度な並行処理パターンについても記事にする予定です。

参考資料

参考にした記事、ドキュメント、書籍などがあれば、必ず記載しましょう。