Go: Under the Hood Under the Hood
中文

11.2 Mutex

The contract of sync.Mutex is simple: the zero value is an unlocked lock; Lock blocks the caller when the lock is taken and Unlock releases it; and the lock does not belong to any goroutine (11.1.3). The core question that an implementation must answer is not simple: when the lock is released, to whom does it go? Handing it to the next waiter strictly in order of arrival is the fairest, but every handoff has to wait until the awakened goroutine actually runs. Letting a goroutine that is already running grab it first gives higher throughput, but may let some waiter lose out again and again. Go’s mutex has gone from one of these approaches to the other and back. This section first introduces the theory and hardware foundations of the mutual exclusion problem, then looks at Go’s implementation, and follows its history to explain the trade-off.

11.2.1 The Mutual Exclusion Problem and Its Foundations

A segment of a program that accesses shared data and into which only one unit of execution may enter at a time is called a critical section. Ensuring this is the mutual exclusion problem. 11.1.1 mentioned that in “Cooperating Sequential Processes” Dijkstra first tried to solve it with ordinary reads and writes alone and gave Dekker’s solution for two processes; in 1965, in a one-page note, Dijkstra gave a general solution for N processes [Dijkstra, 1965]. In 1974 Lamport proposed the bakery algorithm [Lamport, 1974]: processes take a number as in a bakery, first choosing a number larger than all numbers already issued, and then enter the critical section in increasing order of number. Each shared variable is written by only one process. Lamport later wrote that he discovered only after finishing the correctness proof that the proof does not depend on what value a read that overlaps a write returns: the algorithm remains correct even if the read returns an arbitrary value [Lamport, nd].

Algorithms that use only reads and writes prove that mutual exclusion is solvable in principle, but their cost grows with the number of processes (the bakery algorithm checks the numbers of all processes), and they require the memory to execute reads and writes in program order, which on modern processors takes additional memory barriers (11.9). Practical locks are all built on the atomic read-modify-write instructions that hardware provides, such as compare-and-swap (CAS) and fetch-and-add (11.3): a single instruction performs “read the old value, test it, write the new value”, and the problem of one-way access that Dijkstra described is solved by the hardware.

Given atomic instructions, there are still two ways to wait. One is spinning: retrying in a loop until it succeeds. Spinning is cheapest when the lock will be released very soon, but the waiter keeps occupying a processor, and all waiters repeatedly read and write the same variable, so that the cache line holding it bounces between processors. The ticket lock issues numbers with fetch-and-add so that waiters acquire the lock in order; the MCS lock of Mellor-Crummey and Scott has each waiter spin on its own variable, and a handoff writes only the successor’s variable [Mellor-Crummey and Scott, 1991]. The other way is blocking: the waiter gives up the processor and sleeps, and the releaser wakes it. In the past this meant entering the kernel on every acquisition; the Linux futex changed that [Franke et al., 2002]. The state of the lock lives in user space, so that an uncontended acquisition needs only atomic operations, and the kernel is entered only under contention, when queuing and scheduling are needed.

sync.Mutex combines the two: one CAS when uncontended; under contention a brief spin first (11.2.3), and blocking if the lock still cannot be taken. Blocking uses the runtime semaphore, whose comment at the top of runtime/sema.go says that its goal is the same as that of the Linux futex, with much simpler semantics [Go Authors, 2026a]. The difference is that it suspends a goroutine, not a thread (11.1.4). The futex sits at a lower level in Go: when the runtime’s internal locks and notes need to put a thread M to sleep, they use futexes on Linux and some other systems (9.5.2).

11.2.2 The State Word and the Fast Path

Since Go 1.24, sync.Mutex is only a wrapper around internal/sync.Mutex (11.1.4), which has just two fields [Go Authors, 2026b]:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Mutex struct {
	state int32
	sema  uint32
}

const (
	mutexLocked = 1 << iota // mutex is locked
	mutexWoken
	mutexStarving
	mutexWaiterShift = iota

	starvationThresholdNs = 1e6
)

sema is the address handed to the runtime semaphore. state is a bit field. The lowest bit, mutexLocked, says that the lock is held. mutexWoken says that a goroutine that has been woken or is spinning is ready to take the lock, so the unlocker need not wake anyone else. mutexStarving says that the lock is in starvation mode (11.2.4). The remaining high bits record the number of waiters, and adding or removing a waiter adds or subtracts 1<<mutexWaiterShift. Keeping all this information in one 32-bit integer lets a single CAS check and modify it all at once.

