Day 014 — sync.Mutex / RWMutex(コードレビュー編)

2026-08-11 🔵 中級者 / Phase 2 デバッグ・コードレビュー sync.Mutex / RWMutex

📚 背景知識(読んでから問題へ)

前回(Day 013)はsync.Mutex/sync.RWMutex自分でゼロから実装しました。今回は視点を変え、他人が書いたロック処理のレビューをします。実務でMutexが絡むバグは、書いた本人がその場で気づけないことがほとんどです。コンパイラは型エラーを検出してくれますが、「ロックの解放漏れ」「ロックの取り直しによるデッドロック」「構造体ごとのコピー」「保護されていない読み取り」はすべてコンパイルは通るのに実行時にだけ問題が起きるバグだからです。だからこそ、コードレビューでこれらのパターンを見抜く訓練が実務で強く役立ちます。

今回の問題は、Day 013の「よくある誤解・ミス」で扱った4つのパターンを、実際に動く(動かない)1つのプログラムに埋め込んだものです。

  • ロックの解放漏れ: Lock()した後、エラーで早期returnする経路にUnlock()が書かれていない。deferを使わずに手動でUnlock()する設計は、分岐が増えるたびに漏れのリスクが上がる
  • RLock保持中のLock取得(自己デッドロック): RLock()している最中に、同じgoroutineが(別メソッド経由で間接的にでも)Lock()を呼ぶと、「自分が解放するはずのRLockを、自分が待っている」という状態に陥り永久にブロックする
  • 構造体ごとの値渡し(Mutexのコピー): sync.Mutex/sync.RWMutexを含む構造体を値として関数に渡すと、ロックの状態までコピーされる。コピーされたロックは元の変数のロックとは別物になり、排他制御が意味を失う(go vetのcopylocksがこれを検出できる)
  • 保護されていない読み取り: 「読むだけだから安全」という思い込みで、ロックなしで直接mapを読む

これらは個別に見れば単純ですが、複数人が触るコードベースでは別々のメソッドに分散して混入するため、レビュー時に「このメソッドが呼ばれている間、他に何が起きているか」を頭の中で並行に追う訓練が必要です。

📝 問題

以下は、在庫を管理するInventory型のコードです。作者は「Mutex/RWMutexで排他制御したつもり」ですが、4箇所に別々の種類のバグがあります。

⚠️
期待する動作: mainを実行すると、20個のgoroutineが並行して在庫を予約し、その後在庫が閾値を下回っていれば自動補充し、最後に安全に在庫のスナップショットを表示して正常に終了する。
🔥
実際の動作: このプログラムを実際にgo run main.goすると、必ずfatal error: all goroutines are asleep - deadlock!でクラッシュします(Goランタイムが「全goroutineが進行不能」を検出して自ら終了させるため、たまたま起きるのではなく毎回確実に再現します)。さらに、この最初のクラッシュを直しただけでは全ては終わりません。コード中にはもう2つ、実行時には表面化しにくい潜在バグが残っています(1つはgo vetが静的に検出でき、もう1つは呼び出しタイミング次第でしか表面化しません)。

バグのあるコード:

package main

import (
	"fmt"
	"sync"
)

type Inventory struct {
	mu    sync.RWMutex
	stock map[string]int
}

func NewInventory() *Inventory {
	return &Inventory{stock: make(map[string]int)}
}

// Reserve は item を qty 個だけ予約(減算)する。
func (inv *Inventory) Reserve(item string, qty int) error {
	inv.mu.Lock()
	current, ok := inv.stock[item]
	if !ok {
		return fmt.Errorf("item not found: %s", item)
	}
	if current < qty {
		inv.mu.Unlock()
		return fmt.Errorf("insufficient stock: %s", item)
	}
	inv.stock[item] = current - qty
	inv.mu.Unlock()
	return nil
}

// CheckAndRestock は在庫が threshold を下回っていたら addQty 補充する。
func (inv *Inventory) CheckAndRestock(item string, threshold, addQty int) {
	inv.mu.RLock()
	defer inv.mu.RUnlock()
	if inv.stock[item] < threshold {
		inv.Restock(item, addQty)
	}
}

// Restock は item を qty 個補充する。
func (inv *Inventory) Restock(item string, qty int) {
	inv.mu.Lock()
	defer inv.mu.Unlock()
	inv.stock[item] += qty
}

