戻る

Golangにおける並行処理

ニキータ・ハビャ

ニキータ・ハビャ

6分間の読み物

|

3 week前

並行処理とは、複数のタスクを同時に**「処理する」ことです。Goはgoroutineチャネルを通じて、並行処理の組み込みサポートを提供します。このブログでは、Goにおける並行処理の仕組みと、いくつかの一般的な並行処理パターン**について探っていきましょう。

並行性のない世界 ☹️

並行処理がない場合、コード行は順番に実行され、つまり現在の行の実行が完了するまで後続の行をブロックします。この同期実行は、単純なアプリやパフォーマンスが問題にならない場合に適しています。

しかし、実際のアプリケーションでは、これが遅い動作や不快なユーザー体験につながる可能性があります。

並行処理のないYouTubeアプリを想像してみてください 🚩

  1. 動画をクリックして再生すると、動画のバッファリングが始まります...
  2. 動画がバッファリングされている間、UI全体がフリーズします
  3. スクロールも、一時停止も、コメントの閲覧も、一切の操作ができません
  4. 動画の読み込みが完了して初めて、アプリが再び操作を許可します(なんてイライラするでしょう!)

もし同じ状況なら、今こそ並行処理について学び、それがどうすれば優れた応答性の高い、そして高速なアプリケーション構築に役立つのかを知る良い機会です。

Goにおける並行処理 🚀

並行処理とは、多くのタスクを「処理する」ことです。重要なのは「処理する」ことに焦点を当てており、「同時に実行する」ことではありません。

タスクが実際に異なるCPUコアやプロセッサ上で同時に実行される場合、それは並列処理と呼ばれます(これは別の機会に解説します)。

Goにおける並行処理は、以下の2つの構成要素から成り立っています:

ゴルーチン 💪

💡 「go」キーワードについて:

  • 関数呼び出しの前にgoキーワードを付けると、Goランタイムにその関数をゴルーチンとして実行するよう指示します。Goランタイムは、OSスレッドの上でゴルーチンのスケジューリング実行を管理するため、ゴルーチンは軽量効率的です。
  • メイン関数もゴルーチンです。メインゴルーチンが終了すると、他のすべてのゴルーチンも完了していなくても終了します。
  • これを防ぐために、メインゴルーチンを一時的に停止させる(例:time.Sleepを使用)か、同期メカニズム(WaitGroupsなど)を使用できます。
  • time.Sleepは並行処理を考える上で悪い方法です。なぜなら、他のゴルーチンが完了するまでにどれくらい時間がかかるか分からないからです。本番コードで同期のために使用することはほとんどありません。
  • WaitGroupは、ゴルーチンの完了を待つためのより良い方法です。

チャネル ↔️

Goにおける並行処理パターン 📐

アプリケーションの複雑さと並行処理要件が増すにつれ、並行処理を効果的に管理し潜在的なバグを特定するためには、特定のパターンに従う必要があります。 そのためには、よく知られた並行処理パターンに従うことが推奨されます。

Goで認知されている並行処理パターンはほとんど~

1. ワーカープールパターン

固定数の「ワーカー」goroutineが、共有キューからジョブを処理します。

このパターンは、以下の要件を満たす場合に不可欠です:

2. ファンイン・ファンアウトパターン

3. パイプラインパターン

このパターンでは、データは一連のステージを通過します。各ステージは、チャネルで接続されたgoroutineで表されます。各ステージは以下を行います:

4.ジェネレータパターン

何らかの出力を生成するために処理可能なデータストリームを作成するために使用されます。

5. セマフォパターン

共有リソースに同時にアクセスできるGoroutineの数を制御します。セマフォを使用して以下を実現します:

他にも多くのパターンやバリエーションがありますが、これらはGoアプリケーションで最も一般的に使用されるもののいくつかです。

詳細はGo Concurrency Patternsを参照してください。

レース条件 🏁

レース条件は、複数のgoroutineが共有データに同時にアクセスし、その少なくとも1つがデータを変更している場合に発生します。 この結果は予測不可能で、デバッグが困難になる可能性があります。

Goコードの実行時やテスト時に-raceフラグを使用することで、レース条件を検出できます。

go
  go run -race main.go
  go test -race

これは開発やテスト段階で潜在的なレース条件を特定するのに役立ちます。強く推奨される手法であり、安全な並行コードの記述に貢献します。

go
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秒かかります
}

追記: 並行実行時、出力順序保証されません。これは、goroutineが独立して実行され、異なるタイミングで完了する可能性があるためです。

「取り扱い注意」:並行処理の落とし穴 ⚠️

これらの落とし穴を避けるには、並行処理コードを慎重に設計・テストし、同期化メカニズムを正しく使用することが重要です。

P.S 本当に必要でない限り、並行処理は使用しないでください!

まとめ