The two fast paths are as follows (code related to the race detector is omitted):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
}

func (m *Mutex) Unlock() {
	// Fast path: drop lock bit.
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		// Outlined slow path to allow inlining the fast path.
		m.unlockSlow(new)
	}
}

The locking CAS succeeds only when state is 0: unlocked, no waiters and no flags. Unlocking subtracts mutexLocked; a result of 0 means no one is waiting and the unlock is finished. The slow paths are split into separate functions to keep the fast paths small enough for the compiler to inline them at the call site. These are two changes that Ferraris made in Go 1.13, and inlining Lock alone made an uncontended lock about 14% faster [Ferraris, 2018a] [Ferraris, 2018b].

11.2.3 The Slow Path: Spinning and Queuing

Under contention, a goroutine is in no hurry to sleep: a lock is often held only briefly, and the cost of sleeping and waking can exceed the wait itself. Go 1.5 added limited active spinning to Mutex, contributed by Vyukov [Vyukov, 2015]. The runtime’s canSpin allows spinning only when all of the following hold: the goroutine has spun fewer than 4 times so far; the machine has more than one CPU; at least one P other than the current one is running; and the current P’s local run queue is empty [Go Authors, 2026c]. Each spin calls procyield(30), which on amd64 executes the PAUSE instruction 30 times. In the commit message Vyukov explains why to spin: previously sync.Mutex entered the scheduler as soon as it detected contention, although the lock might be released very soon, especially when the critical section is short; and CPU utilization of server programs is usually around 50%, so switching to run other goroutines is not necessarily worthwhile. Spinning must also be conservative, because a spinner occupies a P that other goroutines may be waiting to use; and unlike the runtime’s internal locks, no passive spinning that yields the thread is done here, because there may be work in the global queue or on other Ps.

While spinning, if there are waiters and no one has been woken yet, the spinner sets mutexWoken to tell the unlocker that someone is already ready to take the lock: on seeing this bit the unlocker no longer wakes a waiter, and the lock is left to the spinner.

When the conditions for spinning no longer hold, lockSlow computes the new state, registers itself as a waiter with a CAS, and then sleeps on the semaphore:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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
	// ...

A goroutine queuing for the first time goes to the tail of the queue; one that was woken, failed to get the lock and must queue again goes to the head. Before Go 1.9 the latter also went to the tail, which was less fair when there were many waiters [Vyukov, 2016]. waitStartTime records the time of the first queuing and decides when starvation mode, described in the next section, is entered.

