Goのsyncパッケージ入門:Mutex、WaitGroup、Onceの使い方

Go言語 syncパッケージ入門:Mutex、WaitGroup、Once の使い方

Go言語は並行処理に優れた言語であり、その強力な並行処理機能は、効率的なサーバー構築、データ処理、分散システム開発などを可能にします。syncパッケージは、Go言語の並行処理を支える重要なパッケージの一つであり、複数のゴルーチン間でのデータの同期や、特定の処理の実行制御などを実現するための様々なプリミティブを提供しています。本稿では、syncパッケージの中でも特に重要なMutexWaitGroupOnceに焦点を当て、その使い方を詳細に解説します。これらのプリミティブを理解し使いこなすことで、Go言語の並行処理をより深く理解し、安全かつ効率的な並行処理プログラムを開発することができるようになります。

1. 並行処理における同期の必要性

並行処理とは、複数の処理を同時に実行することを指します。Go言語では、軽量なスレッドであるゴルーチンを用いることで、容易に並行処理を実現できます。しかし、複数のゴルーチンが同じリソース(変数、ファイル、ネットワーク接続など)に同時にアクセスする場合、予期せぬ問題が発生する可能性があります。

例えば、複数のゴルーチンが同時にカウンター変数をインクリメントする場合を考えてみましょう。各ゴルーチンは以下の手順でカウンターをインクリメントするとします。

  1. カウンターの現在の値を読み込む。
  2. 読み込んだ値に1を加算する。
  3. 加算した値をカウンターに書き込む。

もし、複数のゴルーチンが同時に同じ値を読み込んだ場合、それぞれが1を加算し、書き込むことになります。しかし、最後に書き込まれた値が有効となり、他のゴルーチンのインクリメントは失われてしまいます。これを競合状態(Race Condition)と呼びます。

競合状態は、データの破損、プログラムのクラッシュ、予測不能な動作など、様々な問題を引き起こす可能性があります。そのため、並行処理を行う際には、複数のゴルーチンが共有リソースに安全にアクセスできるように、同期(Synchronization)を行う必要があります。

同期とは、複数のゴルーチンの実行順序を調整し、共有リソースへのアクセスを制御することで、競合状態を回避する仕組みです。syncパッケージは、この同期を実現するための様々なプリミティブを提供しています。

2. Mutex:相互排他ロック

Mutex(Mutual Exclusion)は、最も基本的な同期プリミティブの一つであり、複数のゴルーチンが共有リソースに同時にアクセスすることを防ぐための相互排他ロックを提供します。

Mutexは、LockUnlockという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ステートメントを使用して、関数終了時に確実にロックが解放されるようにすることをお勧めします。
  • デッドロック: 複数のMutexRWMutexを使用する場合、デッドロックが発生する可能性があります。デッドロックとは、複数のゴルーチンがお互いのロックの解放を待ち、永久に処理が進まなくなる状態です。デッドロックを回避するためには、ロックの取得順序を常に一定にするなどの対策が必要です。
  • 再帰的なロック: 一般的に、MutexRWMutexは再帰的なロックをサポートしていません。つまり、同じゴルーチンが同じMutexRWMutexを複数回ロックしようとすると、デッドロックが発生します。
  • パフォーマンス: MutexRWMutexを使用すると、並行処理のパフォーマンスが低下する可能性があります。ロックの競合が発生すると、ゴルーチンの実行がブロックされ、CPUの利用効率が低下するためです。MutexRWMutexは、必要な場合にのみ使用し、クリティカルセクションをできるだけ短くすることが重要です。
  • Read-Preferring RWMutex: 標準のRWMutexは、書き込みゴルーチンが到着すると、すべてのリーダーゴルーチンが完了するまで、新しいリーダーゴルーチンがロックを取得することをブロックします。場合によっては、リーダーゴルーチンを優先するRWMutexの方が適切な場合があります。標準ライブラリには含まれていませんが、サードパーティライブラリで提供されている場合があります。

3. WaitGroup:ゴルーチンの完了待ち

WaitGroupは、複数のゴルーチンの完了を待機するための同期プリミティブです。WaitGroupは、カウンターを持ち、AddDoneWaitという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パッケージには、MutexWaitGroupOnce以外にも、様々な同期プリミティブが用意されています。

  • Cond: 条件変数。特定の条件が満たされるまでゴルーチンを待機させることができます。
  • Pool: オブジェクトの再利用を可能にするプール。メモリ割り当てのオーバーヘッドを削減できます。
  • Map: 複数のゴルーチンから安全にアクセスできるマップ。

これらの同期プリミティブは、より複雑な並行処理のシナリオで使用されます。

6. まとめ

本稿では、Go言語のsyncパッケージに含まれるMutexWaitGroupOnceという3つの重要な同期プリミティブについて、その使い方を詳細に解説しました。

  • Mutexは、複数のゴルーチンが共有リソースに同時にアクセスすることを防ぐための相互排他ロックを提供します。
  • WaitGroupは、複数のゴルーチンの完了を待機するための同期プリミティブです。
  • Onceは、特定の処理を一度だけ実行するための同期プリミティブです。

これらの同期プリミティブを理解し使いこなすことで、Go言語の並行処理をより深く理解し、安全かつ効率的な並行処理プログラムを開発することができるようになります。並行処理は複雑なテーマであり、常に注意深く設計する必要があります。本稿が、Go言語の並行処理を学ぶ上で役立つことを願っています。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール