mapの競合状態のはなし
目次
mapの競合状態とは何か?
GoのmapはあるgoroutineでReadしているときに別のgoroutineからWriteしてはいけないというルールがある。
もし違反するとpanicを起こす。
このことはgo1.6のリリースノートに書いてある(他の出典を探したけど見つけられなかった・・)
Go 1.6 Release Notes - The Go Programming Language
if one goroutine is writing to a map, no other goroutine should be reading or writing the map concurrently. If the runtime detects this condition, it prints a diagnosis and crashes the program.
# あるgoroutineがmapに書き込んでいるとき、別のgoroutineが同じmapを読んだり書いたりするとランタイムはこの競合状態を検出します。
# そして、その旨を表示してプログラムをクラッシュさせます。
だから、こういうプログラムを書いて動かすとパニックで異常終了する。
package main import ( "fmt" "math/rand" ) func main() { m := map[int]struct{}{} go func() { for { m[rand.Intn(100000)] = struct{}{} } }() for { fmt.Println(m[rand.Intn(100000)]) } }
実行結果
fatal error: concurrent map read and map write
単にキーを渡して読み取るだけでなく、rangeでmapをループしてもダメである。
package main import ( "fmt" "math/rand" ) func main() { m := map[int]struct{}{} go func() { for { m[rand.Intn(100000)] = struct{}{} } }() for { for k, v := range m { fmt.Println(k, v) } } }
実行結果
fatal error: concurrent map iteration and map write
どうしたらいいのか?
sync.RWMutexを使う
syncパッケージのRWMutexを使ってmapへアクセスする前にロックを取ればこの問題を回避できる。
先ほどのプログラムでmapに要素を入れたり、要素を取り出す前後にロックの取得と解放を行うよう改めると、パニックを起こさなくなる。
[このプログラムは途中で異常終了しないので、やめるときはCtrl+Cで落とす]
package main import ( "fmt" "math/rand" "sync" ) func main() { m := map[int]struct{}{} var mu sync.RWMutex go func() { for { mu.Lock() m[rand.Intn(100000)] = struct{}{} mu.Unlock() } }() for { mu.RLock() v, ok := m[rand.Intn(100000)] fmt.Println(v, ok) // {} true と {} false が混ざって表示されるが、mから要素を消さないので次第に {} true ばっかり表示されるようになる mu.RUnlock() } }
共有ロックと排他ロック
RWMutexはLock/Unlock(排他ロック)とRLock/RUnlock(共有ロック)のロック用メソッドがある。
このため、Lockの呼び出し時にLockないしRLockが既に呼ばれていた場合、そのgoroutineはロックの解放を待つ。
しかし、RLockの呼び出し時はLockが呼ばれていない限り待たずに処理を先へ進める。
sync.Mapを使う
syncパッケージのMapを使うとこの競合状態に対策してRead/Writeしてくれる。
ロックの取得と解放処理はプログラムの状態に気を使って書かなければならない(=難しい)ので、mapを置き換えたいだけならこっちの方がお手軽である。
元のプログラムでmapを使っていた部分をsync.Mapに取り替えると今度はパニックを起こさない。
[このプログラムも、やめるときはCtrl+Cで落とす]
package main import ( "fmt" "math/rand" "sync" ) func main() { var m sync.Map go func() { for { m.Store(rand.Intn(100000), struct{}{}) } }() for { fmt.Println(m.Load(rand.Intn(100000))) } }
atomic.Valueを使う
sync/atomicを使う方法もある。
例えば、mapに格納するデータは定期的にMySQLのテーブルを全件取得して丸ごと入れ替えるといった方式をとる場合、sync.Mapではなくatomic.Valueにmapを入れても安全にデータを読み書きできる(以下)。
[このプログラムも、やめるときはCtrl+Cで落とす]
package main import ( "fmt" "math/rand" "sync/atomic" ) func selectFromMySQL() map[int]struct{} { // データの全取っ替え。 // ここではランダムにやっているけど、実際はバッチが定期的に更新するMySQLのテーブルをSELECTすると思ってください。 size := rand.Intn(100) m := make(map[int]struct{}, size) for i := 0; i < size; i++ { m[i] = struct{}{} } return m } func main() { var at atomic.Value at.Store(selectFromMySQL()) go func() { for { at.Store(selectFromMySQL()) } }() for { m := at.Load().(map[int]struct{}) // Loadはinterface{}型を返すので型アサーションが必要 v, ok := m[50] fmt.Println(v, ok) // {} true と {} false が混ざって表示される } }
ロックとCompare and swap
なお、RWMutexやsync.Mapはロックで状態を守っているが、atomic.ValueはCompare and swap(CAS)という違う方法で守っているらしい。
さいごに 感想
ここまで説明されると「ふーん」という感じで特に感慨もないが、実際はmapの競合状態に起因するバグを仕込んでしまうと発覚や修正が難しい。
例えばmapをインメモリキャッシュとして使うサーバーを作ってこのような問題を起こすバグがあっても
- mapの競合状態を検出してサーバーがクラッシュする
- 監視ツールがサーバープロセスのダウンを検知して再起動させる
- サーバー再起動する
- またmapの競合状態が発生し、クラッシュする
- 監視ツールが再起動させる… この繰り返し
こういう風に監視ツールが再起動させるよう設定が入っていたりすると、単純にサーバーがダウンするんじゃなくて、クラッシュ通知も来ないし裏では再起動を繰り返してサーバーの応答性能が劇落ちしてるといった判断の難しい状況に陥ることもある(実際あった)。
サーバーを複数台立てて冗長化しているとなおさら問題に気付きにくくなる。
だから競合状態(Race condition)はこわい。
サンプルコードはここに置いておきました。ちょっと雑だけど・・
https://github.com/oyakata/gooooolang/tree/master/maprace
最後までお読みくださりありとうございました。