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.Mutex is eight bytes: an int32 of state and a uint32 semaphore. Uncontended, it is a single compare-and-swap. Contended, it spins for four rounds of 30 PAUSE instructions, 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:

go
// 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

go
// 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

go
// 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:

BitsMeaning
0mutexLocked — the lock is held
1mutexWoken — someone has been woken, do not wake another
2mutexStarving — direct handoff mode
3 and upthe 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

go
// 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:

go
// 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:

go
// 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:

go
// 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 most active_spin rounds 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:

go
// src/runtime/proc.go:8031
func internal_sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}

with the constants:

go
// 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:

go
// 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, so queueLifo is false. 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, so queueLifo is true, 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:

go
// 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.next

The 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:

go
// src/internal/sync/mutex.go:150
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs

starvationThresholdNs 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

go
// 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:

go
// 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:

go
// 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

go
// 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:

go
// 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:

go
// 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 << 30

readerCount 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:

go
// 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:

go
// 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:

go
// 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.

go
// 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 = 16

The 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:

go
// 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 real sync.Mutex: state word, lockSlow, unlockSlow, TryLock
  • src/sync/mutex.go — the public wrapper and the documented semantics
  • src/sync/rwmutex.go — RWMutex, the writer-pending signal and the reader semaphores
  • src/runtime/sema.go — semacquire1, semrelease1, cansemacquire, the handoff flag and the treap of waiters
  • src/runtime/proc.go — runtime_canSpin, runtime_doSpin, goyield
  • src/runtime/lock_spinbit.go — the runtime’s own spinbit mutex, a different design for a different job
  • src/runtime/runtime2.go — the sudog struct, including the waiters count used by the semaphore

GoInternalsConcurrencyMutex