sync.Mutex

When multiple goroutines read and write the same variable at the same time, the result is a data race — a bug where the outcome depends on unpredictable timing between goroutines. Go’s sync.Mutex (short for "mutual exclusion") is the fundamental tool for preventing this: it lets only one goroutine at a time enter a section of code that touches shared data, so the rest of the program can rely on that data staying consistent.

Overview / How it works

A sync.Mutex is a struct with two states: locked and unlocked. It starts unlocked (its zero value is ready to use — there is no constructor to call). A goroutine calls Lock() to acquire it and Unlock() to release it. If a goroutine calls Lock() while another goroutine already holds the lock, it blocks — the Go runtime parks that goroutine (it is not spinning or burning CPU) until the lock becomes available, then wakes it up and hands it the lock.

Internally, sync.Mutex is implemented with a 32-bit state word manipulated via atomic compare-and-swap operations, plus a runtime semaphore used to park and wake blocked goroutines efficiently. The state word packs together whether the mutex is locked, whether a goroutine has been woken but not yet acquired the lock, whether the mutex is in "starvation mode", and a count of waiting goroutines. Under low contention, acquiring an unlocked mutex is a single fast atomic compare-and-swap — essentially free. Under high contention, Go’s mutex normally hands the lock to whichever waiter is next in a semaphore queue (not strictly FIFO), but if a goroutine has been waiting more than 1ms it switches to starvation mode, handing the lock directly to the longest-waiting goroutine in FIFO order so no goroutine is starved indefinitely. You never need to configure this — it happens automatically — but it explains why Go’s mutex is both fast in the common case and fair under load.

A sync.Mutex protects data, not code: the convention is to place the mutex next to the field(s) it guards (often as an unexported field in a struct) and to document which fields require the lock. Anyone calling a method that touches those fields must hold the lock for the entire time they read or write them, and release it as soon as they’re done so other goroutines aren’t kept waiting longer than necessary.

Go also provides sync.RWMutex, a reader/writer variant. It allows any number of concurrent readers (via RLock/RUnlock) as long as no writer holds the lock, but a writer (via Lock/Unlock) gets exclusive access with no readers or other writers active. This is a valuable optimization when reads vastly outnumber writes, since concurrent readers don’t have to queue behind each other the way they would with a plain Mutex.

Syntax

var mu sync.Mutex

mu.Lock()
// critical section: code that touches shared data
mu.Unlock()

ok := mu.TryLock() // non-blocking; returns false if already locked
Method Type Meaning
Lock() Mutex / RWMutex Blocks until the mutex is free, then acquires exclusive access.
Unlock() Mutex / RWMutex Releases exclusive access. Panics if called on an unlocked mutex.
TryLock() Mutex / RWMutex Attempts to acquire the lock without blocking; returns true/false.
RLock() RWMutex Acquires a shared read lock; multiple readers may hold it at once.
RUnlock() RWMutex Releases a read lock acquired with RLock().
  • The zero value of sync.Mutex (and sync.RWMutex) is already unlocked and ready to use — just declare var mu sync.Mutex.
  • A mutex must never be copied after it has been used; always share it via a pointer or embed it in a struct that itself is passed by pointer.
  • TryLock (added in Go 1.18) is rarely the right default — prefer blocking Lock unless you have a specific reason to avoid waiting.

Examples

Example 1: A safe counter shared by many goroutines

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu    sync.Mutex
	value int
}

func (c *Counter) Increment() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

func main() {
	var wg sync.WaitGroup
	counter := &Counter{}

	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 100; j++ {
				counter.Increment()
			}
		}()
	}

	wg.Wait()
	fmt.Println("Final counter value:", counter.Value())
}

Output:

Final counter value: 5000

Fifty goroutines each increment the counter 100 times, for 5000 total increments. Without the mutex, many of those increments would be lost: c.value++ is really a read, an add, and a write, and if two goroutines interleave those three steps the result is wrong. The Lock()/Unlock() pair around the increment forces every goroutine to perform its read-modify-write as one atomic step relative to the others, so the final value is always exactly 5000.

Example 2: TryLock for a non-blocking attempt

package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu sync.Mutex

	mu.Lock()
	locked := mu.TryLock()
	fmt.Println("TryLock while held:", locked)
	mu.Unlock()

	locked = mu.TryLock()
	fmt.Println("TryLock while free:", locked)
	if locked {
		mu.Unlock()
	}
}

Output:

TryLock while held: false
TryLock while free: true

The first call to TryLock() happens while the mutex is already locked, so it immediately returns false instead of blocking. After Unlock() is called, the mutex is free, so the second TryLock() succeeds and returns true — at which point the caller now owns the lock and is responsible for unlocking it, exactly as with a normal Lock().

Example 3: sync.RWMutex for many readers, few writers

package main

import (
	"fmt"
	"sync"
)

type Account struct {
	mu      sync.RWMutex
	balance int
}

func (a *Account) Balance() int {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return a.balance
}

func (a *Account) Deposit(amount int) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.balance += amount
}

func main() {
	account := &Account{}
	var wg sync.WaitGroup

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			account.Deposit(100)
		}()
	}

	wg.Wait()
	fmt.Println("Balance:", account.Balance())
}

Output:

Balance: 1000

