何が起きたのか?
プログラムが以下のエラーでクラッシュしました:
panic: sync: unlock of unlocked mutex
goroutine 1 [running]:
sync.throw2({0x5a3b2c?, 0x0?})
/usr/local/go/src/sync/mutex.go:35 +0x5c
sync.(*Mutex).unlockSlow(0xc000014090, 0xffffffff)
/usr/local/go/src/sync/mutex.go:220 +0x38
sync.(*Mutex).Unlock(...)
/usr/local/go/src/sync/mutex.go:193
main.main()
/home/user/myapp/main.go:18 +0x5c
このクラッシュは回復不可能です。すでにアンロック済みの sync.Mutex に対して Unlock() を呼び出した瞬間、ミューテックスの内部状態が負の値に反転し、ランタイムがそれを検出してゴルーチンが終了します。やり直しは一切できません。recover() でラップして処理を続けることもできません。
クラッシュを引き起こす3つのパターン
ほとんどのケースは以下のいずれかのパターンに当てはまります:
1. 二重 Unlock
var mu sync.Mutex
mu.Lock()
mu.Unlock()
mu.Unlock() // ここでパニック — すでにアンロック済み
2. Lock がループ外、Unlock がループ内
var mu sync.Mutex
mu.Lock()
for i := 0; i < 3; i++ {
doWork()
mu.Unlock() // 1回目のイテレーション:正常。2回目:パニック。
}
3. defer と手動 Unlock の二重発火
func process(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // defer スタックにキュー登録
// ... 何らかの処理 ...
mu.Unlock() // 明示的な早期アンロック — この時点ではまだ問題ない
// 関数が返る → defer が再度発火 → パニック
}
Go がエラーを無視しない理由
sync.Mutex はオーナーという概念を持たず、再入可能でもありません。追跡するのはロック済みかアンロック済みかという1ビットだけです。すでにアンロック済みのミューテックスに対して Unlock() を呼び出すのは、常にプログラマーのミスです。Go が安全に処理を続けられる曖昧なケースは存在しないため、続行しようとしません。
同じことが sync.RWMutex にも当てはまります。こちらには同じ問題の2つのバリエーションがあります:
panic: sync: unlock of unlocked mutex // RLock/RUnlock の不一致
panic: sync: RUnlock of unlocked RWMutex // RWMutex 固有のバリアント
まず不一致を見つける
レースディテクターから始めましょう。このパニック自体はデータ競合ではないため直接は検出できませんが、二重アンロックのバグはよく隣接する問題を伴います:
go run -race main.go
# または
go test -race ./...
次に、問題のミューテックスに対するすべての Lock/Unlock 呼び出しを追跡します:
grep -n 'mu\.Unlock\|mu\.Lock' yourfile.go
数を数えてください。すべての Lock に対して、早期リターンやエラー分岐を含むあらゆるコードパスで、正確に1つの Unlock が対応していなければなりません。カウントがずれているパスが1つでもあれば、プログラム全体がクラッシュするのに十分です。
確実に効く修正方法
ルール1 — Lock の直後に defer、それ以外の場所では使わない
関数ごとにスタイルを1つに統一してください。迷わないことが重要です。最もクリーンなパターン:
func safeProcess(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // 関数が終了したときに一度だけ発火
doWork()
// この関数内に他の mu.Unlock() 呼び出しは一切書かない
}
ルール2 — 早期アンロックが必要なら defer を完全に使わない
func processWithEarlyUnlock(mu *sync.Mutex, data []byte) error {
mu.Lock()
snapshot := copyData(data)
mu.Unlock() // 明示的 — defer は一切なし
// ロックを保持せずにスナップショットを処理
return process(snapshot)
}
ルール3 — Lock と Unlock は同じループレベルに置く
// 誤り — Lock は1回、Unlock は複数回
mu.Lock()
for _, item := range items {
process(item)
mu.Unlock() // 最初のイテレーション後にクラッシュ
}
// 正しい — イテレーションごとにペアにする
for _, item := range items {
mu.Lock()
process(item)
mu.Unlock()
}
ルール4 — 複雑な状態は構造体にラップする
ミューテックスが複数の操作を保護する場合、型の内部に隠蔽してください。各メソッドが小さく監査可能な Lock/Unlock ペアになります:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
メソッドが小さければ、影響範囲も小さくなります。Lock/Unlock ペアが1つある5行のメソッドは、ほぼ間違いを犯しようがありません。
defer と手動 Unlock の二重発火を修正する
// 壊れているコード
func broken(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // return 時にも実行される
// ...
mu.Unlock() // 最初のアンロック — 無害に見える
// 関数が返る → defer が発火 → パニック
}
// 修正版A — defer にすべてを任せる
func fixedA(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock()
// ...
// この行以降に明示的な Unlock は一切書かない
}
// 修正版B — defer を使わず、完全に明示的に書く
func fixedB(mu *sync.Mutex) error {
mu.Lock()
// ...
if err := validate(); err != nil {
mu.Unlock()
return err
}
mu.Unlock()
return nil
}
修正を検証する
以下の3つのコマンドを順番に実行してください:
# 基本的な動作確認
go run main.go
# レースディテクターによる確認
go run -race main.go
# レース検出付きでフルテストスイートを実行
go test -race -count=1 ./...
パニックが発生していた正確なコードパスを集中的にテストするテストを書いてください。例えば、SafeCounter が原因だった場合:
func TestNoDoubleLock(t *testing.T) {
var wg sync.WaitGroup
sc := &SafeCounter{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
sc.Increment()
}()
}
wg.Wait()
if sc.Value() != 1000 {
t.Fatalf("expected 1000, got %d", sc.Value())
}
}
1000個のゴルーチン、スリープなし、レースディテクター有効。これがクリーンに通れば、修正は有効です。
クイックリファレンス
- 二重 Unlock → 余分な呼び出しを削除する。Lock 1回、Unlock 1回、以上。
- defer + 手動 Unlock → 関数ごとに1つのスタイルに統一する。両方を混在させない。
- Lock がループ外、Unlock がループ内 → Lock をループ本体の内側に移動する。
- RWMutex バリアント →
RLock/RUnlockとLock/Unlockは別々のペアです。混在させないこと。

