sync.Mapでロック付きのmapを使用する

Go Advent Calender 2017(その4)の11日目の記事です。

Golangの並行処理では、共有リソースにアクセスする場合は競合が発生しないようにロックを用意する必要がある。 例えば、標準のmapはgoroutine safeではない。Write同士が競合した場合や、Write中のReadが発生するとPanicを起こしてしまう。

そのため、従来はmapとmutexを併用することでロックを用意し、複数goroutineから安全にアクセスできるようにする必要があった。 Go1.9ではsync.Mapが標準パッケージとなり、自身でRWMutexを備えたマップを書くことなく、ロック付きのマップを使用することができる。

どのように使うか

以下コードの通り(Playground)。

package main

import (
	"fmt"
	"sync"
)

func main() {
	// Mapを作成
	m := sync.Map{}

	// 保存
	m.Store("hoge", "fuga")

	// 取得
	// 標準のMap同様、キーが存在しなければok == falseとなる
	if v, ok := m.Load("hoge"); ok {
		fmt.Printf("hoge: %s\n", v) // hoge: fuga
	}

	// 新規キーの場合は登録+取得
	// 既存キーの場合は取得のみ(loaded == trueとなる)
	actual, loaded := m.LoadOrStore("hoge", "piyo")
	fmt.Printf("hoge: %s, loaded=%t\n", actual, loaded) // hoge: fuga, loaded=true

	// マップ中の全要素を参照する
	// 引数で渡している関数がfalseを返すとRangeを終了する
	m.Range(func(k, v interface{}) bool {
		fmt.Printf("key: %s, value: %s\n", k, v) // key: hoge, value: fuga
		return true
	})

	// 削除
	m.Delete("hoge")
}

簡潔で使用しやすいが、一方で注意すべき点もある。

func (m *Map) Store(key, value interface{})

上記の通り、keyとvalueはinterface{}であり、実質map[interface{}]interface{}として動作する。 つまりtype-unsafeのため、想定していない型のStoreをコンパイラでチェックすることができない。 避けられるなら避けた方が良い。 特にチームで開発している場合、気付いた時には崩壊している可能性もある。

どのように使うべきか

最も安全に使うには、sync.Mapを構造体に埋め込み、型指定のStore/Loadメソッドを用意する。 パッケージも分けることで、想定外の値を入れられることは無くなる。

package tankmap

import (
	"sync"
)

type Map struct {
	sm sync.Map
}

func (m *Map) Load(key string) (string, bool) {
	val, ok := m.sm.Load(key)
	if !ok {
		return "", false
	}
	return val.(string), true
}

func (m *Map) Store(key, value string) {
	m.sm.Store(key, value)
}

func New() *Map {
	return &Map{}
}

しかし、上記のようなwrapperを書くのであれば、プリミティブなmapとmutexを併用する方がシンプルである。 では、sync.Mapを使う意義は何か。どこで使うべきなのか。

どこで使うべきか

sync.Mapは単純なロックを備えたmapではなく、内部ではdirtyとreadonlyの2つのmap[interface{}]*entryを使用している。 dirtyは書き込み用のmapで、Storeを実行するとmutexでロックした上で値を更新する。 この時、readonlyの更新は発生しない。

type entry struct {
	p unsafe.Pointer
}

従来のmapと異なる点はreadonlyで、Loadを実行するとreadonlyをロックせずに参照する。 ロック処理が不要な分、高速に値を返すことができる。 パッケージに添付されているベンチマークではmap+mutexとの比較がされており、Loadについてはsync.Mapの方が2〜3倍高速なことが確認できた。

BenchmarkLoadMostlyHits/*sync_test.RWMutexMap-4          	20000000	       102 ns/op	       7 B/op	       0 allocs/op
BenchmarkLoadMostlyHits/*sync.Map-4                      	30000000	      47.5 ns/op	       7 B/op	       0 allocs/op
BenchmarkLoadMostlyMisses/*sync_test.RWMutexMap-4        	20000000	      90.5 ns/op	       7 B/op	       0 allocs/op
BenchmarkLoadMostlyMisses/*sync.Map-4                    	30000000	      35.7 ns/op	       7 B/op	       0 allocs/op

ただし、readonlyにkeyが存在しなかった場合(dirtyへ新たに追加されたkeyをLoadする場合)、dirtyをロックして参照するため通常のmapと比べて参照が遅くなる。 そして、dirtyの参照回数がlen(dirty)に達した時、readonlyは破棄され、dirtyが新たなreadonlyとなる。

適用できるケースは少ないと思うが、Load回数が多く、Store回数が少ない傾向のmapをsync.Mapに差し替えることで、パフォーマンスの改善が期待できる。