// PrintSnapshot は在庫の状態を出力する。
func PrintSnapshot(inv Inventory) {
	inv.mu.RLock()
	defer inv.mu.RUnlock()
	fmt.Println(inv.stock)
}

// Peek は item の現在の在庫数だけを覗き見る(読み取り専用のつもり)。
func (inv *Inventory) Peek(item string) int {
	return inv.stock[item]
}

func main() {
	inv := NewInventory()
	inv.stock["widget"] = 10
	inv.stock["gadget"] = 3

	var wg sync.WaitGroup
	for i := 0; i < 20; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := inv.Reserve("gizmo", 1); err != nil { // "gizmo" は在庫に存在しないアイテム
				return
			}
		}()
	}
	wg.Wait()

	inv.CheckAndRestock("gadget", 5, 20)

	fmt.Println("widget stock:", inv.Peek("widget"))
	PrintSnapshot(*inv)
}

要件:

  1. コード中に埋め込まれた4つのバグをすべて特定してください。それぞれについて「どのメソッドの、どの行が問題か」「実行するとどのような不具合が起きるか(ハング/デッドロック・データ競合・panicのどれに繋がるか)」を説明してください
  2. 4つのバグをすべて修正し、go run main.goが正常終了し、かつgo run -race main.goでdata raceが検出されない、完全な1つのプログラムを作成してください
  3. 修正したコードに対してgo vet ./...(またはgo vet main.go)を実行し、修正前は検出されていた警告が消えていることを確認してください

go run main.go / go run -race main.go / go vet のいずれでも問題なく動作する、完全な1つのプログラムとして提出してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

4つのバグは、それぞれ独立して探せます。Reserveは「Lockした後、すべてのreturnの直前でUnlockされているか」を1行ずつ追ってください。CheckAndRestockは「RLockを保持したまま、内部で呼び出している別のメソッドが何をしているか」に注目してください。PrintSnapshotは「引数の型が値なのかポインタなのか」を確認してください。Peekは「mapへのアクセスの前後に何が書かれているか(何も書かれていないか)」を確認してください。

ヒント2 — アプローチ
  • Reserve: Lock()の直後にdefer inv.mu.Unlock()を置けば、以降のどのreturn経路でも確実に解放されます。手動でのUnlock()呼び出しは全て削除できます
  • CheckAndRestock: sync.RWMutexには「RLockからLockへの昇格(upgrade)」という機能は存在しません。RLockを保持したままLockを取ろうとすると、自分自身が保持しているRLockの解放を待つ形になり永久にブロックします。この関数は条件次第で書き込みが起きうるので、そもそも最初からRLockではなくLockを取るべきです(読み取り専用ではない)
  • PrintSnapshot: 引数を*Inventory(ポインタ)に変え、呼び出し側もPrintSnapshot(inv)*invではなく)に変更します
  • Peek: 他の読み取りメソッドと同様、RLock()/RUnlock()stockへのアクセスを保護します
ヒント3 — コード骨格
package main

import (
	"fmt"
	"sync"
)

type Inventory struct {
	mu    sync.RWMutex
	stock map[string]int
}

func NewInventory() *Inventory {
	return &Inventory{stock: make(map[string]int)}
}

func (inv *Inventory) Reserve(item string, qty int) error {
	inv.mu.Lock()
	defer inv.mu.Unlock() // TODO: これで全てのreturn経路をカバーできるか確認する
	// ... 以降、手動Unlock()は不要
	return nil
}

func (inv *Inventory) CheckAndRestock(item string, threshold, addQty int) {
	// TODO: RLockではなくLockを取る(書き込みうる関数だから)
}

func PrintSnapshot(inv *Inventory) { // TODO: ポインタ受け取りに変更
	// ...
}

func (inv *Inventory) Peek(item string) int {
	// TODO: RLock/RUnlockで保護する
	return 0
}

模範解答

package main

import (
	"fmt"
	"sync"
)

type Inventory struct {
	mu    sync.RWMutex
	stock map[string]int
}

func NewInventory() *Inventory {
	return &Inventory{stock: make(map[string]int)}
}

