今川館

都内勤務の地味OLです

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(共有ロック)のロック用メソッドがある。

共有ロックとは - IT用語辞典 e-Words

このため、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)という違う方法で守っているらしい。

コンペア・アンド・スワップ - Wikipedia

さいごに 感想

ここまで説明されると「ふーん」という感じで特に感慨もないが、実際はmapの競合状態に起因するバグを仕込んでしまうと発覚や修正が難しい。

例えばmapをインメモリキャッシュとして使うサーバーを作ってこのような問題を起こすバグがあっても

  • mapの競合状態を検出してサーバーがクラッシュする
  • 監視ツールがサーバープロセスのダウンを検知して再起動させる
  • サーバー再起動する
  • またmapの競合状態が発生し、クラッシュする
  • 監視ツールが再起動させる… この繰り返し

こういう風に監視ツールが再起動させるよう設定が入っていたりすると、単純にサーバーがダウンするんじゃなくて、クラッシュ通知も来ないし裏では再起動を繰り返してサーバーの応答性能が劇落ちしてるといった判断の難しい状況に陥ることもある(実際あった)。

サーバーを複数台立てて冗長化しているとなおさら問題に気付きにくくなる。

だから競合状態(Race condition)はこわい。

サンプルコードはここに置いておきました。ちょっと雑だけど・・

https://github.com/oyakata/gooooolang/tree/master/maprace


最後までお読みくださりありとうございました。