Goの共有mapで同時書き込みが起きる実装をどうレビューするか
Goの共有mapで同時書き込みが起きる実装をどうレビューするか
Goの map は、設定値、キャッシュ、集計結果、接続状態の保持などでよく使われる。
単一goroutine内で閉じていれば扱いやすいが、複数goroutineから読み書きされるmap はレビューで早めに止めたい。
危ないのは次のような実装だ。
- package変数の
mapを複数handlerやworkerから更新している - 書き込みだけmutexで守り、読み取りが素通りしている
GetAll()が内部mapをそのまま返している- goroutine内の集計結果を共有mapへ直接書き込んでいる
sync.Mapを使っているが、選択理由や値の型契約が読めない
この記事ではmapの文法ではなく、
mapの所有者と同時アクセスの境界がコードから読めるかをレビューで確認する観点を整理する。
まず止めたい実装
var userSessions = map[string]Session{}
func SaveSession(userID string, session Session) {
userSessions[userID] = session
}
func GetSession(userID string) (Session, bool) {
session, ok := userSessions[userID]
return session, ok
}@Reviewer: package変数のmapを複数リクエストから直接読み書きしているため、同時アクセス時にデータ競合や `concurrent map writes` が起きる可能性があります。mapの所有者を構造体に閉じ込め、読み書きの排他方針を明示してください。このコードは、小さなキャッシュとして見ると自然に見える。
しかしHTTP handlerやworkerから呼ばれるなら、同時に SaveSession と GetSession が動く可能性がある。
レビューでは「mapに値を入れられるか」ではなく、
誰がそのmapを所有し、どの経路から変更できるのかを見る必要がある。
なぜ危ないのか
Goの通常の map は、複数goroutineからの同時読み書きを前提にしたデータ構造ではない。
同時書き込みが重なると実行時に落ちることがあり、読み書き競合はrace detectorで検出される対象にもなる。
実務では次の問題につながりやすい。
- 本番の並行リクエストでだけ落ちる
- テストでは再現せず、負荷時だけ壊れる
- 読み取り側が古い値や途中状態を見る
- mapを返した呼び出し側が勝手に更新する
- ロックの範囲が関数ごとにばらつき、修正時に漏れる
特に危ないのは、最初は単一goroutine用途だったmapが、後からhandler、batch、goroutineに共有されるケースである。
レビューでは、現在の呼び出し元だけでなく、共有されやすい形になっていないかを確認したい。
レビューで見たい3つの判断線
1. mapの所有者が構造体に閉じているか
package変数のmapは、どこからでも触れるようになりやすい。
少なくとも、mapとmutexを同じ構造体に置き、操作メソッドを通してアクセスさせたい。
type SessionStore struct {
mu sync.RWMutex
sessions map[string]Session
}
func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]Session),
}
}@Reviewer: mapがpackage変数として露出しているため、更新経路を追い切れません。mapと排他制御を同じ構造体にまとめ、操作メソッド経由で扱う設計にしてください。mapの所有者が明確なら、レビューアーは「このmapを守る責務はどこにあるか」を追いやすい。
2. 読み取りと書き込みの両方が同じ方針で守られているか
書き込みだけ Lock し、読み取りはそのままという実装はよくある。
しかし同じmapに対して書き込みがあり得るなら、読み取り側も同じ排他方針に入れる必要がある。
func (s *SessionStore) Save(userID string, session Session) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[userID] = session
}
func (s *SessionStore) Get(userID string) (Session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[userID]
return session, ok
}@Reviewer: 書き込み側だけmutexで保護されていますが、読み取り側が同じmapをロックなしで参照しています。読み取りも `RLock` に入れ、mapアクセスの方針を統一してください。レビューでは、Lock が存在するかだけでは不十分である。
同じ共有資源への全アクセスが同じ規約に従っているかを見る。
3. 内部mapをそのまま返していないか
getterが内部mapを返すと、呼び出し側がロック外で読み書きできてしまう。
この時点で、構造体内にmutexを置いた意味が崩れる。
func (s *SessionStore) All() map[string]Session {
return s.sessions
}@Reviewer: 内部mapをそのまま返すと、呼び出し側がロック外で更新できてしまいます。必要な値だけ返すか、ロック内でコピーしたmapを返してください。一覧が必要なら、ロック中にコピーを作って返す。
func (s *SessionStore) Snapshot() map[string]Session {
s.mu.RLock()
defer s.mu.RUnlock()
copied := make(map[string]Session, len(s.sessions))
for userID, session := range s.sessions {
copied[userID] = session
}
return copied
}コピー返却なら、呼び出し側が返却値を変更しても内部状態は壊れない。
ただし Session 自体がポインタやsliceを含むなら、浅いコピーで十分かも追加で確認する。
改善例
共有mapを構造体に閉じ、読み書きとsnapshotの契約を分ける。
type SessionStore struct {
mu sync.RWMutex
sessions map[string]Session
}
func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]Session),
}
}
func (s *SessionStore) Save(userID string, session Session) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[userID] = session
}
func (s *SessionStore) Get(userID string) (Session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[userID]
return session, ok
}
func (s *SessionStore) Delete(userID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, userID)
}
func (s *SessionStore) Snapshot() map[string]Session {
s.mu.RLock()
defer s.mu.RUnlock()
copied := make(map[string]Session, len(s.sessions))
for userID, session := range s.sessions {
copied[userID] = session
}
return copied
}この構造なら、レビューアーは次を確認しやすい。
- mapの所有者が
SessionStoreに閉じている - 読み取りと書き込みが同じmutexで守られている
- 内部mapが直接外へ出ていない
- 削除も同じ操作境界に入っている
- snapshotは呼び出し側が自由に扱える値として返っている
sync.Map を使えばよいとは限らない
共有mapの競合を見つけると、すぐ sync.Map に置き換えたくなることがある。
しかしレビューでは、sync.Map があるだけで安全と判断しない。
var sessions sync.Map
func SaveSession(userID string, session Session) {
sessions.Store(userID, session)
}
func GetSession(userID string) (Session, bool) {
value, ok := sessions.Load(userID)
if !ok {
return Session{}, false
}
return value.(Session), true
}@Reviewer: `sync.Map` の採用理由が読めません。キーごとに独立した高頻度アクセスなのか、通常のmapとmutexで十分なのか、値の型契約を含めて説明してください。sync.Map は、読み取り中心、キーごとの独立性が高い、キャッシュ的に使う、といった特性に合う場合は選択肢になる。
一方で、型安全性が薄くなり、複数キーをまとめて一貫更新する処理には向きにくい。
レビューでは、次を確認したい。
- 通常の
map + mutexではなくsync.Mapにする理由があるか Load後の型アサーション失敗が起きない契約になっているか- 複数キーをまたぐ整合性を期待していないか
Rangeの結果に強い一貫性を期待していないか
goroutine集計では所有者を1つにする
複数goroutineで集計する処理では、共有mapへ直接書き込むより、結果をchannelで集めて単一goroutineがmapを更新する方が読みやすいこともある。
counts := map[string]int{}
for _, item := range items {
go func(item Item) {
counts[item.Category]++
}(item)
}@Reviewer: 複数goroutineから同じmapへ直接書き込んでいます。mutexで守るか、各goroutineの結果をchannelで集約し、map更新を単一goroutineに閉じてください。map更新の所有者を1つにすると、排他制御そのものを減らせる場合がある。
type CountResult struct {
Category string
}
results := make(chan CountResult)
for _, item := range items {
go func(item Item) {
results <- CountResult{Category: item.Category}
}(item)
}
counts := map[string]int{}
for i := 0; i < len(items); i++ {
result := <-results
counts[result.Category]++
}この設計では、mapを書き換えるgoroutineが1つに限定される。
レビューでは、mutexを足す前に、共有しない構造へ戻せないかも確認したい。
レビュー観点チェックリスト
- mapの所有者が構造体や単一goroutineに閉じているか
- 読み取りと書き込みが同じ排他方針に従っているか
- 内部mapをそのまま返していないか
Snapshotなどコピー返却の契約が明示されているかsync.Mapの採用理由と値の型契約が説明できるか- 複数キーをまたぐ一貫性をmap単体に期待していないか
- race detectorで並行アクセスのテストを確認できるか
まとめ
Goの map は便利だが、共有状態になると急にレビュー難度が上がる。
問題はmapそのものではなく、所有者、排他方針、返却契約が曖昧なまま広がることである。
レビューでは、map を sync.Map に置き換えるかどうかの前に、
そのmapを誰が読み書きしてよいのかをコード上の構造として確認したい。