Deposit takes the exclusive write lock because it mutates balance, while Balance only needs the shared read lock because it just reads. Ten goroutines each deposit 100, giving a final balance of 1000. If many goroutines called Balance() concurrently while few called Deposit(), an RWMutex lets all those readers proceed in parallel instead of queuing one-by-one behind a plain Mutex, which only ever allows one goroutine in at a time regardless of whether it’s reading or writing.

How it works step by step

  • A goroutine calls mu.Lock(). If the mutex’s internal state shows it as unlocked, an atomic compare-and-swap flips it to locked and the goroutine proceeds immediately — no blocking involved.
  • If the mutex is already locked, the calling goroutine is added to a wait queue and parked by the runtime’s semaphore mechanism. Parking means the goroutine is descheduled entirely; it consumes no CPU while it waits.
  • The goroutine holding the lock runs its critical section — the code between Lock() and Unlock() — and then calls Unlock().
  • Unlock() clears the locked bit and, if there are waiters, wakes one of them via the semaphore. That goroutine resumes and now holds the lock.
  • If a waiter has been queued for over 1ms, the mutex switches into starvation mode: instead of letting newly-arriving goroutines race for the lock, it hands the lock directly to the front of the queue in strict FIFO order until the queue drains, guaranteeing no goroutine waits forever.

Common Mistakes

Mistake 1: Locking without a matching, guaranteed Unlock

func (c *Counter) Increment() {
	c.mu.Lock()
	if c.value > 1000 {
		return // BUG: returns without unlocking -- every future Lock() blocks forever
	}
	c.value++
	c.mu.Unlock()
}

Any early return, or a panic, between Lock() and the plain Unlock() call skips the unlock entirely. Every other goroutine that later calls Lock() on this mutex will block forever — a deadlock. The fix is to call defer c.mu.Unlock() immediately after Lock(), so the unlock always runs when the function returns, no matter which path it takes:

func (c *Counter) Increment() {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.value > 1000 {
		return
	}
	c.value++
}

Mistake 2: Copying a struct that contains a Mutex

type Counter struct {
	mu    sync.Mutex
	value int
}

func printValue(c Counter) { // BUG: takes Counter BY VALUE, copying the mutex
	c.mu.Lock()
	defer c.mu.Unlock()
	fmt.Println(c.value)
}

Passing c Counter by value copies the sync.Mutex field along with everything else. The copy starts out in whatever lock state the original happened to be in, and from then on the two mutexes are completely independent — locking the copy does nothing to protect the original’s data, defeating the whole point of the lock. go vet catches this (it reports "passes lock by value") precisely because it’s such an easy mistake to make. Always take a pointer receiver or pointer parameter for any type containing a mutex:

func printValue(c *Counter) {
	c.mu.Lock()
	defer c.mu.Unlock()
	fmt.Println(c.value)
}

Best Practices

  • Keep critical sections small: lock, do the minimum work necessary, unlock. Never do I/O, network calls, or other slow operations while holding a mutex.
  • Use defer mu.Unlock() right after Lock() so the unlock is guaranteed even on early returns or panics.
  • Place the mutex as a field in the struct it protects, and document (or name fields clearly) so it’s obvious which fields require the lock to be held.
  • Never copy a value that contains a sync.Mutex after it has been used — pass structs containing mutexes by pointer, and run go vet to catch accidental copies.
  • Prefer sync.RWMutex over sync.Mutex only when reads genuinely outnumber writes by a wide margin; for balanced or write-heavy workloads a plain Mutex is simpler and often just as fast.
  • Don’t call Lock() again on the same mutex from the same goroutine while already holding it (Go’s Mutex is not reentrant) — this deadlocks instead of succeeding.
  • Run tests with the race detector (go test -race or go run -race) regularly; it will catch missing or misplaced locks that manual review misses.

Practice Exercises

  • Write a SafeMap struct wrapping a map[string]int with a sync.Mutex, exposing Set(key string, value int) and Get(key string) (int, bool) methods. Launch 20 goroutines that each call Set with a unique key, then print the map’s length after wg.Wait() — it should always be exactly 20.
  • Take the Counter from Example 1 and deliberately remove the mu.Lock()/mu.Unlock() calls in Increment. Run the program several times with go run -race and observe that the printed value is often less than 5000 and that the race detector reports a data race.
  • Convert the SafeMap from the first exercise to use sync.RWMutex instead, with Get using RLock/RUnlock and Set using Lock/Unlock. Explain in a comment why this change is safe.

Summary

  • sync.Mutex lets only one goroutine at a time execute the code between Lock() and Unlock(), preventing data races on shared state.
  • Its zero value is ready to use; never copy a mutex (or a struct containing one) after first use — pass it by pointer.
  • defer mu.Unlock() immediately after Lock() guarantees the lock is released even on early returns or panics.
  • sync.RWMutex allows many concurrent readers or one exclusive writer, which helps when reads greatly outnumber writes.
  • TryLock() attempts a non-blocking lock acquisition and reports success via its boolean return value.
  • Go’s mutex uses fast atomic operations when uncontended and switches to a fair, FIFO starvation mode when a goroutine has waited too long.
  • Use go vet and go test -race to catch lock-copying bugs and data races that are easy to miss by reading code alone.