GoのMapと「concurrent map writes」エラー

1. 概要

MapはGoで最も頻繁に使用されるデータ構造で、sliceに次ぐものです。一方、複数のgoroutineが同時にアクセスする場合、Mapは安全ではありません。複数のgoroutineからMapに書き込むと、プログラムはfatal error: concurrent map writesというメッセージで完全に停止する可能性があります。

この記事では、Mapの基本的な操作をおさらいします。その後、このエラーが発生する理由、race detectorで検出する方法、そして3つの修正方法について説明します:sync.Mutex、sync.RWMutex、sync.Map。サンプルはGo 1.22以降を使用します。

2. GoのMap:覚えておくべきこと

2.1. 基本操作

package main

import "fmt"

func main() {
	stock := map[string]int{"apple": 10, "banana": 5}
	stock["orange"] = 7

	qty, ok := stock["grape"]
	fmt.Println(qty, ok)

	delete(stock, "banana")
	fmt.Println(len(stock))
}

説明:

  • map[string]int{...}はkeyがstring型、valueがint型のmapを作成します。
  • stock["grape"]はkeyが存在しない場合、zero value(0)を返します。変数okはkeyが実際にmapに存在するかどうかを教えてくれます。
  • deleteはkeyを削除します。存在しないkeyでdeleteを呼び出してもエラーは発生しません。
0 false
2

2.2. Nil map

var m map[K]Vと宣言して初期化しないと、nil mapが得られます。nil mapから読むことはできますが、書き込むとpanicが発生します:

package main

func main() {
	var orders map[string]int
	_ = orders["A-001"] // OK、0を返す
	orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map

修正は常にmake(map[string]int)またはliteral map[string]int{}でmapを初期化することです。このエラーはmapがstructのfieldであり、constructorで初期化を忘れたときによく発生します。

2.3. Mapは参照型、反復順序はランダム

mapを別の変数に割り当てたり、関数に渡すと、両方が基礎となる同じデータ構造を指します。したがって、2つのgoroutineが「同じmap」を受け取ると、実際には同じメモリ領域を共有しています。これが次のセクションの問題の根本です。

さらに、for k, v := range mの反復順序はruntimeによって意図的にシャッフルされます。その順序に依存するコードやテストを書かないでください。固定された順序が必要な場合は、keyを取り出して、ソートしてから反復してください。

3. Concurrent map writesエラー

3.1. エラーの再現

ユーザーごとのアクセス数をカウントしており、各requestは異なるgoroutineで実行されると想定します:

package main

import (
	"fmt"
	"sync"
)

func main() {
	visits := make(map[string]int)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				visits[fmt.Sprintf("user-%d", j%10)]++
			}
		}()
	}

	wg.Wait()
	fmt.Println(len(visits))
}

数回実行すると、ほぼ確実に以下が表示されます:

fatal error: concurrent map writes

goroutine 23 [running]:
main.main.func1()
	/tmp/visits/main.go:17 +0x...
...
exit status 2

行visits[...]++は1回の読み取りと1回の書き込みで構成されています。100個のgoroutineが同じことを行うと、複数のgoroutineが同時にmapの内部構造(buckets、expand処理)を修正する可能性があります。Goのruntimeはシンプルなチェックメカニズムがあります:mapは「書き込み中」フラグを保持しています。goroutineが書き込みを開始するときにそのフラグがセットされているのを見ると、runtimeはプログラムを即座に停止します。

別のgoroutineが書き込んでいる間にgoroutineが読む場合に、同様なバリエーションがあります:

fatal error: concurrent map read and map write

3.2. なぜrecoverできないのか

多くの人がrecoverでコードをラップしようとします:

go func() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered:", r)
		}
	}()
	visits["user-1"]++
}()

この方法は効果がありません。concurrent map writesはpanicではありません。これはruntimeがthrowを呼び出すfatal errorであり、プロセス全体を終了します。recoverはそれを捕捉できません。この選択は意図的です:mapが破損したら、実行を続けることは停止するより危険です。

注意: runtimeの検出メカニズムはあくまで「best-effort」です。プログラムがクラッシュしないからといって、コードが安全とは限りません。2つのgoroutineが衝突するちょうどそのタイミングにまだ遭遇していないだけで、データは静かに破損しているかもしれません。