The semaphore itself is only a uint32 count. semacquire first tries to decrement it with a CAS and queues only if that fails; queued goroutines are organized by semaphore address in the global semtable. The table has 251 buckets, a prime number chosen to avoid correlation with regularities in the addresses used by user programs. Each bucket is protected by a runtime-internal lock, and within the bucket is a treap keyed by address; each node represents one address and holds the list of goroutines waiting on that address. Enqueuing and dequeuing at the same address is O(1), and lookup among different addresses is O(log n) [Go Authors, 2026a]. This structure took shape step by step. In 2011 Vyukov replaced the single centralized wait table with a distributed table hashed by address, and made the releaser take no lock when there are no waiters [Vyukov, 2011a]. In 2017 Cox found that when several locks hash to the same bucket, finding the waiters of one lock scans all the waiters of another, so that n lock acquisitions cost O(n²) in total (#17953); he first changed the singly linked list in the bucket into a two-level list grouped by address, and then replaced the top level with a treap [Cox, 2017a] [Cox, 2017b].

11.2.4 Handoff, Barging and Starvation Mode

Go’s answer to whom the lock goes when it is released has changed twice.

The first Mutex, written by Cox in 2008, had only a counter and a semaphore [Cox, 2008]. The following is the code before the 2011 change, with the same algorithm as in 2008:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (m *Mutex) Lock() {
	if atomic.AddInt32(&m.key, 1) == 1 {
		// changed from 0 to 1; we hold lock
		return
	}
	runtime.Semacquire(&m.sema)
}

func (m *Mutex) Unlock() {
	switch v := atomic.AddInt32(&m.key, -1); {
	case v == 0:
		// changed from 1 to 0; no contention
		return
	case v == -1:
		// changed from 0 to -1: wasn't locked
		// (or there are 4 billion goroutines waiting)
		panic("sync: unlock of unlocked mutex")
	}
	runtime.Semrelease(&m.sema)
}

key counts holders and waiters together. If there are still waiters at unlock time, key remains greater than zero and the lock has not really been released: the goroutine woken by Semrelease holds the lock as soon as it wakes. This approach is called handoff: the lock stays held throughout, and no one can cut in front of the awakened goroutine.

In 2011 Vyukov separated “locked” from “number of waiters”, forming the prototype of today’s state [Vyukov, 2011b]. Unlocking clears the lock bit and then wakes one waiter; the awakened goroutine must compete again with the goroutines that arrive in the meantime. The commit message says that the new implementation allows a goroutine to acquire the lock several times in a row while there are blocked goroutines, and also allows newcomers to acquire the lock ahead of blocked ones, that is, first-in-first-out is no longer enforced. This approach is called barging. On a heavily contended benchmark the effect is an order of magnitude: with GOMAXPROCS at 16, BenchmarkMutex dropped from 1380 ns to 96.3 ns.

Why is barging faster? Lea also chose barging when designing Java’s java.util.concurrent, on the grounds that it shortens the time during which the lock is free but unheld because the next thread is still being woken [Lea, 2004]. Pizlo of WebKit explains from another angle: on a strictly first-in-first-out lock, contenders form a “convoy”, each holding the lock only once per round and each acquisition accompanied by a context switch; barging lets a running thread acquire the lock several times in one round [Pizlo, 2016].

The price is fairness. In the same section Lea points out that a barging lock is fair only in a probabilistic sense: if newcomers arrive faster than the awakened thread can wake, the thread at the head of the queue rarely wins. In 2015 Cox reported an extreme example in #13086 [Cox, 2015]: one goroutine holds the lock almost all the time, releasing it only briefly each time; another goroutine needs the lock once every 100 µs but often has to wait more than 100 seconds. The reason is that the former keeps running after unlocking and waking the latter, and takes the lock back before the latter is actually scheduled; this repeats millions of times before the latter succeeds once. Cox proposed falling back to handoff when severe unfairness is detected, and wrote that he did not want to see blog posts explaining when to use sync.Mutex and when to use sync.FairMutex.

A year later Vyukov supplied an implementation, released with Go 1.9 [Vyukov, 2016]. The comments in the source describe it as two modes. In normal mode, waiters queue in first-in-first-out order, but an awakened waiter does not own the lock and must compete with newly arriving goroutines. The newcomers are running on a CPU and may be many, so the awakened waiter is likely to lose, and when it does it is put back at the head of the queue. If a waiter fails to acquire the lock for more than 1 ms, it switches the lock to starvation mode. In starvation mode, ownership of the lock is handed off directly from the unlocker to the waiter at the head of the queue; newcomers do not try to acquire the lock even if it looks free, and do not spin, but queue at the tail. A waiter that acquires the lock switches it back to normal mode if it is the last waiter in the queue or if it waited less than 1 ms. The comment sums up: normal mode has much better performance, because a goroutine can acquire the lock several times in a row even when there are blocked waiters; starvation mode exists to prevent pathological tail latency.

stateDiagram-v2
    direction LR
    state "Normal mode" as Normal
    state "Starvation mode" as Starving
    Normal --> Starving: an awakened waiter has waited more than 1 ms and still has not got the lock
    Starving --> Normal: the waiter that gets the lock is the last one, or waited less than 1 ms

The exit condition “waited less than 1 ms” is not optional. The code comment says that starvation mode is very inefficient, and two goroutines that switch the lock into starvation mode could otherwise alternate in holding it indefinitely, so the mode must be exited here based on the waiting time.

The two modes on the unlocking side look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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)
	}
}

In normal mode, the unlocker first acquires “the right to wake someone” with a CAS and then releases the semaphore; what the awakened goroutine gets is only the chance to compete, not the lock itself. This also answers a question readers often ask: if the goroutine woken is the one at the head of the queue, why can waiters still starve? Because in normal mode, waking is not handing off.

