Goの共有mapで同時書き込みが起きる実装をどうレビューするか

Goの map は、設定値、キャッシュ、集計結果、接続状態の保持などでよく使われる。
単一goroutine内で閉じていれば扱いやすいが、複数goroutineから読み書きされるmap はレビューで早めに止めたい。

危ないのは次のような実装だ。

  • package変数の map を複数handlerやworkerから更新している
  • 書き込みだけmutexで守り、読み取りが素通りしている
  • GetAll() が内部mapをそのまま返している
  • goroutine内の集計結果を共有mapへ直接書き込んでいる
  • sync.Map を使っているが、選択理由や値の型契約が読めない

この記事では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
}
Comment
@Reviewer: package変数のmapを複数リクエストから直接読み書きしているため、同時アクセス時にデータ競合や `concurrent map writes` が起きる可能性があります。mapの所有者を構造体に閉じ込め、読み書きの排他方針を明示してください。

このコードは、小さなキャッシュとして見ると自然に見える。
しかしHTTP handlerやworkerから呼ばれるなら、同時に SaveSessionGetSession が動く可能性がある。

レビューでは「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),
    }
}
Comment
@Reviewer: mapがpackage変数として露出しているため、更新経路を追い切れません。mapと排他制御を同じ構造体にまとめ、操作メソッド経由で扱う設計にしてください。

mapの所有者が明確なら、レビューアーは「このmapを守る責務はどこにあるか」を追いやすい。

2. 読み取りと書き込みの両方が同じ方針で守られているか

書き込みだけ Lock し、読み取りはそのままという実装はよくある。
しかし同じmapに対して書き込みがあり得るなら、読み取り側も同じ排他方針に入れる必要がある。

読み書きを同じmutexで守る例
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
}
Comment
@Reviewer: 書き込み側だけmutexで保護されていますが、読み取り側が同じmapをロックなしで参照しています。読み取りも `RLock` に入れ、mapアクセスの方針を統一してください。

レビューでは、Lock が存在するかだけでは不十分である。
同じ共有資源への全アクセスが同じ規約に従っているかを見る。

3. 内部mapをそのまま返していないか

getterが内部mapを返すと、呼び出し側がロック外で読み書きできてしまう。
この時点で、構造体内にmutexを置いた意味が崩れる。

内部mapを漏らす実装
func (s *SessionStore) All() map[string]Session {
    return s.sessions
}
Comment
@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の契約を分ける。

共有mapの責務を閉じた実装
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 があるだけで安全と判断しない。

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
}
Comment
@Reviewer: `sync.Map` の採用理由が読めません。キーごとに独立した高頻度アクセスなのか、通常のmapとmutexで十分なのか、値の型契約を含めて説明してください。

sync.Map は、読み取り中心、キーごとの独立性が高い、キャッシュ的に使う、といった特性に合う場合は選択肢になる。
一方で、型安全性が薄くなり、複数キーをまとめて一貫更新する処理には向きにくい。

レビューでは、次を確認したい。

  • 通常の map + mutex ではなく sync.Map にする理由があるか
  • Load 後の型アサーション失敗が起きない契約になっているか
  • 複数キーをまたぐ整合性を期待していないか
  • Range の結果に強い一貫性を期待していないか

goroutine集計では所有者を1つにする

複数goroutineで集計する処理では、共有mapへ直接書き込むより、結果をchannelで集めて単一goroutineがmapを更新する方が読みやすいこともある。

goroutineから共有mapへ直接書く実装
counts := map[string]int{}

for _, item := range items {
    go func(item Item) {
        counts[item.Category]++
    }(item)
}
Comment
@Reviewer: 複数goroutineから同じmapへ直接書き込んでいます。mutexで守るか、各goroutineの結果をchannelで集約し、map更新を単一goroutineに閉じてください。

map更新の所有者を1つにすると、排他制御そのものを減らせる場合がある。

集計結果を単一goroutineで反映する例
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レビューの確認項目
  • mapの所有者が構造体や単一goroutineに閉じているか
  • 読み取りと書き込みが同じ排他方針に従っているか
  • 内部mapをそのまま返していないか
  • Snapshot などコピー返却の契約が明示されているか
  • sync.Map の採用理由と値の型契約が説明できるか
  • 複数キーをまたぐ一貫性をmap単体に期待していないか
  • race detectorで並行アクセスのテストを確認できるか

まとめ

Goの map は便利だが、共有状態になると急にレビュー難度が上がる。
問題はmapそのものではなく、所有者、排他方針、返却契約が曖昧なまま広がることである。

レビューでは、mapsync.Map に置き換えるかどうかの前に、
そのmapを誰が読み書きしてよいのかをコード上の構造として確認したい。