What just happened?
Your program crashed with:
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
This crash is unrecoverable. The moment you call Unlock() on a sync.Mutex that's already unlocked, the mutex's internal state flips negative, the runtime catches it, and the goroutine dies โ no second chances. You can't wrap this in a recover() and move on.
The three ways this breaks
Most cases fall into one of these patterns:
1. Double Unlock
var mu sync.Mutex
mu.Lock()
mu.Unlock()
mu.Unlock() // panic here โ already unlocked
2. Unlock inside a loop when Lock was outside
var mu sync.Mutex
mu.Lock()
for i := 0; i < 3; i++ {
doWork()
mu.Unlock() // first iteration: fine. second: panic.
}
3. defer + manual Unlock firing twice
func process(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // queued on the defer stack
// ... some code ...
mu.Unlock() // explicit early unlock โ fine so far
// function returns โ defer fires again โ panic
}
Why Go doesn't just ignore it
sync.Mutex has no owner concept and isn't re-entrant. It tracks one bit: locked or unlocked. Calling Unlock() on an already-unlocked mutex is always a programmer mistake โ there's no ambiguous case where Go could safely continue. So it doesn't try.
The same applies to sync.RWMutex, which has two flavors of the same problem:
panic: sync: unlock of unlocked mutex // RLock/RUnlock imbalance
panic: sync: RUnlock of unlocked RWMutex // RWMutex-specific variant
Find the imbalance first
Start with the race detector. It won't catch this exact panic โ it's not a data race โ but double-unlock bugs often come with neighbors:
go run -race main.go
# or
go test -race ./...
Then track every Lock/Unlock call on the suspect mutex:
grep -n 'mu\.Unlock\|mu\.Lock' yourfile.go
Count them. Every Lock gets exactly one Unlock โ across every code path, including early returns and error branches. A single path where the count is off is enough to crash the whole program.
Fixes that actually stick
Rule 1 โ defer immediately after Lock, and nowhere else
Pick one style per function. Don't hedge. The cleanest pattern:
func safeProcess(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // fires once, when the function exits
doWork()
// no other mu.Unlock() calls in this function โ ever
}
Rule 2 โ need an early unlock? Drop defer entirely
func processWithEarlyUnlock(mu *sync.Mutex, data []byte) error {
mu.Lock()
snapshot := copyData(data)
mu.Unlock() // explicit โ no defer in sight
// work on snapshot without holding the lock
return process(snapshot)
}
Rule 3 โ Lock and Unlock must live at the same loop level
// WRONG โ Lock once, Unlock many times
mu.Lock()
for _, item := range items {
process(item)
mu.Unlock() // crashes after the first iteration
}
// RIGHT โ paired per iteration
for _, item := range items {
mu.Lock()
process(item)
mu.Unlock()
}
Rule 4 โ wrap complex state in a struct
When a mutex guards multiple operations, hide it inside a type. Each method becomes a tiny, auditable Lock/Unlock pair:
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
}
Small methods mean small blast radius. A 5-line method with one Lock/Unlock pair is nearly impossible to get wrong.
Fixing the defer + manual Unlock double-fire
// BROKEN
func broken(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock() // will also run at return
// ...
mu.Unlock() // first unlock โ looks harmless
// function returns โ defer fires โ panic
}
// FIXED option A โ let defer do all the work
func fixedA(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock()
// ...
// no explicit Unlock anywhere below this line
}
// FIXED option B โ ditch defer, go fully explicit
func fixedB(mu *sync.Mutex) error {
mu.Lock()
// ...
if err := validate(); err != nil {
mu.Unlock()
return err
}
mu.Unlock()
return nil
}
Verify the fix
Three commands, run them in order:
# Basic sanity check
go run main.go
# Race detector pass
go run -race main.go
# Full test suite with race detection
go test -race -count=1 ./...
Write a targeted test that hammers the exact code path that was panicking. For example, if SafeCounter was the culprit:
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 goroutines, no sleeps, race detector on. If it passes clean, the fix holds.
Quick reference
- Double Unlock โ delete the extra call. One Lock, one Unlock, full stop.
- defer + manual Unlock โ commit to one style per function. Never both.
- Unlock inside loop, Lock outside โ move the Lock inside the loop body.
- RWMutex variant โ
RLock/RUnlockandLock/Unlockare separate pairs. Don't cross the streams.