// Reserve は item を qty 個だけ予約(減算)する。
// Lock直後にdeferでUnlockすることで、どのreturn経路でも確実にロックが解放される。
func (inv *Inventory) Reserve(item string, qty int) error {
	inv.mu.Lock()
	defer inv.mu.Unlock()

	current, ok := inv.stock[item]
	if !ok {
		return fmt.Errorf("item not found: %s", item)
	}
	if current < qty {
		return fmt.Errorf("insufficient stock: %s", item)
	}
	inv.stock[item] = current - qty
	return nil
}

// CheckAndRestock は在庫が threshold を下回っていたら addQty 補充する。
// 書き込みが起こりうるため、最初からLock(書き込みロック)を取る。
// RLockからLockへの「昇格」はsync.RWMutexには存在しないため、
// 内部で別の書き込みロックメソッドを呼ぶ設計は避ける。
func (inv *Inventory) CheckAndRestock(item string, threshold, addQty int) {
	inv.mu.Lock()
	defer inv.mu.Unlock()
	if inv.stock[item] < threshold {
		inv.stock[item] += addQty
	}
}

// Restock は item を qty 個補充する(CheckAndRestockとは独立した公開API)。
func (inv *Inventory) Restock(item string, qty int) {
	inv.mu.Lock()
	defer inv.mu.Unlock()
	inv.stock[item] += qty
}

// PrintSnapshot は在庫の状態を出力する。
// ポインタで受け取ることで sync.RWMutex のコピーを避ける。
func PrintSnapshot(inv *Inventory) {
	inv.mu.RLock()
	defer inv.mu.RUnlock()
	fmt.Println(inv.stock)
}

// Peek は item の現在の在庫数を読み取る。読み取りもRLock/RUnlockで保護する。
func (inv *Inventory) Peek(item string) int {
	inv.mu.RLock()
	defer inv.mu.RUnlock()
	return inv.stock[item]
}

func main() {
	inv := NewInventory()
	inv.stock["widget"] = 10
	inv.stock["gadget"] = 3

	var wg sync.WaitGroup
	for i := 0; i < 20; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := inv.Reserve("gizmo", 1); err != nil {
				return
			}
		}()
	}
	wg.Wait()

	inv.CheckAndRestock("gadget", 5, 20)

	fmt.Println("widget stock:", inv.Peek("widget"))
	PrintSnapshot(inv)
}
▶ 実行結果を見る(go run main.go / go run -race main.go / go vet main.go すべて検証済み)
widget stock: 10
map[gadget:23 widget:10]

gizmoは在庫に存在しないため20回のReserveはすべてinsufficientではなくnot foundエラーで失敗し在庫は変化せず、gadgetは初期値3が閾値5を下回るため20補充されて23になります。go run -race main.goでもdata race検出なし、go vet main.goでも警告なしを確認済みです。

🪜 Step-by-Step 解説

1
バグ① — Reserveのロック解放漏れ
!okの分岐(=存在しないアイテムを予約しようとした場合)では、Unlock()を呼ばずに関数を抜けています。元のmain"gizmo"という存在しないアイテムを20個のgoroutineから予約しようとするため、最初の1回の呼び出しでロックが永久に握られたままになります。残り19個のgoroutineはinv.mu.Lock()で永久にブロックされ、mainwg.Wait()でブロックされます。その結果「起動中の全goroutineが、誰かが解放するはずの何かを待ったまま身動きが取れない」状態になり、Goランタイムはこれを検出してfatal error: all goroutines are asleep - deadlock!という致命的エラーでプロセスを強制終了させます(単に無限に待ち続けるだけでなく、ランタイム自身が「もう進めない」と判断して落ちる点が実務上は分かりやすい手がかりになります)。分岐が増えるたびに「このreturnの前にUnlockを書いたか」を人力でチェックするのは事故のもとであり、Lock()の直後にdefer inv.mu.Unlock()を1回書くことで、以降どれだけreturn経路が増えても自動的に解放される設計にするのが定石です。
2
バグ② — CheckAndRestockの自己デッドロック
func (inv *Inventory) CheckAndRestock(item string, threshold, addQty int) {
	inv.mu.RLock()
	defer inv.mu.RUnlock()
	if inv.stock[item] < threshold {
		inv.Restock(item, addQty) // Restock内部でinv.mu.Lock()を呼ぶ
	}
}
CheckAndRestockRLock()を保持したまま、条件を満たすとRestockを呼び出します。Restockは内部でinv.mu.Lock()(書き込みロック)を取得しようとしますが、これは同じgoroutineが既に保持しているRLockが解放されるのを待つことになります。ところがそのRLockRestockの呼び出しが終わってCheckAndRestockreturnしない限り解放されません(deferで解放される設計のため)。つまり「自分が持っているロックの解放を、自分自身が待つ」という自己デッドロックです。sync.RWMutexには「読み取りロックを書き込みロックに昇格させる」機能が存在しないため、この設計自体が誤りです。修正としては、CheckAndRestockが条件次第で書き込みを行う以上、最初から(読み取り区間を経ずに)Lock()を取るべきです。
3
バグ③ — PrintSnapshotでのMutexコピー
func PrintSnapshot(inv Inventory) { // 値渡し
	inv.mu.RLock()
	// ...
}