3.3. Race detectorで早期に検出

より信頼できるツールはrace detectorで、-raceフラグで有効にできます:

go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000... by goroutine 8:
  runtime.mapaccess1_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...

Previous write at 0x00c000... by goroutine 7:
  runtime.mapassign_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...
==================

Race detectorは競合する読み取りと書き込みの行を正確に指摘します。CIでテストを実行するときは-raceを有効にするべきです。-raceで実行するプログラムは遅く、メモリをより多く消費するため、通常、productionビルドにはこのフラグを使用しません。

4. 最初の修正方法:sync.Mutex

最も一般的で理解しやすい方法は、mapをmutexを持つstructで包装し、methodを通じてのみmapへのアクセスを許可することです:

package visits

import (
	"maps"
	"sync"
)

type VisitCounter struct {
	mu     sync.Mutex
	counts map[string]int
}

func NewVisitCounter() *VisitCounter {
	return &VisitCounter{counts: make(map[string]int)}
}

func (c *VisitCounter) Inc(user string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[user]++
}

func (c *VisitCounter) Get(user string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.counts[user]
}

func (c *VisitCounter) Snapshot() map[string]int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return maps.Clone(c.counts)
}

説明:

  • Field countsは小文字で始まるため、package外のコードは直接mapにアクセスできません。
  • IncとGetの両方がmutexをロックするため、いかなる時点でも1つのgoroutineだけがmapを操作します。
  • Snapshotはmaps.Cloneでコピーを返します(Go 1.21以降)。直接c.countsを返すと、呼び出し元はロックなしでそのmapを反復することになり、元のエラーに戻ります。
  • Constructor NewVisitCounterはmapを初期化し、セクション2.2のnil mapエラーを避けます。

4.1. -raceでテストを書く

Goは JUnitやAssertJを使用しません。標準のtestingパッケージはこの例で十分です:

package visits

import (
	"sync"
	"testing"
)

func TestVisitCounter_ConcurrentInc(t *testing.T) {
	counter := NewVisitCounter()
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter.Inc("alice")
			}
		}()
	}
	wg.Wait()

	if got := counter.Get("alice"); got != 100_000 {
		t.Fatalf("Get(alice) = %d, want 100000", got)
	}
}
go test -race ./...
ok  	example.com/visits	1.214s

テストは2つのことをチェックします。第1に、結果は正確に100,000であり、つまりインクリメントが失われません。第2に、race detectorがDATA RACEを報告しません。Incのc.mu.Lock()を削除して再度実行すると、テストはすぐに失敗します。

4.2. Mutexに関する一般的なエラー

Mutexを含むstructのコピー。 Value receiver(例えばfunc (c VisitCounter) Inc(...))でmethodを宣言すると、methodを呼び出すたびにmutexのコピーが使用されます。コピー上のロックは何も保護しません。常にpointer receiverを使用してください。go vetはこれをチェックcopylocksを通じて警告します:

./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex

ロックを長すぎる時間保持する。 ロックを保持しながらHTTP呼び出し、データベースクエリ、またはファイル書き込みを行わないでください。必要なデータを取得して、ロックを解放してから、遅い処理を実行します。

Check-then-actが分離されている。 以下のコードには2つの別々のロックがあるため、その間に別のgoroutineが介入する可能性があります:

if counter.Get("bob") == 0 {
	counter.Inc("bob") // 別のgoroutineが先に増加させた可能性があります
}

「存在しなければ作成」ロジックが必要な場合は、1つのmethodとして書き、method全体でロックを保持します。

5. 2番目の修正方法:sync.RWMutex

構成キャッシュや商品価格テーブルなど、読み取りが非常に多いが書き込みはまれなデータの場合、sync.RWMutexは複数のgoroutineが並行して読むことを許可します:

package pricing

import "sync"

type PriceTable struct {
	mu     sync.RWMutex
	prices map[string]int64
}

func NewPriceTable() *PriceTable {
	return &PriceTable{prices: make(map[string]int64)}
}

func (p *PriceTable) Price(sku string) (int64, bool) {
	p.mu.RLock()
	defer p.mu.RUnlock()
	v, ok := p.prices[sku]
	return v, ok
}

func (p *PriceTable) SetPrice(sku string, cents int64) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.prices[sku] = cents
}

