Go's sync.Mutex: Spin, Starve, and the Four-Millisecond Handoff
sync.Mutex is one int32 of state plus a semaphore: an uncontended CAS fast path, four rounds of PAUSE-based spinning, a FIFO wait queue, and a starvation mode that hands the lock directly to the first waiter.
TL;DR:
sync.Mutexis eight bytes: anint32of state and auint32semaphore. Uncontended, it is a single compare-and-swap. Contended, it spins for four rounds of 30PAUSEinstructions, then sleeps on a semaphore. If a waiter has been blocked for more than a millisecond the mutex flips into starvation mode, where unlocking hands the lock directly to the first waiter instead of letting new arrivals barge.
Every Go program that shares state between goroutines ends up using a
mutex, and almost nobody has read the implementation. It is short — about
200 lines — and it contains some of the sharpest engineering in the Go
runtime. This article walks through it, from the Go 1.28 tree (commit
c0be4674865b).
Where the code lives now
A warning before we start: the file every tutorial points you at,
src/runtime/lock2.go, does not exist any more. sync.Mutex was moved
into its own package, src/internal/sync, and src/sync/mutex.go is now
a two-line forwarding shim:
// src/sync/mutex.go:30
type Mutex struct {
_ noCopy
mu isync.Mutex
}The real implementation is src/internal/sync/mutex.go, and it links
against the runtime’s semaphore implementation through go:linkname.
The struct: two words
// src/internal/sync/mutex.go:20
type Mutex struct {
state int32
sema uint32
}That is the whole mutex. Eight bytes, no owner field, no goroutine id, no list of waiters. The waiters live in the runtime’s semaphore table, and the state word is where all the interesting flags live.
The state word
// src/internal/sync/mutex.go:25
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
// Mutex fairness.
//
// Mutex can be in 2 modes of operations: normal and starvation.
// In normal mode waiters are queued in FIFO order, but a woken up waiter
// does not own the mutex and competes with new arriving goroutines over
// the ownership. New arriving goroutines have an advantage -- they are
// already running on CPU and there can be lots of them, so a woken up
// waiter has good chances of losing. In such case it is queued at front
// of the wait queue. If a waiter fails to acquire the mutex for more than 1ms,
// it switches mutex to the starvation mode.
//
// In starvation mode ownership of the mutex is directly handed off from
// the unlocking goroutine to the waiter at the front of the queue.
// New arriving goroutines don't try to acquire the mutex even if it appears
// to be unlocked, and don't try to spin. Instead they queue themselves at
// the tail of the wait queue.
//
// If a waiter receives ownership of the mutex and sees that either
// (1) it is the last waiter in the queue, or (2) it waited for less than 1 ms,
// it switches mutex back to normal operation mode.
//
// Normal mode has considerably better performance as a goroutine can acquire
// a mutex several times in a row even if there are blocked waiters.
// Starvation mode is important to prevent pathological cases of tail latency.
starvationThresholdNs = 1e6
)That comment is the design document, and it deserves to be read carefully because it explains a behaviour that otherwise looks like a bug.
The layout of the 32-bit state word:
| Bits | Meaning |
|---|---|
| 0 | mutexLocked — the lock is held |
| 1 | mutexWoken — someone has been woken, do not wake another |
| 2 | mutexStarving — direct handoff mode |
| 3 and up | the number of waiters, shifted by mutexWaiterShift (3) |
So state packs a lock bit, two mode bits and a waiter count into one
word, and every transition is a single compare-and-swap. There is no
separate waiter list in the mutex itself — the count in the high bits is
all it needs, because the actual queue lives in the runtime.
The fast path
// src/internal/sync/mutex.go:61
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}One CAS from 0 to mutexLocked. The slow path is deliberately not in the
same function so the fast path can be inlined into the caller — the
comment says so explicitly. An uncontended Lock is a single atomic
compare-and-swap plus a branch.
Unlock is the mirror image:
// src/internal/sync/mutex.go:187
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
// Fast path: drop lock bit.
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
// Outlined slow path to allow inlining the fast path.
// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
m.unlockSlow(new)
}
}atomic.AddInt32(&m.state, -mutexLocked) clears the lock bit and, in the
same operation, decrements the waiter count if there are waiters (because
the waiter count lives above the lock bit). The result being non-zero means
“there were waiters, go do the slow part”.
Note the missing check for unlocking an unlocked mutex on this path: it is
in unlockSlow, which only runs when new != 0. If the mutex was
unlocked, new would be -1, which is non-zero, so the slow path runs
and catches it.
The slow path: spin first, then sleep
lockSlow is the interesting function. Here it is in full:
// src/internal/sync/mutex.go:95
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// Don't spin in starvation mode, ownership is handed off to waiters
// so we won't be able to acquire the mutex anyway.
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// Active spinning makes sense.
// Try to set mutexWoken flag to inform Unlock
// to not wake other blocked goroutines.
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
old = m.state
continue
}
new := old
// Don't try to acquire starving mutex, new arriving goroutines must queue.
if old&mutexStarving == 0 {
new |= mutexLocked
}
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
// The current goroutine switches mutex to starvation mode.
// But if the mutex is currently unlocked, don't do the switch.
// Unlock expects that starving mutex has waiters, which will not
// be true in this case.
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
if awoke {
// The goroutine has been woken from sleep,
// so we need to reset the flag in either case.
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
new &^= mutexWoken
}
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
// If we were already waiting before, queue at the front of the queue.
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 2)
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
if old&mutexStarving != 0 {
// If this goroutine was woken and mutex is in starvation mode,
// ownership was handed off to us but mutex is in somewhat
// inconsistent state: mutexLocked is not set and we are still
// accounted as waiter. Fix that.
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
// Exit starvation mode.
// Critical to do it here and consider wait time.
// Starvation mode is so inefficient, that two goroutines
// can go lock-step infinitely once they switch mutex
// to starvation mode.
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
}Four things are happening in that loop.
1. The spin phase
The first thing the loop does is try to spin. It spins only if the mutex
is locked but not starving, and only if runtime_canSpin says so.
The runtime decides whether spinning is worth it:
// src/runtime/proc.go:8014
func internal_sync_runtime_canSpin(i int) bool {
// sync.Mutex is cooperative, so we are conservative with spinning.
// Spin only few times and only if running on a multicore machine and
// GOMAXPROCS>1 and there is at least one other running P and local runq is empty.
// As opposed to runtime mutex we don't do passive spinning here,
// because there can be work on global runq or on other Ps.
if i >= active_spin || numCPUStartup <= 1 || gomaxprocs <= sched.npidle.Load()+sched.nmspinning.Load()+1 {
return false
}
if p := getg().m.p.ptr(); !runqempty(p) {
return false
}
return true
}Read the conditions one by one:
i >= active_spin— at mostactive_spinrounds of spinning.numCPUStartup <= 1— pointless to spin on a single-CPU machine.gomaxprocs <= idle + spinning + 1— no other P is running, so nobody is going to release the lock; spinning would just burn the timeslice.- the local run queue is not empty — there is other work to do, so go do it rather than spinning.
And “one round of spinning” is 30 PAUSE instructions:
// src/runtime/proc.go:8031
func internal_sync_runtime_doSpin() {
procyield(active_spin_cnt)
}with the constants:
// src/runtime/lock_spinbit.go:53
const (
active_spin = 4 // referenced in proc.go for sync.Mutex implementation
active_spin_cnt = 30 // referenced in proc.go for sync.Mutex implementation
)So: four rounds of 30 pause instructions, at most. procyield on amd64
emits the PAUSE instruction, which is a hint to the CPU that the thread
is in a spin-wait loop: it saves power and, on modern Intel and AMD parts,
gives up pipeline resources so another thread on the same core can make
progress.
That “four” is where the number in this article’s title comes from, and it is worth being precise: four is the number of spin rounds. The number that governs the handoff is one millisecond, and we get to that next.
2. Setting the woken flag
Before spinning, the goroutine tries to set mutexWoken, which tells the
unlocking goroutine “don’t wake anyone, I am spinning and will take it”.
That saves a wake-up, a scheduler round trip and a sudog dequeue. If the
spin fails, the flag is cleared when the goroutine goes to sleep.
3. Queueing on the semaphore
If the CAS to take the lock fails, the goroutine increments the waiter count and calls into the runtime:
// src/internal/sync/mutex.go:144
// If we were already waiting before, queue at the front of the queue.
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 2)queueLifo is the fairness knob, and it is subtler than it looks:
- First time you queue:
waitStartTime == 0, soqueueLifoisfalse. You go to the tail of the wait queue: FIFO. - If you were woken and then lost the race to a newly arrived goroutine,
you loop around and queue again — this time
waitStartTime != 0, soqueueLifoistrue, and you go to the head of the queue. A waiter that has already been through one round trip should not be punished for it.
The lifo flag is implemented in the runtime’s semaphore:
// src/runtime/sema.go:304
func (root *semaRoot) queue(addr *uint32, s *sudog, lifo bool) {
s.g = getg()
s.elem.set(unsafe.Pointer(addr))
// Storing this pointer so that we can trace the semaphore address
// from the blocked goroutine when checking for goroutine leaks.
s.g.waiting = s
s.next = nil
s.prev = nil
s.waiters = 0
var last *sudog
pt := &root.treap
for t := *pt; t != nil; t = *pt {
if uintptr(unsafe.Pointer(addr)) == t.elem.uintptr() {
// Already have addr in list.
if lifo {
// Substitute s in t's place in treap.
*pt = s
s.ticket = t.ticket
s.acquiretime = t.acquiretime // preserve head acquiretime as oldest time
s.parent = t.parent
s.prev = t.prev
s.next = t.nextThe semaphore’s waiters live in a treap keyed by address, so different mutexes do not share a queue and there is no global contention on the wait-list structure.
4. The starvation threshold
Here is the line that decides everything:
// src/internal/sync/mutex.go:150
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNsstarvationThresholdNs is 1e6, i.e. one millisecond. If this
goroutine has been unable to acquire the lock for longer than a
millisecond, it declares the mutex starving. On the next CAS it sets the
mutexStarving bit, and from that moment on the mutex behaves
completely differently.
Before starvation mode, the semantics are: unlocking makes the mutex available, and whoever gets there first takes it. A newly arriving goroutine is already running on a CPU, so it usually wins against a waiter that has just been woken and has to be scheduled. Under sustained contention, that means the waiter at the back of the queue can wait a long time — this is the classic “convoy effect”.
In starvation mode, the semantics change to: unlocking hands the lock to the waiter at the head of the queue, directly. New arrivals do not even try to take the lock; they queue at the tail.
Unlocking, and the handoff
// src/internal/sync/mutex.go:202
func (m *Mutex) unlockSlow(new int32) {
if (new+mutexLocked)&mutexLocked == 0 {
fatal("sync: unlock of unlocked mutex")
}
if new&mutexStarving == 0 {
old := new
for {
// If there are no waiters or a goroutine has already
// been woken or grabbed the lock, no need to wake anyone.
// In starvation mode ownership is directly handed off from unlocking
// goroutine to the next waiter. We are not part of this chain,
// since we did not observe mutexStarving when we unlocked the mutex above.
// So get off the way.
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// Grab the right to wake someone.
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 2)
return
}
old = m.state
}
} else {
// Starving mode: handoff mutex ownership to the next waiter, and yield
// our time slice so that the next waiter can start to run immediately.
// Note: mutexLocked is not set, the waiter will set it after wakeup.
// But mutex is still considered locked if mutexStarving is set,
// so new coming goroutines won't acquire it.
runtime_Semrelease(&m.sema, true, 2)
}
}Two paths:
Normal mode. If there are waiters, take the right to wake one (by
setting mutexWoken and decrementing the waiter count) and call
runtime_Semrelease(&m.sema, false, 2). The handoff argument is
false: the semaphore is incremented, some waiter will take it, and the
unlocker does not wait.
Starvation mode. runtime_Semrelease(&m.sema, true, 2) — the
handoff argument is true. That means “give the token directly to the
first waiter, and don’t just make it available”.
Here is what handoff does in the runtime:
// src/runtime/sema.go:257
if s.ticket != 0 {
throw("corrupted semaphore ticket")
}
if handoff && cansemacquire(addr) {
s.ticket = 1
}
readyWithTime(s, 5+skipframes)
if s.ticket == 1 && getg().m.locks == 0 && getg() != getg().m.g0 {
// Direct G handoff
//
// readyWithTime has added the waiter G as runnext in the
// current P; we now call the scheduler so that we start running
// the waiter G immediately.
//
// Note that waiter inherits our time slice: this is desirable
// to avoid having a highly contended semaphore hog the P
// indefinitely. goyield is like Gosched, but it emits a
// "preempted" trace event instead and, more importantly, puts
// the current G on the local runq instead of the global one.
// We only do this in the starving regime (handoff=true), as in
// the non-starving case it is possible for a different waiter
// to acquire the semaphore while we are yielding/scheduling,
// and this would be wasteful. We wait instead to enter starving
// regime, and then we start to do direct handoffs of ticket and P.
//
// See issue 33747 for discussion.
//
// We don't handoff directly if we're holding locks or on the
// system stack, since it's not safe to enter the scheduler.
goyield()
}This is the good bit. handoff && cansemacquire(addr) sets a ticket on
the woken sudog, which tells the waiter “you own this; do not compete
for it”. Then, if the unlocking goroutine is not holding other locks and
is not on the system stack, it calls goyield() — putting itself on the
local run queue and running the woken goroutine immediately, on the same
P, without a scheduler round trip.
So the starvation-mode handoff is not “wake someone and hope”. It is “you are now the owner of the mutex, and here is my CPU”.
The waiter, on waking up, sees mutexStarving set, knows it was handed
the lock, and fixes up the state word:
// src/internal/sync/mutex.go:152
if old&mutexStarving != 0 {
// If this goroutine was woken and mutex is in starvation mode,
// ownership was handed off to us but mutex is in somewhat
// inconsistent state: mutexLocked is not set and we are still
// accounted as waiter. Fix that.
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
// Exit starvation mode.
// Critical to do it here and consider wait time.
// Starvation mode is so inefficient, that two goroutines
// can go lock-step infinitely once they switch mutex
// to starvation mode.
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}Two things happen here: the waiter takes the lock bit (it was handed ownership), and the waiter count goes down by one (it is no longer waiting). And if this waiter was not itself starving, or if it is the last one in the queue, the mutex exits starvation mode — back to normal barging semantics.
That exit condition is subtle and important: if starvation mode never exited, two goroutines could ping-pong the lock in lock-step forever, burning a context switch per acquisition. The comment says so explicitly.
TryLock
// src/internal/sync/mutex.go:76
func (m *Mutex) TryLock() bool {
old := m.state
if old&(mutexLocked|mutexStarving) != 0 {
return false
}
// There may be a goroutine waiting for the mutex, but we are
// running now and can try to grab the mutex before that
// goroutine wakes up.
if !atomic.CompareAndSwapInt32(&m.state, old, old|mutexLocked) {
return false
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return true
}TryLock succeeds if the mutex is unlocked and not in starvation mode.
Note that it does not check the waiter count: if there are waiters queued
but the mutex is unlocked, TryLock takes it. The comment explains why
that is fair: “we are running now and can try to grab the mutex before
that goroutine wakes up”. In starvation mode it always fails, so a
starving mutex cannot be stolen.
The doc comment on the public method is worth repeating, because it is advice rather than mechanics:
// src/sync/mutex.go:49
// TryLock tries to lock m and reports whether it succeeded.
//
// Note that while correct uses of TryLock do exist, they are rare,
// and use of TryLock is often a sign of a deeper problem
// in a particular use of mutexes.sync.RWMutex
RWMutex allows many readers or one writer. The trick is that “a writer
is pending” has to be visible to new readers, otherwise readers keep
arriving and the writer never gets in. The current implementation does
that with a negative readerCount rather than a separate flag bit:
// src/sync/rwmutex.go:39
type RWMutex struct {
w Mutex // held if there are pending writers
writerSem uint32 // semaphore for writers to wait for completing readers
readerSem uint32 // semaphore for readers to wait for completing writers
readerCount atomic.Int32 // number of pending readers
readerWait atomic.Int32 // number of departing readers
}
const rwmutexMaxReaders = 1 << 30readerCount is the reader count plus, when a writer is pending, a large
negative offset. A reader that sees a negative count knows a writer is
waiting and blocks:
// src/sync/rwmutex.go:67
func (rw *RWMutex) RLock() {
if race.Enabled {
race.Read(unsafe.Pointer(&rw.w))
race.Disable()
}
if rw.readerCount.Add(1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
}
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem))
}
}Add(1) < 0 is the whole check. Adding one to a negative count keeps it
negative, so the reader knows to block; and because readerCount was
already negative, the writer’s count of “readers I still have to wait
for” is not disturbed.
The write side:
// src/sync/rwmutex.go:144
func (rw *RWMutex) Lock() {
if race.Enabled {
race.Read(unsafe.Pointer(&rw.w))
race.Disable()
}
// First, resolve competition with other writers.
rw.w.Lock()
// Announce to readers there is a pending writer.
r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
// Wait for active readers.
if r != 0 && rw.readerWait.Add(r) != 0 {
runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
}Three steps: take the writer mutex (so writers are serialised among
themselves), announce the pending writer by subtracting rwmutexMaxReaders
from readerCount, then wait for the readers that were already inside.
The + rwmutexMaxReaders immediately after the Add recovers the number
of readers that were present at that moment, which is how many the writer
must wait for.
And unlocking wakes the readers:
// src/sync/rwmutex.go:201
func (rw *RWMutex) Unlock() {
if race.Enabled {
race.Read(unsafe.Pointer(&rw.w))
race.Release(unsafe.Pointer(&rw.readerSem))
race.Disable()
}
// Announce to readers there is no active writer.
r := rw.readerCount.Add(rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
race.Enable()
fatal("sync: Unlock of unlocked RWMutex")
}
// Unblock blocked readers, if any.
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
// Allow other writers to proceed.
rw.w.Unlock()
if race.Enabled {
race.Enable()
}
}r is the number of readers that were blocked; each one gets a
readerSem release.
The upshot: RWMutex blocks new readers the moment a writer arrives, so
a steady stream of readers cannot starve a writer. That is the whole
point of the writer-pending signal.
The runtime’s own mutex is a different animal
The mutex that the runtime uses internally (runtime.mutex, the one in
hchan.lock) is not the sync.Mutex we just read. It is a
“spinbit” mutex with a completely different design, and it is worth
contrasting them because they optimise for different things.
// src/runtime/lock_spinbit.go:58
const (
mutexLocked = 0x001
mutexSleeping = 0x002
mutexSpinning = 0x100
mutexStackLocked = 0x200
mutexMMask = 0x3FF
mutexMOffset = gc.MallocHeaderSize // alignment of heap-allocated Ms (those other than m0)
mutexActiveSpinCount = 4
mutexActiveSpinSize = 30
mutexPassiveSpinCount = 1
mutexTailWakePeriod = 16The runtime’s mutex is a single uintptr that packs three flag bits, an
anti-starvation “spin bit”, a “stack locked” bit and a partial pointer to
the M that most recently slept on it. It spins actively, then passively
(osyield), then sleeps on a per-M semaphore, and it uses the “spin bit”
to let exactly one waiter spin at a time so that the cache line stays
shared rather than being ping-ponged.
The key structural difference: sync.Mutex blocks goroutines, and the
runtime’s mutex blocks M threads. A runtime lock cannot park a goroutine,
because the goroutine might be the one holding the P that the M needs to
schedule it later.
Trade-offs
What the spin buys. Under light contention, the lock holder finishes
in a few hundred nanoseconds. Spinning for 4 rounds of 30 PAUSEs costs
almost nothing on a modern CPU and avoids the entire park/wake/schedule
round trip, which costs microseconds. On a multi-core machine where
another P is idle and available to run the holder, the waiter can grab the
lock as soon as it is released.
What the spin costs. If the holder is descheduled, the spinner burns
CPU for nothing. That is why runtime_canSpin refuses to spin when the
local run queue is non-empty or when no other P is running: in those cases
the waiter has better things to do.
What the starvation mode buys. Bounded tail latency. Without it, a goroutine that joins the queue behind a hundred others can wait arbitrarily long, because every newly arriving goroutine can barge ahead of it. With it, once a waiter crosses the one-millisecond line it gets guaranteed ownership of the lock at the next unlock.
What the starvation mode costs. Every acquisition becomes a scheduler
handoff with a goyield, even when there is no contention to speak of.
Two goroutines in a lock-step ping-pong would flip the mutex into
starvation mode and then pay a context switch per lock — which is why the
mode is exited as soon as the last waiter leaves or a waiter that was not
itself starving takes the lock.
What RWMutex buys. Concurrent reads. What it costs: two
semaphores, an extra mutex, four atomics on the read path, and the
readers-writers problem, which is much harder than the readers-writers
problem with a write-preferring policy. The Go implementation is
write-preferring, which is the right default but means a busy write lock
will block readers that arrive after it.
Caveats
The headline number is one millisecond, not four. Four is the number
of spin rounds (active_spin = 4); the starvation threshold that governs
the direct handoff is starvationThresholdNs = 1e6, one millisecond.
A mutex is not owned by a goroutine. The public documentation is explicit:
// src/sync/mutex.go:58
// Unlock unlocks m.
// It is a run-time error if m is not locked on entry to Unlock.
//
// A locked [Mutex] is not associated with a particular goroutine.
// It is allowed for one goroutine to lock a Mutex and then
// arrange for another goroutine to unlock it.So you cannot unlock a mutex from a deferred function in a different goroutine, and unlocking an unlocked mutex is a fatal error, not a panic.
Mutex must not be copied. The noCopy field in the struct is
there to make go vet complain, because copying a mutex copies its
state word and produces two mutexes sharing one bit — a bug the compiler
cannot catch for you.
The zero value is a usable unlocked mutex. No init needed. But a
mutex that is copied after first use is a data race, and the state word
is only 32 bits with a 29-bit waiter count — with more than 2^29 waiters
the count wraps. That is not a practical concern, but it is why the waiter
count is clamped in the runtime’s sudog struct too.
Starvation mode is entered by waiters, not by the unlocker. If a mutex has exactly one waiter that is descheduled for a while, that waiter will declare the mutex starving and the next unlock will be a full scheduler handoff, even though there was no real contention. Under a workload with sporadic long GC pauses this can show up as unexpected latency.
sync.Mutex is not fair in the fairness-queue sense. In normal mode
a newly arriving goroutine can take the lock ahead of a woken waiter.
Only the starvation mode gives you FIFO-ish ordering, and only after the
one-millisecond threshold.
The public sync.Mutex is a wrapper. The real code lives in
src/internal/sync/mutex.go and src/sync/mutex.go just forwards. If you
are reading blog posts that quote src/sync/mutex.go and wonder why the
code you see does not match, that is why.
Wrapping up
The one thing to remember: sync.Mutex is an int32 state word and a
uint32 semaphore. Uncontended it is one CAS; contended it spins four
rounds of 30 PAUSEs and then sleeps on the runtime’s semaphore queue.
The part people get wrong: the fairness guarantee is conditional. In normal mode a newly arriving goroutine will usually beat a woken waiter, because it is already running on a CPU. Only after a waiter has been blocked for more than a millisecond does the mutex switch to starvation mode and start handing ownership directly to the head of the queue.
What to go do about it: if you are seeing tail-latency spikes under
contention, look at the sync/mutex block profile (runtime.SetMutexProfileFraction)
and check whether the mutex is spending time in starvation mode. If it is,
the fix is almost always to reduce the critical section rather than to
replace the mutex.
Further reading
src/internal/sync/mutex.go— the realsync.Mutex: state word,lockSlow,unlockSlow,TryLocksrc/sync/mutex.go— the public wrapper and the documented semanticssrc/sync/rwmutex.go—RWMutex, the writer-pending signal and the reader semaphoressrc/runtime/sema.go—semacquire1,semrelease1,cansemacquire, thehandoffflag and the treap of waiterssrc/runtime/proc.go—runtime_canSpin,runtime_doSpin,goyieldsrc/runtime/lock_spinbit.go— the runtime’s own spinbit mutex, a different design for a different jobsrc/runtime/runtime2.go— thesudogstruct, including thewaiterscount used by the semaphore