// 呼び出し側
PrintSnapshot(*inv)
PrintSnapshotInventoryで受け取っています。Inventoryは内部にsync.RWMutexを埋め込んでいるため、*invで値をコピーして渡した瞬間、ロックの内部状態ごとコピーされます。コピーされたinv.muは元のInventoryが持つmuとは別物なので、RLock()をかけても元のロックとは無関係な「見せかけの保護」にしかなりません。go vetはこのパターンをcopylocksという診断で静的に検出します(sync.Mutexを含む値をコピーする関数呼び出し・代入を見つけると警告を出します)。修正はシンプルで、引数の型を*Inventoryにしてポインタで渡すだけです。
4
バグ④ — Peekの無保護な読み取り
func (inv *Inventory) Peek(item string) int {
	return inv.stock[item] // ロックなし
}
Peekは一見「読むだけ」なので安全に見えますが、他のgoroutineがinv.stockに書き込み中(ReserveCheckAndRestockLock()している最中)に、ロックなしで同じmapを読むとGoランタイムのmap実装がこれを検出し、fatal error: concurrent map read and writeでプロセスそのものを異常終了させることがあります。これは「たまに違う値が読める」程度の緩やかな不具合ではなく、検出されると回復不能な致命的エラーです。
📌
今回のmainではバグ①のfatal error: all goroutines are asleep - deadlock!が先に発生するため、Peekは実際にはその手前で処理が止まり、このバグは今回の実行では表面化しません。Peekの呼び出しはwg.Wait()の後、つまり書き込み用のgoroutineが全て終わった後の逐次実行区間に置かれているためです。これは実務でもよくある落とし穴で、「今回のテストで再現しなかった=バグがない」ではありません。呼び出し元のタイミングやスレッド数が変われば同じPeekが容易にfatal errorを引き起こします。「読み取りだからロック不要」という判断を個々の呼び出しパターンに依存させず、メソッド自体を常に安全な実装にしておくのが正しい設計です。

💡 設計思想・なぜこう書くのか

📌
ロックの責任範囲を1メソッドに閉じ込める: この4つのバグに共通しているのは、「ロックの責任範囲があいまいなまま、複数のメソッド間にまたがってしまっている」という点です。Goの標準ライブラリやエフェクティブなGoコードでは、「1つのメソッドの中でLockしてUnlockするところまで完結させる」というロックのスコープを1メソッド内に閉じ込める設計が徹底されています。バグ②(CheckAndRestockRestockという別のロック取得メソッドを、自分のロックを保持したまま呼び出す)はまさにこの原則の違反です。「ロックを取得したまま、ロックを取得する別のpublicメソッドを呼ばない」という規律を守るだけで、この種のデッドロックの大半は未然に防げます。
📌
deferへのリソース解放の一元化はGo全体で一貫した思想: defer mu.Unlock()をロック取得の直後に1行だけ書く、という一見単調な慣習も、単なるスタイルの好みではなく「分岐が増えるほど手動管理のリスクが線形に増える」という現実に対するGoらしい対処です。Goには例外機構(try/catch)がなく、panicからの回復はdeferrecoverに限られるため、リソース解放をdeferに一元化する設計は言語全体で一貫しています。

🌐 他言語との比較

