[Goのはじめ方] Part22: syncパッケージ(Mutex, WaitGroupなど)

Go

はじめに:なぜ同期が必要なのか?🤔

前のステップでは、Goの強力な並行処理機能であるGoroutineとChannelについて学びました。Goroutineを使うと、たくさんの処理を同時に(並行して)実行できます。Channelは、Goroutine間で安全にデータをやり取りするための仕組みでしたね。

しかし、複数のGoroutineが同じデータ(変数やメモリ領域)に同時にアクセスしようとすると、問題が発生することがあります。これを競合状態 (Race Condition) と呼びます。例えば、あるGoroutineが値を読み取っている間に、別のGoroutineがその値を書き換えてしまう、といった状況です。

このような問題を解決し、Goroutine間の処理のタイミングを調整するために、Goはsyncパッケージを提供しています。このパッケージには、並行処理を安全かつ効率的に行うためのツールが含まれています。今回は、その中でも特に重要なMutexWaitGroupについて詳しく見ていきましょう!✨

sync.Mutex:共有リソースへのアクセスを制御する 🔒

sync.Mutex(ミューテックス)は、相互排他ロック (Mutual Exclusion Lock) の略です。複数のGoroutineが同時にアクセスしてはいけない共有リソース(変数など)を保護するために使います。

Mutexには主に2つのメソッドがあります。

  • Lock(): ロックを取得します。もし他のGoroutineが既にロックを取得している場合、そのGoroutineがUnlock()するまで待機します。
  • Unlock(): ロックを解放します。待機している他のGoroutineがいれば、そのうちの一つがロックを取得できるようになります。

基本的な使い方は、保護したい処理(クリティカルセクション)の前にLock()を呼び出し、処理が終わったら必ずUnlock()を呼び出す、という流れです。defer文を使ってUnlock()を呼び出すのが一般的です。こうすることで、関数が途中でreturnした場合やパニックが発生した場合でも、確実にロックが解放されます。

コード例:カウンターの保護

以下の例では、複数のGoroutineが共有のカウンター変数countをインクリメントします。Mutexを使わない場合、競合状態が発生して期待通りの結果にならない可能性がありますが、Mutexで保護することで安全にインクリメントできます。

<package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var count int
	var mu sync.Mutex // Mutexを宣言
	var wg sync.WaitGroup

	numGoroutines := 1000

	wg.Add(numGoroutines) // これから起動するGoroutineの数を設定

	for i := 0; i < numGoroutines; i++ {
		go func() {
			defer wg.Done() // Goroutineが終了したらDoneを呼ぶ

			// クリティカルセクション(共有リソースへのアクセス)
			mu.Lock()   // ロックを取得
			count++     // 共有変数をインクリメント
			mu.Unlock() // ロックを解放
		}()
	}

	wg.Wait() // すべてのGoroutineが終了するのを待つ

	fmt.Printf("Expected count: %d\n", numGoroutines)
	fmt.Printf("Actual count: %d\n", count) // Mutexにより期待通りの値になるはず

	// --- Mutexを使わない場合の比較(コメントアウトを外して試してみましょう) ---
	// count = 0 // カウンターリセット
	// wg.Add(numGoroutines)
	// for i := 0; i < numGoroutines; i++ {
	// 	go func() {
	// 		defer wg.Done()
	// 		count++ // Mutexなしでインクリメント (競合状態が発生する可能性)
	// 	}()
	// }
	// wg.Wait()
	// fmt.Println("--- Without Mutex ---")
	// fmt.Printf("Expected count: %d\n", numGoroutines)
	// fmt.Printf("Actual count (without mutex): %d\n", count) // 期待通りにならない可能性が高い
}
>

注意: Mutexを使わずに上記のコードのコメントアウト部分を実行すると、Actual countExpected countと一致しないことがあります。これは競合状態が発生している証拠です。

sync.RWMutex:読み取りが多い場合に効率的 📖

sync.RWMutexは、読み取りロック (Read Lock) と書き込みロック (Write Lock) を区別するMutexです。

  • 書き込みロック (Lock(), Unlock()): 従来のMutexと同じで、一つのGoroutineしかロックを取得できません。書き込みロック中は、他のどのGoroutineも読み取り/書き込みロックを取得できません。
  • 読み取りロック (RLock(), RUnlock()): 複数のGoroutineが同時に読み取りロックを取得できます。ただし、書き込みロックが取得されている間は、読み取りロックは取得できません。

読み取り操作が頻繁に行われ、書き込み操作が少ない場合にRWMutexを使うと、Mutexよりもパフォーマンスが向上することがあります。なぜなら、複数の読み取り操作を同時に許可できるからです。