In starvation mode the second parameter of Semrelease, handoff, is true: the runtime performs the semaphore decrement on behalf of the awakened goroutine, so no other goroutine can snatch it. Go 1.14 went one step further [Ferraris, 2019]: the releaser puts the waiter into the current P’s runnext and yields immediately, and the waiter inherits the remaining time slice and starts running at once. The comment in sema.go explains why this is done only in starvation mode: in normal mode, another goroutine may acquire the semaphore during the yield, which would make the yield wasted.

The following program reproduces the scenario in #13086:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var mu sync.Mutex
	done := make(chan struct{})

	// The first goroutine holds the lock almost all the time, releasing it only briefly between acquisitions.
	go func() {
		for {
			select {
			case <-done:
				return
			default:
				mu.Lock()
				time.Sleep(100 * time.Microsecond)
				mu.Unlock()
			}
		}
	}()

	// The main goroutine needs the lock only occasionally and holds it very briefly.
	var longest time.Duration
	for range 50 {
		time.Sleep(100 * time.Microsecond)
		start := time.Now()
		mu.Lock()
		longest = max(longest, time.Since(start))
		mu.Unlock()
	}
	close(done)
	fmt.Printf("longest wait in 50 acquisitions: %.1f ms\n", float64(longest)/1e6)
}

The output of one run is:

longest wait in 50 acquisitions: 1.3 ms

After the main goroutine queues, each time it is woken it fails to beat the other goroutine and goes back to the head of the queue. When it finally wakes having waited more than 1 ms, it switches the lock to starvation mode as it requeues, and the other goroutine’s next unlock hands the lock directly to it. The longest wait is therefore about 1 ms plus the length of one or two critical sections. What Cox measured in 2015 was more than 100 seconds; in Vyukov’s commit message the same program waits about 1.2 ms.

The interactive figure below simulates this program with the rules of lockSlow and unlockSlow, with G1 standing for the goroutine that holds the lock almost all the time and main for the main goroutine, and shows how the fields of state change in each round. Two timings in it are assumptions: a woken goroutine starts running about 5 µs later, while G1 locks again within 1 µs of unlocking. Step through it to watch main wake up and go back to the head of the queue again and again until, having waited 1 ms, it switches the lock to starvation mode. Switch to Go 1.8 to see main’s wait keep growing when there is no starvation mode.

11.2.5 The Spectrum of Fairness

Other systems have made different choices on this question.

Java’s ReentrantLock leaves the choice to the user: fairness can be requested at construction, and the default is unfair. The documentation warns that a fair lock used by many threads may have much lower throughput, but less variance in the time to obtain the lock, and guarantees freedom from starvation; and fairness of the lock does not imply fairness of thread scheduling [Oracle, 2023].

WebKit’s WTF::Lock chooses barging, and Pizlo justifies the choice with an experiment [Pizlo, 2016]: with ten threads contending for one lock for 100 ms, the operating system’s mutex is perfectly fair, but each thread gets the lock at most about 3000 times; WTF::Lock does not guarantee fairness, yet even the unluckiest thread got about 557,000 acquisitions, roughly 180 times as many. Pizlo concludes that fair algorithms suit cases where critical sections are all long and a bounded maximum wait must be guaranteed; for programs with short critical sections, the cost of guaranteeing fairness is too high.

Rust’s parking_lot borrows from the WebKit design. Up to 0.12.5 it used “eventual fairness”: on average every 0.5 ms it forces a fair unlock, handing the lock to the next waiter [d’Antras, 2025]; version 0.13, released in October 2026, removed the automatic eventual fairness and kept only the explicit fair-unlock interface [d’Antras, 2026].

Go offers no choice: there is only one Mutex, and it switches automatically between the two modes with 1 ms as the boundary. This agrees with what Cox wanted in #13086: users are better off not having to know these details, let alone think about them.

11.2.6 Read-Write Locks

The readers-writers problem was posed by Courtois, Heymans and Parnas in 1971 [Courtois et al., 1971]: several readers may access the data at the same time, while a writer must have exclusive access. The structure of sync.RWMutex is as follows [Go Authors, 2026d]:

1
2
3
4
5
6
7
8
9
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 records the number of readers. RLock adds one to it and returns directly if the result is non-negative; RUnlock subtracts one. The reader’s fast path is a single atomic add and takes no lock. This is the result of Vyukov’s 2011 rewrite, which made an uncontended read lock nearly twice as fast [Vyukov, 2011c].