観点GoJavaPythonRust
ロック解放漏れの検出実行時にハングとして発現。go vetは解放漏れ自体は検出しない(copylocksはコピーのみ検出)try-with-resourcesや静的解析ツール(SpotBugs等)で一部検出可能with lock: を使えば構文的に解放漏れが起きないコンパイラの所有権システムがDropによるRAII解放を保証し、解放漏れはコンパイル時に構造的に発生しない
ロックの再入・昇格RLockLockの昇格は不可。両方とも非再入。誤ると即デッドロックReentrantReadWriteLockも昇格は不可(ドキュメントで明記)だが、通常のLockは再入可能なため同種のミスは起きにくいRLock相当の標準機構なし。サードパーティ実装依存RwLockも同様に読み取りロック保持中の書き込みロック取得はデッドロックになりうる
Mutexのコピー事故値渡しで発生しうる。go vetのcopylocksが静的検出該当なし(オブジェクトは常に参照)該当なし(オブジェクトは常に参照)所有権システムにより「コピー」ではなく「ムーブ」になるため、同じ種類の事故は構造的に起きない(Cloneを明示的に呼ばない限り)

Rustの所有権システムと比べると、Goのsync.Mutexはいずれのバグもコンパイラではなくgo vetや実行時観測(ハング・-race・fatal error)で気づくという設計になっていることが際立ちます。Goが「言語をシンプルに保つ」ために所有権チェッカーのような重厚な静的検証機構を持たない代償として、こうしたロック関連バグの発見は開発者の規律(deferの徹底、ロックスコープをメソッド内に閉じる、ポインタレシーバの徹底)とgo vet/-raceのようなツールに委ねられています。

🏆 実務での使いどころ

  • コードレビューのチェックリスト化: 「Lock()の直後にdefer Unlock()があるか」「ロック保持中に他のpublicメソッドを呼んでいないか」「Mutexを含む構造体を値で渡していないか」は、レビュー時に機械的にチェックできる観点としてチームのレビュー基準に組み込む価値がある
  • CIでのgo vet / go test -raceの常時実行: 今回のバグ③(copylocks)はgo vetで、バグ④のようなデータ競合はgo test -raceでCI上検出できる。人間のレビューだけに頼らず、ツールで機械的に検出できる部分は必ずパイプラインに組み込む
  • ロックを取得するメソッドの命名規約: 「このメソッドは内部でロックを取得する」ことが名前から分かりにくいと、バグ②のような「知らずにロック保持中に別のロック取得メソッドを呼ぶ」事故が起きやすい。社内ではxxxLocked(呼び出し側で既にロック済みを前提とする内部ヘルパー)と、通常の公開メソッドを命名で区別するチームもある

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
エラーの早期returnにはUnlockが不要だと錯覚する「正常系だけ気にしていればいい」という思考の癖全てのreturn経路でロックを解放する必要がある。deferで一元化すれば分岐の数に関わらず安全
RLockからLockに「昇格」できると思い込む「読み取り→条件次第で書き込み」という自然な処理の流れから連想するsync.RWMutexに昇格機能はない。書き込みが起こりうる関数は最初からLockを取る
ロックを取得中に呼び出した別メソッドの内部実装(そのメソッドも同じロックを取るかどうか)を確認しない「関数を呼ぶ」という操作がロックのスコープに影響するとは直感的に意識しにくいロック保持中に呼び出す全てのメソッドについて、それが同じmuを再度取得しないかを確認する。安全なのは「ロックのスコープを1メソッド内に完結させる」設計
値渡しで構造体を渡してもMutexだけは元と共有されると思い込むMutex自体を「ロックという抽象的な概念」だと捉え、実体がメモリ上のデータであることを意識しないsync.Mutex/RWMutexも構造体の一部のただのフィールド。値渡しすればフィールドごとコピーされ、別々のロックになる

🚀 次のステップ

  • 発展: 今回のCheckAndRestockを、Restockを呼び出す設計のまま安全にするにはどうすればよいか考えてみましょう(ヒント: Restockのロック取得部分をrestockLockedという「呼び出し側が既にLock済みであることを前提とする」非公開ヘルパーに切り出し、公開版のRestockCheckAndRestockの両方からそれを呼ぶ設計にすると、ロックの二重取得を構造的に避けられます)
  • 次回予告: Day 015 — sync.Once, sync.Pool(実装問題)。初期化を一度だけ実行する仕組みと、オブジェクトの再利用によるGC負荷軽減を学びます

🎯 自己評価

自分の回答

気づき・メモ