Go: Goの同期プリミティブ:Mutex/RWMutex、sync.Once、sync.Map、atomic、レース検出…
チャネルはゴルーチン間の通信に使用され、
syncパッケージは共有状態を保護するために使用されます。Go は並行プログラミングのための包括的なツールキットを提供しています。
複数のゴルーチンが同じ変数から読み込んだり、その変数に書き込んだりする必要がある場合、安全性を確保するために同期プリミティブが必要となります。このレッスンでは、Goのsyncパッケージの主要なツールと選択戦略についてすべて学びます。
1. 学習内容
sync.Mutexミューテックスsync.RWMutex読み書き用ミューテックス(読み取りは非排他的)sync.Once: 1回実行sync.Map: 並行処理に安全なマップsync/atomic原子操作- レース検出器(
-raceフラグ) sync.Pool一時オブジェクトプール- 同期プリミティブの選択戦略
2. 高同時実行環境を担当するエンジニアの実話
(1) 問題:100個のgoroutineが同時にカウンタに書き込みを行うため、データが完全に破損してしまう
チャーリーは、あるECプラットフォームのバックエンドエンジニアで、1秒あたりの注文数をカウントする必要があります:
「100個のgoroutineが同時に注文を処理しており、各goroutineは注文を完了するたびにカウンターを1ずつ増やしていました。サービス開始後の初日、データに大きな乖離が生じました。ダッシュボードには1秒あたり1,000件の注文が表示されていたのに、データベースには300件しか記録されていなかったのです。上司から『残りの700件分の注文代金はどこへ消えたんだ?』と問いただされました。」
彼はコードを開いた:
// Bad code: concurrent write to shared variable, no lock
var counter int
func processOrder(orderID string) {
// ... process order
counter++ // Non-atomic operation! Equivalent to:
// temp = counter
// temp = temp + 1
// counter = temp <- 3 goroutines executing this step simultaneously = chaos
}
counter++ はアトミックな操作ではありません。これは、読み取り、1 増分、書き込みという 3 つのステップに分解されます。もし 2 つのゴルーチンが同時に counter=10 を読み取り、1 増分し、それを書き戻した場合、結果は 12 ではなく counter=11 になってしまいます。これは データ競合 の典型的な例です。
(2) Go言語での解決策:ミューテックス
// counter.go
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
c.value++ // Protected by the lock; only one goroutine can execute at a time
c.mu.Unlock()
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
var counter SafeCounter
var wg sync.WaitGroup
// 100 goroutines incrementing simultaneously
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Printf("Final value: %d (expected 100)\n", counter.Value())
}
出力:
Final value: 100 (expected 100)
(3) リターン:ロック状態とロック解除状態
| 状況 | 結果 | データ競合 |
|---|---|---|
| ロックフリーの並行処理 | 95/100/97 (ランダム) | ✅ はい (レースコンディションが検出されました) |
| ミューテックス保護 | 100 | ❌ なし |
| 原子操作 | 100 | ❌ なし |
go run -race main.go はデータ競合を検出できます。共有変数への並行アクセスがロックによって保護されていない場合、-race オプションは実行時に警告を出力します。CI/CD では、常に -race を有効にすることをお勧めします。
3. sync.Mutex ミューテックス
(1) ミューテックスの基礎
package main
import (
"fmt"
"sync"
)
type Account struct {
mu sync.Mutex
balance float64
}
func (a *Account) Deposit(amount float64) {
a.mu.Lock()
a.balance += amount
a.mu.Unlock()
}
func (a *Account) Withdraw(amount float64) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return fmt.Errorf("insufficient balance: have %.2f, need %.2f", a.balance, amount)
}
a.balance -= amount
return nil
}
func (a *Account) Balance() float64 {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}
func main() {
acc := Account{balance: 1000}
var wg sync.WaitGroup
// 10 concurrent deposits
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
acc.Deposit(100)
}()
}
wg.Wait()
fmt.Printf("Balance: %.2f\n", acc.Balance())
}
▶ サンプル:ロック解除パターンの延期(推奨)
// Recommended: Lock then immediately defer Unlock
func (a *Account) SafeMethod() {
a.mu.Lock()
defer a.mu.Unlock()
// All operations are within the lock
a.balance += 100
a.balance -= 50
// Even if there is a panic in between, defer ensures Unlock
}
// Not recommended: manual Unlock
func (a *Account) UnsafeMethod() {
a.mu.Lock()
a.balance += 100
a.mu.Unlock() // If there is a return or panic in between, Unlock won't be called → deadlock
}
Lock の後には、必ず Unlock を呼び出す必要があります。 最後に手動で Unlock を呼び出すのではなく、常に defer a.mu.Unlock() を使用してください。複数の戻りパスがある場合、そのいずれかで Unlock の呼び出しを忘れると、デッドロックが発生します。
4. sync.RWMutex 読み書きロック
(1) 読み書きロックの原理
package main
import (
"fmt"
"sync"
"time"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}
// Read operation: RLock (concurrent reads, non-exclusive)
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
// Write operation: Lock (exclusive, blocks all reads and writes)
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func main() {
cache := NewCache()
var wg sync.WaitGroup
// Multiple concurrent reads (RLock is non-exclusive)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 3; j++ {
if v, ok := cache.Get("key"); ok {
fmt.Printf("Reader %d: %s\n", id, v)
}
time.Sleep(10 * time.Millisecond)
}
}(i)
}
// One writer
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(5 * time.Millisecond)
cache.Set("key", "value")
fmt.Println("Writer: write complete")
}()
wg.Wait()
}
▶ サンプル:ミューテックスとRWMutexのパフォーマンス比較
package main
import (
"sync"
"testing"
)
type DataStore struct {
mu sync.RWMutex
value int
}
func BenchmarkMutexRead(b *testing.B) {
store := DataStore{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
store.mu.Lock()
_ = store.value
store.mu.Unlock()
}
})
}
func BenchmarkRWMutexRead(b *testing.B) {
store := DataStore{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
store.mu.RLock()
_ = store.value
store.mu.RUnlock()
}
})
}
// go test -bench=. -benchmem
(3) ミューテックスとRWMutexの比較
| 機能 | ミューテックス | RWMutex |
|---|---|---|
| 読み取りと読み取り | 排他 | 非排他 |
| 読み書き | 排他制御 | 排他制御 |
| 書き込みと書き込み | 排他 | 排他 |
| ユースケース | 読み書き比率 1:1 | 読み取りが書き込みを大幅に上回る(例:キャッシュ) |
| 読み取り性能 | 低速(シリアル化) | 高速(並列読み取り) |
5. sync.Once:1回だけ実行する
(1) 基本を一度
package main
import (
"fmt"
"sync"
)
var (
config map[string]string
configOnce sync.Once
)
func loadConfig() {
configOnce.Do(func() {
fmt.Println("Initializing config...")
config = map[string]string{
"host": "localhost",
"port": "8080",
}
})
}
func main() {
var wg sync.WaitGroup
// 100 goroutines call simultaneously, but init executes only once
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
loadConfig()
}()
}
wg.Wait()
fmt.Printf("config: %v\n", config)
}
出力:
Initializing config...
config: map[host:localhost port:8080]
(2) sync.Once 対 手動フラグ
| メソッド | スレッドセーフ | コードサイズ |
|---|---|---|
if !initialized { ... } |
❌ データ競合 | 軽微 |
sync.Once |
✅ Goの保証 | 最小限 |
init() 関数 |
✅ パッケージの読み込み時に実行される | 最小限 |
sync.Once を使用すると、たとえ 1,000 の goroutine から同時に呼び出されたとしても、Do 内の関数が 1 回だけ実行されることが保証されます。その内部実装ではアトミック操作とミューテックスが使用されており、非常に効率的です。
6. sync.Map:並行処理に安全なマップ
(1) sync.Map の基本
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
var wg sync.WaitGroup
// Concurrent writes
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m.Store(fmt.Sprintf("key-%d", n), n*100)
}(i)
}
wg.Wait()
// Read
m.Range(func(key, value interface{}) bool {
fmt.Printf("%s = %d\n", key, value)
return true
})
// LoadOrStore: returns if exists, stores if not
actual, loaded := m.LoadOrStore("key-0", 999)
fmt.Printf("LoadOrStore: actual=%d, loaded=%v\n", actual, loaded)
}
▶ サンプル:sync.Map 対 map+Mutex
package main
import (
"sync"
"testing"
)
// map + Mutex
type MutexMap struct {
mu sync.Mutex
items map[string]int
}
func (m *MutexMap) Store(key string, value int) {
m.mu.Lock()
m.items[key] = value
m.mu.Unlock()
}
func (m *MutexMap) Load(key string) (int, bool) {
m.mu.Lock()
v, ok := m.items[key]
m.mu.Unlock()
return v, ok
}
func BenchmarkMutexMap(b *testing.B) {
m := &MutexMap{items: make(map[string]int)}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Store("key", 1)
m.Load("key")
}
})
}
func BenchmarkSyncMap(b *testing.B) {
var m sync.Map
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Store("key", 1)
m.Load("key")
}
})
}
// go test -bench=. -benchmem
(3) sync.Map のユースケース
| シナリオ | 推奨事項 | 理由 |
|---|---|---|
| キーは1回のみ書き込み | sync.Map | 読み取り最適化(書き込みは少なく、読み取りが多い) |
| 頻繁なキーの更新 | map+Mutex | sync.Map の書き込みがわずかに遅い |
| キーセットの拡大 | sync.Map | マップ全体のロックを避ける |
| シンプルなシナリオ | マップ+ミューテックス | より直感的 |
| 高性能要件 | 特定のベンチマーク | 実際のテスト結果に基づく |
7. sync/atomic:アトミック操作
(1) 原子の基礎
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var counter int64
// Atomic increment
newVal := atomic.AddInt64(&counter, 1)
fmt.Printf("AddInt64: %d\n", newVal)
// Atomic read
val := atomic.LoadInt64(&counter)
fmt.Printf("LoadInt64: %d\n", val)
// Atomic write
atomic.StoreInt64(&counter, 100)
// CAS (Compare And Swap)
swapped := atomic.CompareAndSwapInt64(&counter, 100, 200)
fmt.Printf("CAS: swapped=%v, val=%d\n", swapped, atomic.LoadInt64(&counter))
}
▶ サンプル:アトミックカウンタとミューテックス
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Increment() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
type MutexCounter struct {
mu sync.Mutex
value int
}
func (c *MutexCounter) Increment() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *MutexCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
var wg sync.WaitGroup
n := 100000
// Atomic counter
atomicStart := time.Now()
var ac AtomicCounter
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ac.Increment()
}()
}
wg.Wait()
fmt.Printf("Atomic: %d (%v)\n", ac.Value(), time.Since(atomicStart))
// Mutex counter
muStart := time.Now()
var mc MutexCounter
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mc.Increment()
}()
}
wg.Wait()
fmt.Printf("Mutex: %d (%v)\n", mc.Value(), time.Since(muStart))
}
(3) 一般的な原子関数
| 操作 | 機能 | 目的 |
|---|---|---|
| 加算/減算 | AddInt64, AddUint32 |
カウンタ |
| 読む | LoadInt64, LoadPointer |
安全に読む |
| 書き込み | StoreInt64, StorePointer |
安全な書き込み |
| CAS | CompareAndSwapInt64 |
オプティミスティックロック |
| スワップ | SwapInt64 |
アトミックスワップ |
8. レース検出器
▶ サンプル:データ競合の検出
// race_example.go
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // Data race!
}()
}
wg.Wait()
fmt.Println(counter)
}
$ go run -race race_example.go
==================
WARNING: DATA RACE
Read at 0x00c0000... by goroutine X
main.main.func1()
race_example.go:14 +0x...
Previous write at 0x00c0000... by goroutine Y
main.main.func1()
race_example.go:14 +0x...
==================
9. sync.Pool オブジェクトプール
▶ サンプル:オブジェクトプール
package main
import (
"fmt"
"sync"
)
type User struct {
Name string
Age int
}
var userPool = sync.Pool{
New: func() interface{} {
return &User{}
},
}
func main() {
// Get from pool (avoids allocating a new object each time)
u := userPool.Get().(*User)
u.Name = "Alice"
u.Age = 28
fmt.Printf("Using: %+v\n", u)
// Put back into the pool after use
userPool.Put(u)
// Next Get reuses the previous object
u2 := userPool.Get().(*User)
fmt.Printf("Reusing: %+v\n", u2)
}
10. 完全な例:高並行性のカウンタ
// concurrent_counter.go
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// ---------- 3 counter variants ----------
type Counter interface {
Increment()
Value() int64
}
// AtomicCounter
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Increment() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
// MutexCounter
type MutexCounter struct {
mu sync.Mutex
value int64
}
func (c *MutexCounter) Increment() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *MutexCounter) Value() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
// ChannelCounter
type ChannelCounter struct {
ch chan struct{}
value int64
done chan struct{}
}
func NewChannelCounter() *ChannelCounter {
c := &ChannelCounter{
ch: make(chan struct{}, 100),
done: make(chan struct{}),
}
go func() {
for range c.ch {
c.value++
}
close(c.done)
}()
return c
}
func (c *ChannelCounter) Increment() {
c.ch <- struct{}{}
}
func (c *ChannelCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
func (c *ChannelCounter) Close() {
close(c.ch)
<-c.done
}
// ---------- Benchmark ----------
func benchmarkCounter(c Counter, goroutines, increments int) time.Duration {
var wg sync.WaitGroup
start := time.Now()
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
c.Increment()
}
}()
}
wg.Wait()
return time.Since(start)
}
func main() {
const (
goroutines = 100
increments = 10000
)
fmt.Printf("Benchmark: %d goroutines, each executing %d Increments\n\n", goroutines, increments)
// 1. Atomic counter
ac := &AtomicCounter{}
atomicTime := time.Now()
atomicDone := make(chan struct{})
go func() {
defer close(atomicDone)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
ac.Increment()
}
}()
}
wg.Wait()
}()
<-atomicDone
fmt.Printf("AtomicCounter: %d (expected %d) — %v\n",
ac.Value(), goroutines*increments, time.Since(atomicTime))
// 2. Mutex counter
mc := &MutexCounter{}
mutexTime := time.Now()
mutexDone := make(chan struct{})
go func() {
defer close(mutexDone)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
mc.Increment()
}
}()
}
wg.Wait()
}()
<-mutexDone
fmt.Printf("MutexCounter: %d (expected %d) — %v\n",
mc.Value(), goroutines*increments, time.Since(mutexTime))
// 3. Channel counter
cc := NewChannelCounter()
channelTime := time.Now()
channelDone := make(chan struct{})
go func() {
defer close(channelDone)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
cc.Increment()
}
}()
}
wg.Wait()
cc.Close()
}()
<-channelDone
fmt.Printf("ChannelCounter: %d (expected %d) — %v\n",
cc.Value(), goroutines*increments, time.Since(channelTime))
fmt.Println("\nConclusion: Atomic > Mutex > Channel (performance from high to low)")
}
sequenceDiagram
participant G1 as Goroutine 1
participant G2 as Goroutine 2
participant G3 as Goroutine 3
participant M as Mutex
G1->>M: Lock()
Note over G1,M: G1 holds the lock
G2->>M: Lock() blocked
G3->>M: Lock() blocked
G1->>M: Unlock()
M-->>G2: wake up
M-->>G3: still blocked
G2->>M: Lock() success
Note over G2,M: G2 holds the lock
G2->>M: Unlock()
M-->>G3: wake up
G3->>M: Lock() success
*Counter)。
❓ よくある質問
sync.Once はどのように使いますか?sync.Once を使用すると、たとえ 1,000 の goroutine が同時に呼び出したとしても、f が 1 回だけ実行されることが保証されます。代表的な用途:設定の遅延読み込み、シングルトンパターン、および一度だけ初期化が必要なリソースなどです。内部的には、アトミック操作とミューテックスを使用して実装されています。sync.Map はどのような場面に適していますか?atomic はどのような場面に適していますか?atomicはミューテックスよりも10~100倍高速ですが、int、uint、pointerなどの基本型でのみ使用可能です。複雑なロジックの場合は、代わりにミューテックスを使用してください。go run -race main.go または go test -race ./... を使用します。これは実行時にデータレースを検出します。2つのgoroutineが同時に同じ変数にアクセスし、そのうち少なくとも1つが書き込み操作である場合、警告が表示されます。CI/CDでは、この機能を有効にしておくことをお勧めします。sync.Pool はどのような場合に使用すべきですか?sync.Mutexは再入不可能なロックです。同じgoroutineがLockを連続して2回呼び出すと、デッドロックが発生します。同じゴルーチン内で再度ロックを取得する必要がある場合は、再帰呼び出しで sync.Mutex を使用するようにコードをリファクタリングするか、defer を使用してロックが適切に解放されるようにしてください。Lock のみを、読み取り操作には RLock のみを使用し、これらを混在させないでください。📖 まとめ
- ミューテックス:
Lock/Unlock、1:1の読み書き比率に適しています - RWMutex 読み書きロック:並行読み取りの場合は
RLock、排他書き込みの場合はLock - sync.Once:
Do(f)は、この関数が一度だけ実行されることを保証します - sync.Map: 読み込みが頻繁で書き込みが少ないシナリオ向けの、並行処理に安全なMap
- 同期/アトミック:ロックフリーカウンター、CASによる楽観的ロック
- 競合検出機能:
-raceフラグはデータ競合を検出します - sync.Pool: ガベージコレクタの負荷を軽減するための一時オブジェクトのプール
- 選択:atomic > Mutex > RWMutex > Channel(パフォーマンスの高い順)
📝 練習問題
-
基本問題(難易度 ⭐):ミューテックスを使用して、
map[string]intへの並行読み取りおよび書き込みを保護してください。データを書き込むためのライター・ゴルーチン 10 個と、データを読み取るためのリーダー・ゴルーチン 10 個を起動し、レースコンディションが発生しないことを確認してください(-raceテストに合格すること)。 -
上級問題(難易度 ⭐⭐):4種類のカウンタ(アトミック、ミューテックス、RWMutex、チャネル)のパフォーマンスを比較します。10,000回のインクリメント操作を実行する100個のgoroutineについて、所要時間を測定し、その正しさを検証してください。
-raceを使用して、データ競合が発生していないことを確認してください。 -
課題(難易度 ⭐⭐⭐):読み取りが多く、書き込みが少ない高並行性のキャッシュを実装してください。要件:(1) 保護に RWMutex を使用すること;(2) Get/Set/Delete/Range 操作をサポートすること;(3) TTL による有効期限切れをサポートすること(
time.Now().After(expireAt)); (4) 書き込み操作中はすべての読み取り操作をブロックするが、並行した読み取り操作は許可すること;(5)-raceテストに合格すること。