<package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var configData string
	var rwMu sync.RWMutex // RWMutexを宣言
	var wg sync.WaitGroup

	// 書き込みGoroutine (1つ)
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(100 * time.Millisecond) // 少し待つ

		rwMu.Lock() // 書き込みロックを取得
		fmt.Println("Writer: Acquiring write lock...")
		configData = "New configuration set at " + time.Now().String()
		fmt.Println("Writer: Configuration updated.")
		rwMu.Unlock() // 書き込みロックを解放
		fmt.Println("Writer: Released write lock.")
	}()

	// 読み取りGoroutine (複数)
	numReaders := 5
	wg.Add(numReaders)
	for i := 0; i < numReaders; i++ {
		go func(id int) {
			defer wg.Done()
			// 書き込みが終わるのを待つか、他の読み取りと同時に実行される
			rwMu.RLock() // 読み取りロックを取得
			fmt.Printf("Reader %d: Acquiring read lock...\n", id)
			// 書き込みロックが解放されるまで待つか、他のリーダーと同時に読み取る
			fmt.Printf("Reader %d: Reading config: %s\n", id, configData)
			time.Sleep(50 * time.Millisecond) // 読み取り処理をシミュレート
			rwMu.RUnlock() // 読み取りロックを解放
			fmt.Printf("Reader %d: Released read lock.\n", id)
		}(i)
	}

	wg.Wait() // すべてのGoroutineが終了するのを待つ
	fmt.Println("All goroutines finished.")
}
>

sync.WaitGroup:Goroutineの完了を待つ 🏁

sync.WaitGroupは、起動した複数のGoroutineがすべて終了するのを待ち合わせるために使います。例えば、「いくつかのGoroutineに処理を分担させ、すべての処理が終わったら次のステップに進む」といった場合に便利です。

WaitGroupには主に3つのメソッドがあります。

メソッド説明
Add(delta int)WaitGroupのカウンターにdeltaを加算します。通常、起動するGoroutineの数を指定します。負の値を指定することもできますが、カウンターが負にならないように注意が必要です。
Done()WaitGroupのカウンターを1減らします。Goroutineの処理が完了したときに、そのGoroutine自身がdefer文などを使って呼び出すのが一般的です。
Wait()WaitGroupのカウンターが0になるまで、呼び出し元のGoroutineをブロック(待機)させます。カウンターが最初から0の場合、または既に0になっている場合は、待機せずにすぐに処理が続行されます。

コード例:複数のワーカー処理の完了待ち

以下の例では、いくつかのワーカーGoroutineを起動し、それぞれの処理が終わるのをWaitGroupで待ち合わせます。

<package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	// このGoroutineが終了するときにカウンターを減らすようにdeferで登録
	defer wg.Done()

	fmt.Printf("Worker %d starting\n", id)
	// 何らかの処理を実行
	time.Sleep(time.Second)
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	// WaitGroupを生成
	var wg sync.WaitGroup

	numWorkers := 3

	// 起動するGoroutineの数だけカウンターを増やす
	wg.Add(numWorkers)

	// ワーカーGoroutineを起動
	for i := 1; i <= numWorkers; i++ {
		go worker(i, &wg) // WaitGroupへのポインタを渡す
	}

	fmt.Println("Waiting for workers to finish...")
	// すべてのGoroutineがDone()を呼び出すまで待機
	wg.Wait()

	fmt.Println("All workers finished.")
}
>

💡 wg.Add(1) をGoroutine起動のループ内で行い、go func() { defer wg.Done(); ... }() のように書くことも一般的です。ただし、Addの呼び出しは、goキーワードによるGoroutine起動の前に行う必要があります。

その他のsyncパッケージの機能(少しだけ紹介)

syncパッケージには、他にも便利な機能があります。ここでは簡単に紹介します。

  • sync.Once: 特定の処理を、複数のGoroutineから呼び出されたとしても、プログラム全体で一度だけ確実に実行したい場合に使います。初期化処理などに便利です。
  • sync.Pool: 一時的なオブジェクトをプールしておき、再利用するための仕組みです。頻繁に生成・破棄されるオブジェクト(例えば、大きなバッファなど)のメモリ確保やガベージコレクションの負荷を軽減するのに役立ちます。
  • sync.Cond: 特定の条件が満たされるまでGoroutineを待機させ、条件が満たされたときに待機しているGoroutineを再開させるための仕組みです(条件変数)。Mutexと組み合わせて使われることが多いです。

これらの機能は、より高度な並行処理パターンを実装する際に役立ちますが、まずはMutexWaitGroupをしっかり理解することが重要です。

まとめ 🎉

今回は、Goのsyncパッケージに含まれる重要な同期プリミティブであるMutexWaitGroupについて学びました。

  • sync.Mutexは、共有リソースへの同時アクセスを防ぎ、競合状態を回避するために使います。読み取りが多い場合はsync.RWMutexが有効です。
  • sync.WaitGroupは、複数のGoroutineの完了を待ち合わせるために使います。

これらのツールを適切に使うことで、Goの強力な並行処理機能を安全かつ効果的に活用できます。最初は少し難しく感じるかもしれませんが、実際にコードを書いて試してみるのが一番の近道です! 💪

次のステップでは、ファイル操作やネットワーク、Web開発に関連するパッケージについて学んでいきます。お楽しみに!

コメント

タイトルとURLをコピーしました