When a writer locks, it first excludes other writers with w, then subtracts rwmutexMaxReaders (2^30) from readerCount, making it negative: readers arriving afterward see the negative number in RLock and wait on readerSem. Adding 2^30 back to the result of the subtraction gives the number of readers still holding the read lock at that moment; the writer adds it to readerWait and waits on writerSem for those readers to leave, and the last reader to leave wakes the writer. When the writer unlocks, it adds 2^30 back to readerCount; the result is the number of readers blocked in the meantime, which the writer wakes one by one before releasing w.

This arrangement gives priority to writers: the documentation states that if a goroutine calls Lock while the read lock is held, all subsequent RLock calls block until the writer has acquired and released the lock, which guarantees that the writer eventually gets the lock. The cost is that read locks cannot be taken recursively: if a goroutine already holding a read lock calls RLock again and a writer arrives in between, the second RLock waits for the writer, and the writer waits for the first read lock to be released, so the two wait for each other. A read lock also cannot be upgraded to a write lock, nor a write lock downgraded to a read lock.

In the terms of the memory model (11.9): the n-th call to Unlock synchronizes before the m-th call to Lock (n < m); for any call to RLock, there exists an n such that the n-th call to Unlock synchronizes before that RLock, and the matching RUnlock synchronizes before the (n+1)-th call to Lock [Go Authors, 2026d] [Go Authors, 2026e].

11.2.7 TryLock

In 2010, in the same email that explained why Go’s mutex is not reentrant, Cox wrote that he believed Go would eventually need TryLock [Cox, 2010]. It was actually added 11 years later. The 2021 proposal #45435 was at first opposed by Cox himself: locks protect invariants (11.1.3), and when the lock is held by someone else nothing can be said about the invariant, so TryLock would encourage imprecise thinking about locks [Cox, 2021a]. What changed his mind came from the race detector: a TryLock can also be implemented outside the library with a mutex and an atomic variable, but such an implementation creates spurious happens-before edges for the race detector even when it fails, masking real data races, whereas a TryLock built into sync does not. The commit message that implemented it states that using these functions is almost (but not always) a bad idea [Cox, 2021b]. Mutex.TryLock, RWMutex.TryLock and RWMutex.TryRLock were released with Go 1.18.

The implementation of Mutex.TryLock is a single CAS (code related to the race detector is omitted):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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
	}
	return true
}

It fails at once if the lock is held or in starvation mode; otherwise, even if there are waiters, it can take the lock ahead of the awakened one, which is exactly the barging of normal mode. By the memory model, a successful TryLock is equivalent to a Lock, and a failed TryLock establishes no synchronizes-before relation. The documentation cautions that there are indeed cases where TryLock is used correctly, but they are rare, and its use often signals a deeper problem in some use of the mutex.

11.2.8 Misuse and Diagnosis

All the state of a lock is in the value of the Mutex, so copying a lock yields another, independent lock, and if the lock is held at the moment of copying, the copy is held too. The documentation therefore requires that a Mutex must not be copied after first use, and the copylocks check of go vet reports such copies. Go 1.24 added an explicit noCopy field to Mutex, so that this check no longer depends on the internal structure of Mutex [Knyszek, 2024].

Calling Unlock on an unlocked Mutex terminates the program with fatal error: sync: unlock of unlocked mutex, which recover cannot catch. Before Go 1.8 this was an ordinary panic, but after the panic the lock was left in an unusable state, and keeping it usable would have made the lock significantly slower and less scalable; Cox therefore changed it to terminate the program outright [Cox, 2016].

Lock contention can be located with the mutex profile. It was added by Weinberger in Go 1.8: after sampling is enabled by calling runtime.SetMutexProfileFraction, the mutex profile of pprof records the call stacks of the holders of contended locks rather than those of the waiters [Weinberger, 2016]. The semrelease1 in sema.go mentioned earlier is exactly what, at unlock time, charges the delay suffered by the waiters to the unlocker. Since Go 1.22 this time is accumulated per blocked goroutine: 100 goroutines waiting 10 ms on the same lock count as 1000 ms of contention, consistent with the Google C++ (now Abseil) mutex profiler on which this profiler is modeled [Cox, 2023].

Further Reading