並行処理とは、複数のタスクを同時に**「処理する」ことです。Goはgoroutineとチャネルを通じて、並行処理の組み込みサポートを提供します。このブログでは、Goにおける並行処理の仕組みと、いくつかの一般的な並行処理パターン**について探っていきましょう。
並行性のない世界 ☹️
並行処理がない場合、コード行は順番に実行され、つまり現在の行の実行が完了するまで後続の行をブロックします。この同期実行は、単純なアプリやパフォーマンスが問題にならない場合に適しています。
しかし、実際のアプリケーションでは、これが遅い動作や不快なユーザー体験につながる可能性があります。
並行処理のないYouTubeアプリを想像してみてください 🚩
- 動画をクリックして再生すると、動画のバッファリングが始まります...
- 動画がバッファリングされている間、UI全体がフリーズします
- スクロールも、一時停止も、コメントの閲覧も、一切の操作ができません
- 動画の読み込みが完了して初めて、アプリが再び操作を許可します(なんてイライラするでしょう!)
もし同じ状況なら、今こそ並行処理について学び、それがどうすれば優れた、応答性の高い、そして高速なアプリケーション構築に役立つのかを知る良い機会です。
Goにおける並行処理 🚀
並行処理とは、多くのタスクを「処理する」ことです。重要なのは「処理する」ことに焦点を当てており、「同時に実行する」ことではありません。
タスクが実際に異なるCPUコアやプロセッサ上で同時に実行される場合、それは並列処理と呼ばれます(これは別の機会に解説します)。
Goにおける並行処理は、以下の2つの構成要素から成り立っています:
- ゴルーチン
- チャネル
ゴルーチン 💪
- 独立して実行される軽量関数
- **
go**キーワードを使用して作成 - ゴルーチンの詳細は前回のブログ記事で詳しく解説
💡 「go」キーワードについて:
- 関数呼び出しの前に
goキーワードを付けると、Goランタイムにその関数をゴルーチンとして実行するよう指示します。Goランタイムは、OSスレッドの上でゴルーチンのスケジューリングと実行を管理するため、ゴルーチンは軽量で効率的です。- メイン関数もゴルーチンです。メインゴルーチンが終了すると、他のすべてのゴルーチンも完了していなくても終了します。
- これを防ぐために、メインゴルーチンを一時的に停止させる(例:
time.Sleepを使用)か、同期メカニズム(WaitGroupsなど)を使用できます。time.Sleepは並行処理を考える上で悪い方法です。なぜなら、他のゴルーチンが完了するまでにどれくらい時間がかかるか分からないからです。本番コードで同期のために使用することはほとんどありません。- WaitGroupは、ゴルーチンの完了を待つためのより良い方法です。
チャネル ↔️
- ゴルーチン同士が通信するための手段
- 有名な引用句 「メモリを共有することで通信するのではなく、通信することでメモリを共有せよ」 はGoにおいても真実です。
- ゴルーチンはチャネルを通じて値を送受信でき、互いの状態を認識させることができます。
- チャネルの詳細は前回のブログ記事で詳しく解説
Goにおける並行処理パターン 📐
アプリケーションの複雑さと並行処理要件が増すにつれ、並行処理を効果的に管理し潜在的なバグを特定するためには、特定のパターンに従う必要があります。 そのためには、よく知られた並行処理パターンに従うことが推奨されます。
Goで認知されている並行処理パターンはほとんど~
1. ワーカープールパターン
固定数の「ワーカー」goroutineが、共有キューからジョブを処理します。
このパターンは、以下の要件を満たす場合に不可欠です:
- 同時実行操作の制限(例:データベース接続)
- システムリソースを圧迫せずに大量のタスクを効率的に処理する
2. ファンイン・ファンアウトパターン
- ファンアウト: 大規模なタスクを複数のgoroutineに分散し、サブタスクを並行して実行する
- ファンイン: 複数のgoroutineからの結果を単一のチャネルに収集する
3. パイプラインパターン
このパターンでは、データは一連のステージを通過します。各ステージは、チャネルで接続されたgoroutineで表されます。各ステージは以下を行います:
- 入力チャネルからデータを受信する
- 固有の変換処理を実行する
- 出力チャネルを介して結果を次のステージに送信する
4.ジェネレータパターン
何らかの出力を生成するために処理可能なデータストリームを作成するために使用されます。
- 値を生成し、それらをチャネルに送信するジェネレータ関数で構成されます。
- 他のgoroutineはこのチャネルからデータを受信し、必要に応じて処理できます。
5. セマフォパターン
共有リソースに同時にアクセスできるGoroutineの数を制御します。セマフォを使用して以下を実現します:
- データベース接続の同時実行数を制限する
- APIリクエストをスロットリングし、高負荷時のリソース枯渇を防ぐ
他にも多くのパターンやバリエーションがありますが、これらはGoアプリケーションで最も一般的に使用されるもののいくつかです。
詳細はGo Concurrency Patternsを参照してください。
レース条件 🏁
レース条件は、複数のgoroutineが共有データに同時にアクセスし、その少なくとも1つがデータを変更している場合に発生します。 この結果は予測不可能で、デバッグが困難になる可能性があります。
Goコードの実行時やテスト時に-raceフラグを使用することで、レース条件を検出できます。
go run -race main.go
go test -race
これは開発やテスト段階で潜在的なレース条件を特定するのに役立ちます。強く推奨される手法であり、安全な並行コードの記述に貢献します。
package main
import (
"fmt"
"sync"
"time"
)
// calculateSquare は時間のかかる計算をシミュレートします
func calculateSquare(num int) int {
time.Sleep(time.Second)
return num * num
}
// 同期実行: 実行は順次
func sequentialCalculation(numbers []int) {
fmt.Println("同期実行")
start := time.Now()
for _, num := range numbers {
result := calculateSquare(num)
fmt.Printf("%d² = %d\n", num, result)
}
fmt.Printf("Time taken: %v\n", time.Since(start))
}
// 並行実行: goroutineとWaitGroupを使用
func concurrentCalculation(numbers []int) {
fmt.Println("並行実行")
start := time.Now()
var wg sync.WaitGroup
for _, num := range numbers {
wg.Add(1)
go func(n int) {
defer wg.Done()
result := calculateSquare(n)
fmt.Printf("%d² = %d\n", n, result)
}(num)
}
wg.Wait()
fmt.Printf("処理時間: %v\n", time.Since(start))
}
// チャネルを使用した並行実行: goroutineとチャネルを使用
func concurrentWithChannels(numbers []int) {
fmt.Println("チャネルを使用した並行実行")
start := time.Now()
results := make(chan string, len(numbers))
for _, num := range numbers {
go func(n int) {
result := calculateSquare(n)
results <- fmt.Sprintf("%d² = %d", n, result)
}(num)
}
for range numbers {
fmt.Println(<-results)
}
fmt.Printf("処理時間: %v\n", time.Since(start))
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
sequentialCalculation(numbers) // 約5秒かかります
fmt.Println("================")
concurrentCalculation(numbers) // 約1秒かかります
fmt.Println("================")
concurrentWithChannels(numbers) // 約1秒かかります
}
- 上記の例では、スライス内の数値の平方を計算する異なるアプローチを示す3つの関数があります。
sequentialCalculation関数は計算を順次実行し、5個の数字で約5秒かかります。concurrentCalculation関数はgoroutineとWaitGroupを用いて並列計算を行い、処理時間を約1秒に短縮します。concurrentWithChannels関数はgoroutineとチャネルを用いて同様の並列処理を実現し、こちらも約1秒で完了します。- 両方の並列処理手法は、WaitGroupとチャネルを活用してgoroutineを効率的に管理することで、パフォーマンスを大幅に向上させています。
追記: 並行実行時、出力順序は保証されません。これは、goroutineが独立して実行され、異なるタイミングで完了する可能性があるためです。
「取り扱い注意」:並行処理の落とし穴 ⚠️
- Goroutineリーク: Goroutineが適切に終了されず、バックグラウンドで実行を継続しリソースを消費し続ける状態。チャンネルへの書き込みや読み取りが一切行われない状態でブロックされた場合、または無限ループに陥った場合に発生する可能性があります。
- デッドロック: バッファリングされていないチャネルを使用する場合、2つ以上のGoroutineが互いのデータ送信または受信を待機していると、いずれも進行不能なデッドロック状態に陥ることがあります。
- 競合状態: 複数のgoroutineが適切な同期を確保せずに共有データに同時にアクセスすると、予測不可能な動作を引き起こします。
- goroutineの終了待ちをしない場合: メインgoroutineが他のgoroutineの処理が完了する前に終了すると、それらのgoroutineは途中で強制終了されます。
これらの落とし穴を避けるには、並行処理コードを慎重に設計・テストし、同期化メカニズムを正しく使用することが重要です。
P.S 本当に必要でない限り、並行処理は使用しないでください!
まとめ
並行処理はGoの強力な機能であり、応答性が高く高速で高性能なアプリケーションを構築することを可能にします。GoroutinesとchannelsはGoの並行処理モデルの中核を成す。- 概念を理解する以上に、並行処理を扱う際の落とし穴とベストプラクティスを知ることが重要である。
並行処理パターンを活用することで、複雑性の管理と安全な並行コードの記述が可能になる。- 開発プロセス早期に潜在的な競合状態を特定するため、
-raceフラグで並行コードをテストせよ。