Go言語 syncパッケージ入門:Mutex、WaitGroup、Once の使い方
Go言語は並行処理に優れた言語であり、その強力な並行処理機能は、効率的なサーバー構築、データ処理、分散システム開発などを可能にします。sync
パッケージは、Go言語の並行処理を支える重要なパッケージの一つであり、複数のゴルーチン間でのデータの同期や、特定の処理の実行制御などを実現するための様々なプリミティブを提供しています。本稿では、sync
パッケージの中でも特に重要なMutex
、WaitGroup
、Once
に焦点を当て、その使い方を詳細に解説します。これらのプリミティブを理解し使いこなすことで、Go言語の並行処理をより深く理解し、安全かつ効率的な並行処理プログラムを開発することができるようになります。
1. 並行処理における同期の必要性
並行処理とは、複数の処理を同時に実行することを指します。Go言語では、軽量なスレッドであるゴルーチンを用いることで、容易に並行処理を実現できます。しかし、複数のゴルーチンが同じリソース(変数、ファイル、ネットワーク接続など)に同時にアクセスする場合、予期せぬ問題が発生する可能性があります。
例えば、複数のゴルーチンが同時にカウンター変数をインクリメントする場合を考えてみましょう。各ゴルーチンは以下の手順でカウンターをインクリメントするとします。
- カウンターの現在の値を読み込む。
- 読み込んだ値に1を加算する。
- 加算した値をカウンターに書き込む。
もし、複数のゴルーチンが同時に同じ値を読み込んだ場合、それぞれが1を加算し、書き込むことになります。しかし、最後に書き込まれた値が有効となり、他のゴルーチンのインクリメントは失われてしまいます。これを競合状態(Race Condition)と呼びます。
競合状態は、データの破損、プログラムのクラッシュ、予測不能な動作など、様々な問題を引き起こす可能性があります。そのため、並行処理を行う際には、複数のゴルーチンが共有リソースに安全にアクセスできるように、同期(Synchronization)を行う必要があります。
同期とは、複数のゴルーチンの実行順序を調整し、共有リソースへのアクセスを制御することで、競合状態を回避する仕組みです。sync
パッケージは、この同期を実現するための様々なプリミティブを提供しています。
2. Mutex:相互排他ロック
Mutex
(Mutual Exclusion)は、最も基本的な同期プリミティブの一つであり、複数のゴルーチンが共有リソースに同時にアクセスすることを防ぐための相互排他ロックを提供します。
Mutex
は、Lock
とUnlock
という2つのメソッドを持ちます。Lock
メソッドは、Mutex
のロックを取得しようとします。もし、他のゴルーチンがすでにロックを取得している場合、Lock
メソッドはロックが解放されるまでブロックされます。Unlock
メソッドは、Mutex
のロックを解放します。ロックを解放することで、他のゴルーチンがロックを取得できるようになります。
2.1 Mutexの基本的な使い方
“`go
package main
import (
“fmt”
“sync”
“time”
)
var (
counter int
mutex sync.Mutex
)
func incrementCounter() {
mutex.Lock() // ロックを取得
defer mutex.Unlock() // 関数終了時にロックを解放
counter++
fmt.Printf("Counter: %d (Goroutine ID: %v)\n", counter, getGoroutineID())
time.Sleep(time.Millisecond * 100) // 処理をシミュレート
}
func getGoroutineID() int {
var buf [64]byte
runtime.Stack(buf[:], false)
idField := strings.Fields(strings.TrimPrefix(string(buf[:]), “goroutine “))[0]
id, err := strconv.Atoi(idField)
if err != nil {
panic(fmt.Sprintf(“cannot get goroutine id: %v”, err))
}
return id
}
func main() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done()
for j := 0; j < 10; j++ {
incrementCounter()
}
}(i)
}
wg.Wait() // すべてのゴルーチンが終了するまで待機
fmt.Println("Final Counter:", counter)
}
“`
このコードでは、counter
という共有変数を複数のゴルーチンからインクリメントしています。mutex
というsync.Mutex
型の変数が、counter
へのアクセスを保護するために使用されています。
incrementCounter
関数では、まずmutex.Lock()
を呼び出してロックを取得します。defer mutex.Unlock()
は、関数が終了する際に必ずmutex.Unlock()
が呼び出されるようにするためのものです。これにより、関数が正常に終了した場合だけでなく、panicが発生した場合でも、ロックが確実に解放されます。
mutex.Lock()
とmutex.Unlock()
で囲まれた部分はクリティカルセクションと呼ばれます。クリティカルセクション内では、counter
変数が安全にインクリメントされます。他のゴルーチンは、mutex.Lock()
が呼び出された時点でロックを取得しようとしますが、すでに別のゴルーチンがロックを取得しているため、ブロックされます。ロックが解放されるまで、他のゴルーチンはクリティカルセクションに入ることができません。
main
関数では、5つのゴルーチンを起動し、各ゴルーチンが10回incrementCounter
関数を呼び出します。WaitGroup
を使用して、すべてのゴルーチンが終了するまで待機します。
2.2 RWMutex:読み込み/書き込みロック
RWMutex
は、Mutex
の拡張版であり、読み込みロックと書き込みロックを区別して使用できます。複数のゴルーチンが同時に共有リソースを読み込む必要があるが、書き込みを行うゴルーチンは排他的にアクセスする必要がある場合に、RWMutex
が有効です。
RWMutex
は、以下のメソッドを持ちます。
RLock()
: 読み込みロックを取得します。複数のゴルーチンが同時に読み込みロックを取得できます。RUnlock()
: 読み込みロックを解放します。Lock()
: 書き込みロックを取得します。書き込みロックを取得すると、他のゴルーチンは読み込みロックも書き込みロックも取得できなくなります。Unlock()
: 書き込みロックを解放します。
“`go
package main
import (
“fmt”
“sync”
“time”
)
var (
data map[string]string
rwMutex sync.RWMutex
)
func readData(key string) string {
rwMutex.RLock() // 読み込みロックを取得
defer rwMutex.RUnlock() // 関数終了時に読み込みロックを解放
value := data[key]
fmt.Printf("Read: Key=%s, Value=%s (Goroutine ID: %v)\n", key, value, getGoroutineID())
time.Sleep(time.Millisecond * 50) // 処理をシミュレート
return value
}
func writeData(key, value string) {
rwMutex.Lock() // 書き込みロックを取得
defer rwMutex.Unlock() // 関数終了時に書き込みロックを解放
data[key] = value
fmt.Printf("Write: Key=%s, Value=%s (Goroutine ID: %v)\n", key, value, getGoroutineID())
time.Sleep(time.Millisecond * 100) // 処理をシミュレート
}
func getGoroutineID() int {
var buf [64]byte
runtime.Stack(buf[:], false)
idField := strings.Fields(strings.TrimPrefix(string(buf[:]), “goroutine “))[0]
id, err := strconv.Atoi(idField)
if err != nil {
panic(fmt.Sprintf(“cannot get goroutine id: %v”, err))
}
return id
}
func main() {
data = make(map[string]string)
var wg sync.WaitGroup
wg.Add(6)
// リーダーゴルーチン
for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done()
for j := 0; j < 3; j++ {
readData(fmt.Sprintf("key%d", i))
}
}(i)
}
// ライターゴルーチン
go func() {
defer wg.Done()
for i := 0; i < 2; i++ {
writeData(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
}
}()
wg.Wait() // すべてのゴルーチンが終了するまで待機
fmt.Println("Data:", data)
}
“`
このコードでは、data
というmap[string]string
型の共有変数を複数のゴルーチンから読み書きしています。rwMutex
というsync.RWMutex
型の変数が、data
へのアクセスを保護するために使用されています。
readData
関数では、rwMutex.RLock()
を呼び出して読み込みロックを取得します。複数のゴルーチンが同時にreadData
関数を呼び出した場合、すべてが同時に読み込みロックを取得できます。
writeData
関数では、rwMutex.Lock()
を呼び出して書き込みロックを取得します。書き込みロックを取得すると、他のゴルーチンは読み込みロックも書き込みロックも取得できなくなります。これにより、data
への書き込みが排他的に行われます。
main
関数では、5つのリーダーゴルーチンと1つのライターゴルーチンを起動します。リーダーゴルーチンはreadData
関数を呼び出してデータを読み込み、ライターゴルーチンはwriteData
関数を呼び出してデータを書き込みます。
2.3 Mutex/RWMutexの注意点
- ロックの解放忘れ:
Lock
メソッドを呼び出した場合は、必ずUnlock
メソッドを呼び出してロックを解放する必要があります。ロックを解放し忘れると、他のゴルーチンが永久にブロックされる可能性があります。defer
ステートメントを使用して、関数終了時に確実にロックが解放されるようにすることをお勧めします。 - デッドロック: 複数の
Mutex
やRWMutex
を使用する場合、デッドロックが発生する可能性があります。デッドロックとは、複数のゴルーチンがお互いのロックの解放を待ち、永久に処理が進まなくなる状態です。デッドロックを回避するためには、ロックの取得順序を常に一定にするなどの対策が必要です。 - 再帰的なロック: 一般的に、
Mutex
やRWMutex
は再帰的なロックをサポートしていません。つまり、同じゴルーチンが同じMutex
やRWMutex
を複数回ロックしようとすると、デッドロックが発生します。 - パフォーマンス:
Mutex
やRWMutex
を使用すると、並行処理のパフォーマンスが低下する可能性があります。ロックの競合が発生すると、ゴルーチンの実行がブロックされ、CPUの利用効率が低下するためです。Mutex
やRWMutex
は、必要な場合にのみ使用し、クリティカルセクションをできるだけ短くすることが重要です。 - Read-Preferring RWMutex: 標準のRWMutexは、書き込みゴルーチンが到着すると、すべてのリーダーゴルーチンが完了するまで、新しいリーダーゴルーチンがロックを取得することをブロックします。場合によっては、リーダーゴルーチンを優先するRWMutexの方が適切な場合があります。標準ライブラリには含まれていませんが、サードパーティライブラリで提供されている場合があります。
3. WaitGroup:ゴルーチンの完了待ち
WaitGroup
は、複数のゴルーチンの完了を待機するための同期プリミティブです。WaitGroup
は、カウンターを持ち、Add
、Done
、Wait
という3つのメソッドを持ちます。
Add(delta int)
: カウンターにdelta
を加算します。通常は、起動するゴルーチンの数だけカウンターを加算します。Done()
: カウンターを1減算します。ゴルーチンが完了した際に呼び出します。Wait()
: カウンターが0になるまでブロックされます。カウンターが0になると、Wait
メソッドは復帰します。
3.1 WaitGroupの基本的な使い方
“`go
package main
import (
“fmt”
“sync”
“time”
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // ゴルーチン終了時にカウンターを減算
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second * time.Duration(id)) // 処理をシミュレート
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
wg.Add(5) // カウンターを5に設定
for i := 1; i <= 5; i++ {
go worker(i, &wg) // ゴルーチンを起動
}
wg.Wait() // すべてのゴルーチンが終了するまで待機
fmt.Println("All workers done")
}
“`
このコードでは、5つのワーカゴルーチンを起動し、各ワーカゴルーチンが処理を実行した後、完了を通知します。WaitGroup
を使用して、すべてのワーカゴルーチンが完了するまでmain
関数が待機するようにします。
main
関数では、まずwg.Add(5)
を呼び出して、カウンターを5に設定します。次に、5つのゴルーチンを起動し、各ゴルーチンにworker
関数を実行させます。worker
関数では、defer wg.Done()
を呼び出して、ゴルーチンが終了する際にカウンターを1減算します。最後に、wg.Wait()
を呼び出して、カウンターが0になるまで待機します。
3.2 WaitGroupの注意点
- AddとDoneの不一致:
Add
メソッドで設定したカウンターの値と、Done
メソッドで減算する回数が一致しない場合、予期せぬ問題が発生する可能性があります。Done
の回数がAdd
の値より少ない場合、Wait
メソッドが永久にブロックされる可能性があります。Done
の回数がAdd
の値より多い場合、panicが発生する可能性があります。
- Waitの複数回呼び出し:
Wait
メソッドは、カウンターが0になるまでブロックされます。カウンターが0になった後、再度Wait
メソッドを呼び出すと、すぐに復帰します。 - Addのタイミング:
Add
メソッドは、ゴルーチンを起動する前に呼び出す必要があります。ゴルーチンを起動した後にAdd
メソッドを呼び出すと、ゴルーチンがDone
メソッドを呼び出す前にWait
メソッドが復帰してしまう可能性があります。 - コピーによる問題:
WaitGroup
はゼロ値で初期化される構造体であり、コピーによって渡されると、意図しない動作を引き起こす可能性があります。特に、ゴルーチンにWaitGroup
を渡す場合は、必ずポインタ (*sync.WaitGroup
) を使用して渡すようにしてください。
4. Once:一度だけの初期化
Once
は、特定の処理を一度だけ実行するための同期プリミティブです。通常、遅延初期化(Lazy Initialization)などのシナリオで使用されます。Once
は、Do
というメソッドを持ち、引数として関数を受け取ります。Do
メソッドは、引数として渡された関数を一度だけ実行します。
4.1 Onceの基本的な使い方
“`go
package main
import (
“fmt”
“sync”
“time”
)
var (
initialized bool
once sync.Once
)
func initialize() {
fmt.Println(“Initializing…”)
time.Sleep(time.Second * 2) // 初期化処理をシミュレート
initialized = true
fmt.Println(“Initialization complete”)
}
func main() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(i int) {
defer wg.Done()
once.Do(initialize) // 初期化処理を一度だけ実行
fmt.Printf("Worker %d: initialized = %v\n", i, initialized)
}(i)
}
wg.Wait() // すべてのゴルーチンが終了するまで待機
}
“`
このコードでは、initialize
関数が一度だけ実行されることを保証するために、sync.Once
型のonce
変数が使用されています。複数のゴルーチンが同時にonce.Do(initialize)
を呼び出した場合、最初のゴルーチンだけがinitialize
関数を実行し、他のゴルーチンはinitialize
関数の実行が完了するまでブロックされます。initialize
関数の実行が完了すると、ブロックされていたゴルーチンは復帰し、initialized
変数の値を確認します。
4.2 Onceの注意点
- 関数の引数と戻り値:
Once.Do
に渡される関数は引数を受け取らず、戻り値を返しません。もし、初期化処理の結果を他のゴルーチンに共有する必要がある場合は、共有変数を使用する必要があります。 - panic:
Once.Do
に渡された関数がpanicを発生させた場合、Once
はエラー状態となり、以降のOnce.Do
の呼び出しもpanicを発生させます。 - 再初期化:
Once
は、特定の処理を一度だけ実行することを保証するものであり、再初期化を行うことはできません。もし、再初期化が必要な場合は、別のOnce
変数を新たに作成する必要があります。
5. より高度な同期プリミティブ
sync
パッケージには、Mutex
、WaitGroup
、Once
以外にも、様々な同期プリミティブが用意されています。
- Cond: 条件変数。特定の条件が満たされるまでゴルーチンを待機させることができます。
- Pool: オブジェクトの再利用を可能にするプール。メモリ割り当てのオーバーヘッドを削減できます。
- Map: 複数のゴルーチンから安全にアクセスできるマップ。
これらの同期プリミティブは、より複雑な並行処理のシナリオで使用されます。
6. まとめ
本稿では、Go言語のsync
パッケージに含まれるMutex
、WaitGroup
、Once
という3つの重要な同期プリミティブについて、その使い方を詳細に解説しました。
Mutex
は、複数のゴルーチンが共有リソースに同時にアクセスすることを防ぐための相互排他ロックを提供します。WaitGroup
は、複数のゴルーチンの完了を待機するための同期プリミティブです。Once
は、特定の処理を一度だけ実行するための同期プリミティブです。
これらの同期プリミティブを理解し使いこなすことで、Go言語の並行処理をより深く理解し、安全かつ効率的な並行処理プログラムを開発することができるようになります。並行処理は複雑なテーマであり、常に注意深く設計する必要があります。本稿が、Go言語の並行処理を学ぶ上で役立つことを願っています。