sync.Mutex: Normal vs Starvation Mode (Fairness Handoff)
- Last updated
- Prerequisites:
- Go goroutines and mutex basics
- Understanding of lock contention
- go
- sync
- mutex
- starvation
- fairness
- concurrency
Read at your depth
The practical view
sync.Mutex is Go's mutual exclusion lock: mu.Lock() / mu.Unlock(). It has two modes: normal mode (default) and starvation mode. In normal mode, a newly-unlocking goroutine hands the lock to a waiting goroutine that was first in the queue (the head), but a NEW goroutine calling Lock() can 'barge' and grab it first if the lock is still free when it arrives — this favors throughput but can let a waiter starve. When a waiter waits longer than ~1ms, the mutex switches to starvation mode: the unlock hands the lock directly to the head waiter (FIFO), and new arrivals queue without barging. After the head waiter gets the lock (or waits <1ms), the mutex returns to normal mode.
The same idea in other frameworks
N/A — Go runtime concern.N/A — Go concern.N/A — Go concern.N/A — Go concern.Legacy vs modern
Barging normal mode vs starvation-mode fairness
Normal mode lets new arrivals grab a just-released lock (higher throughput, possible starvation); starvation mode enforces FIFO handoff to bound worst-case latency.
mu.Lock() // ❌ OLD: assuming 'first come, first served' alwaysmu.Lock() // ✅ NEW: normal mode (throughput) → starvation mode (fairness)Interview gotchas
Context
This tests the fairness mechanism — interviewers want the barging vs FIFO handoff distinction and the starvation threshold that drives the transition.
The mechanical answer
In normal mode, a goroutine calling Lock() while the mutex is unlocked can acquire it immediately (barging), even if other goroutines are already waiting — this favors throughput because handoffs are expensive and the lock is used productively. The downside is that a waiting goroutine can be overtaken repeatedly. When a goroutine has been waiting for more than about 1 millisecond, the mutex switches to starvation mode: the unlock operation hands the lock DIRECTLY to the first goroutine in the wait queue (FIFO handoff, no barging), and newly arriving Lock() calls queue behind it. Once the starved goroutine acquires the lock (and its total wait drops below the threshold), the mutex returns to normal mode. This guarantees a maximum wait bound while preserving throughput in the common case.
Trap
The naive answer is 'sync.Mutex is always FIFO'. It's FIFO only in starvation mode; normal mode allows barging. Another trap: thinking you can configure the mode — it's automatic and timing-based, not a knob. Candidates who describe the ~1ms threshold and the automatic transition show real understanding.
Context
This probes the low-level handoff mechanics — interviewers want the semaphore-based parking, the woken flag, and why a barging goroutine can still beat a handoff.
The mechanical answer
When the lock is contended, waiting goroutines park on a runtime semaphore (semacquire). The unlock path checks the state word: if there are waiters, it sets the 'woken' bit and releases ONE waiter via semrelease (in normal mode) — that waiter wakes and races to acquire the lock. The woken bit prevents two goroutines from both thinking they should wake a waiter. The race: between the unlock's decision and the woken waiter actually running, a NEW goroutine can CAS the lock from unlocked (barging) — this is legal in normal mode, which is why a handoff doesn't guarantee the lock reaches the waiter. In starvation mode, the unlock hands off directly by NOT clearing the locked bit (passing ownership), so a barger cannot acquire; the head waiter takes over. This is the core of the fairness switch.
Trap
The naive answer is 'unlock wakes the next waiter and they always get it'. In normal mode, barging can beat the handoff. Another trap: assuming the mutex uses OS-level futexes directly — it uses the Go runtime's semaphore (gopark/ready), which is goroutine-level parking, not a raw OS mutex. Candidates who describe the woken bit and the handoff race show real depth.