RLockは複数のgoroutineが同時に読むことを許可します。Lockは排他的ロックです:すべてのreaderが完了するのを待ち、書き込みが完了するまで新しいreaderをブロックします。

注意: RWMutexが常にMutexより高速とは限りません。それ自体が高い管理オーバーヘッドを持っています。書き込み比率が大きい場合、またはロック内のコードが非常に短い場合、Mutexは通常、同等またはより高速です。変更する前にbenchmarkで測定してください。

よくある誤りは、p.mu.RLock()を呼び出してから、別のmethodもRLockを呼び出すことです。その間にwriterが待機していると、プログラムはdeadlockする可能性があります。理由は、RWMutexがwriterがキューに入っている場合、新しいreaderをブロックするからです。

6. 3番目の修正方法:sync.Map

syncパッケージにsync.Mapがあります。これはロック管理を必要としないconcurrency-safe mapです:

package main

import (
	"fmt"
	"sync"
)

type Session struct {
	UserID string
}

func main() {
	var sessions sync.Map

	sessions.Store("token-abc", Session{UserID: "alice"})

	actual, loaded := sessions.LoadOrStore("token-abc", Session{UserID: "bob"})
	fmt.Println(actual.(Session).UserID, loaded)

	if v, ok := sessions.Load("token-abc"); ok {
		fmt.Println(v.(Session).UserID)
	}

	sessions.Range(func(key, value any) bool {
		fmt.Println(key, value.(Session).UserID)
		return true
	})
}
alice true
alice
token-abc alice

説明:

  • Storeはkey/valueペアを書き込みます。
  • LoadOrStoreはatomic操作です。keyが既に存在する場合、methodは古い値を返し、loaded = trueであり、上書きされません。これはsync.Mapでcheck-then-actを処理する正しい方法です。
  • Rangeはmapを反復します。コールバック関数は早期に停止するためにfalseを返します。

sync.Mapには知っておくべき制限があります:

  • keyとvalueはany型であり、compile時のtype checkが失われ、至る所でtype assertionが必要です。
  • Len()がありません。要素をカウントするには、Rangeで反復する必要があります。
  • sync.Mapは2つの特定のケースに最適化されています。1つは、各keyが1回書き込まれてから何度も読み取られます。例えば、追加のみのキャッシュ。2つは、goroutineが重複しないkeyのセットで読み書きする場合です。

注意: 上記の2つのケース以外では、MutexまたはRWMutex付きの通常のmapは、ほとんどの場合、より高速で、読みやすく、型安全です。sync.Mapですべてのmapをデフォルトで置き換えないでください。

7. どのソリューションを選ぶ?

シナリオ 推奨される選択肢
読み書きが混在、条件付きロジック map + sync.Mutex
読み取りが大部分、書き込みは稀 map + sync.RWMutex(事前計測)
キャッシュは追加のみ、またはgoroutineごとに異なるkeyセット sync.Map
Mapは1つのgoroutineでのみ使用 通常のmap、ロック不要

別の方法があります:1つのgoroutineだけがmapを所有し、他のgoroutineはchannelを通じてリクエストを送信します。この方法は、map更新が既存の処理ループに関連している場合に適しています。シンプルなカウンターでは、この方法はmutexより冗長で、明らかな利点がありません。

読み取り専用mapについての追加の注意:mapが各goroutineが開始される前に初期化され、その後誰も書き込まない場合、複数のgoroutineが同時に読み取ることは安全であり、ロックは必要ありません。

8. 結論

GoのMapは高速で便利ですが、concurrencyに対して安全ではありません。複数のgoroutineが同時に書き込む場合、または読み書きが混在する場合、runtimeはfatal error: concurrent map writesでプログラムを停止する可能性があります。このエラーはrecoverできません。プログラムがクラッシュしないからといってコードが正しいわけではないため、go test -raceでテストを実行してdata raceを早期に検出してください。

ほとんどの場合、sync.Mutexを持つstructでmapを包装し、pointer receiverのmethodでアクセスし、元のmap代わりにコピーを返すだけで十分です。読み取り負荷が本当に圧倒的な場合はsync.RWMutexを使用し、それが設計されている正確なシナリオにのみsync.Mapを使用してください。

golangmapconcurrencygoroutinesync