Skip to content

A mutex guards a counter

Level: 101 · anyone sharing a variable between goroutines

One line: Eight goroutines adding to one int lose increments, and the number lost changes on every run. Put a sync.Mutex around the increment and all 800,000 arrive.

Verified output of a_mutex_guards_a_counter_go.go — regenerated by tools/run_examples.py, never hand-typed.

workers:              8
views each:           100000
expected total:       800000
total with a Mutex:   800000

Reading the output

Every worker called record 100,000 times, and the count is exactly 800,000. record locks, increments and unlocks, so at most one goroutine is ever between Lock and Unlock. When a goroutine calls Lock on a mutex that is already in use, it waits until the mutex is free. The zero value of a Mutex is an unlocked mutex, which is why pageViews needs no constructor.

Without the lock. Take the locking out and the program is demo/lost_updates.go: the same eight workers, with count++ on a plain int. Here it is run ten times:

Real runs — go1.25.5, x86-64 Mac (Intel Core i5-10500, 12 logical CPUs), macOS 26.6.2, 10 runs, 2026-09-14
go version go1.25.5 darwin/amd64
logical CPUs: 12
313240 of 800000
226726 of 800000
264085 of 800000
265054 of 800000
241343 of 800000
272270 of 800000
271790 of 800000
252563 of 800000
241500 of 800000
296448 of 800000

No two runs agree, and every run lost more than 60% of the increments. count++ is three steps: read, add one, write. If another goroutine writes between one goroutine's read and its write, that write overwrites the other goroutine's increment, and the increment is gone. Because the total changes, this program lives in demo/ and not examples/: an answer key cannot record a number that varies. To count on your own machine, run bash demo/tally.sh from this folder, or bash demo/tally.sh 50 for fifty runs. Your numbers will not match these.

A total that happened to come out right would not make the program correct. The Go memory model ↗ requires access to data that goroutines modify at the same time to be serialized, whatever the arithmetic produces. The race detector shows how to prove a race is there, instead of inferring it from a wrong total.

defer p.mu.Unlock(). record unlocks with defer, so the unlock runs however record returns: from its last line, from an early return someone adds later, or during a panic. The next program shows why that last case matters.

a_mutex_guards_a_counter_go.go

a_mutex_guards_a_counter_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Eight goroutines each count 100,000 page views into one shared counter. With
// a sync.Mutex around the increment, not one view is lost. The same program
// without the lock is demo/lost_updates.go, and its total changes on every run.
//
//  go build a_mutex_guards_a_counter_go.go && ./a_mutex_guards_a_counter_go
package main

import (
    "fmt"
    "sync"
)

const (
    workers   = 8
    perWorker = 100_000
)

// pageViews keeps the mutex next to the value it guards.
type pageViews struct {
    mu    sync.Mutex
    count int
}

func (p *pageViews) record() {
    p.mu.Lock()
    defer p.mu.Unlock() // runs on every way out of record, a panic included
    p.count++
}

func main() {
    var views pageViews // the zero value is an unlocked mutex and a count of 0
    var wg sync.WaitGroup
    for range workers {
        wg.Go(func() {
            for range perWorker {
                views.record()
            }
        })
    }
    wg.Wait()

    fmt.Printf("workers:              %d\n", workers)
    fmt.Printf("views each:           %d\n", perWorker)
    fmt.Printf("expected total:       %d\n", workers*perWorker)
    fmt.Printf("total with a Mutex:   %d\n", views.count)
}

A panic and a locked mutex

Two deposit methods that differ in one thing: one unlocks with defer, the other on its last line. Both lock, then panic on a negative amount. recovered catches each panic, the way net/http recovers a panic in a handler and keeps serving other requests.

Verified output of a_mutex_guards_a_counter_defer_go.go — regenerated by tools/run_examples.py, never hand-typed.

defer mu.Unlock():
  recovered panic:      deposit of -5
  unlocked afterwards:  true
  next deposit, balance: 150
mu.Unlock() on the last line:
  recovered panic:      deposit of -5
  unlocked afterwards:  false

With defer mu.Unlock(), the deferred call ran while the panic unwound out of depositDefer, and the mutex was unlocked afterwards. The next deposit locked it as usual. Lock returns nothing, so there was no error to check and no state to clear. A Go Mutex has no record that a panic happened.

With mu.Unlock() on the last line, the panic skipped that line. The mutex stayed locked with no code left to unlock it, so the next Lock on it would wait forever.

Without a recover somewhere, neither case arises: a panic that nothing recovers ends the whole program, as A panic ends the whole program shows. TryLock is here only as a probe for "is it locked?". Its documentation ↗ says correct uses of it exist but are rare.

a_mutex_guards_a_counter_defer_go.go

a_mutex_guards_a_counter_defer_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// What a panic does to a locked sync.Mutex: nothing. The mutex has no record of
// the panic. If the function that locked it deferred the Unlock, the panic
// unlocks it on the way out; if the Unlock is an ordinary last line, the panic
// skips it and the mutex stays locked.
//
// A panic that nothing recovers ends the whole program, locks and all, so this
// only matters where something recovers: net/http, for one, recovers a panic in
// a handler and keeps serving. recovered below plays that part.
//
// TryLock is used only as a probe, to ask "is it locked?" without blocking.
//
//  go build a_mutex_guards_a_counter_defer_go.go && ./a_mutex_guards_a_counter_defer_go
package main

import (
    "fmt"
    "sync"
)

type account struct {
    mu      sync.Mutex
    balance int
}

// depositDefer unlocks with defer.
func (a *account) depositDefer(amount int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    if amount <= 0 {
        panic(fmt.Sprintf("deposit of %d", amount))
    }
    a.balance += amount
}

// depositLastLine unlocks on its last line, which a panic never reaches.
func (a *account) depositLastLine(amount int) {
    a.mu.Lock()
    if amount <= 0 {
        panic(fmt.Sprintf("deposit of %d", amount))
    }
    a.balance += amount
    a.mu.Unlock()
}

// recovered calls f and returns the value it panicked with, or nil.
func recovered(f func()) (value any) {
    defer func() { value = recover() }()
    f()
    return nil
}

// unlocked reports whether nobody holds mu, leaving it as it found it.
func unlocked(mu *sync.Mutex) bool {
    if mu.TryLock() {
        mu.Unlock()
        return true
    }
    return false
}

func main() {
    var a account
    a.depositDefer(100)
    fmt.Println("defer mu.Unlock():")
    fmt.Println("  recovered panic:     ", recovered(func() { a.depositDefer(-5) }))
    fmt.Println("  unlocked afterwards: ", unlocked(&a.mu))
    a.depositDefer(50) // an ordinary Lock: no error to check, no flag to clear
    fmt.Println("  next deposit, balance:", a.balance)

    var b account
    b.depositLastLine(100)
    fmt.Println("mu.Unlock() on the last line:")
    fmt.Println("  recovered panic:     ", recovered(func() { b.depositLastLine(-5) }))
    fmt.Println("  unlocked afterwards: ", unlocked(&b.mu))
}

What to do

  • Keep the mutex in the same struct as the fields it guards, and touch those fields only through methods that lock. Nothing in Go ties the fields to the mutex. The struct and its methods are the only place that pairing is written down.
  • Write defer mu.Unlock() on the line after mu.Lock(), so no early return or panic can leave the mutex locked. If a long function needs the lock for only a few lines, move those lines into their own function rather than unlocking by hand.
  • Never copy a Mutex after first use. The sync.Mutex documentation ↗ says so in exactly those words. Pass a struct that contains one by pointer, and give its methods pointer receivers, as record has. go vet's copylocks check reports copies.
  • Don't reach for a channel just to guard a variable. A counter or a cache is state, and the Go wiki ↗ suggests a mutex for state. The chapter introduction has the rest of that advice.
  • For a single number, consider an atomic. Atomic counters gets the same exact total with no lock.

In other languages

  • The Rust library's Data races ↗, for C and C++ programmers. Two pthreads incrementing one counter lose about half the increments at -O0, ThreadSanitizer reports the race, and Rust refuses to compile the unsynchronized version at all. Go compiles and runs it, as the Real runs above show, and leaves finding the race to the race detector.
  • The Rust library's Forgotten unlock ↗. A pthread mutex needs its unlock on every path out of the function. A Rust lock() returns a guard that unlocks when it goes out of scope, and the Rust Mutex contains the data it guards. Go's defer also covers every path, but it is a line you have to remember, beside a bare Unlock that compiles just as well. And a Go Mutex sits next to count, not around it, so nothing stops code from touching count without locking.
  • The Rust library's Lock poisoning ↗. A Rust Mutex is poisoned when a thread panics while holding its guard, and every later lock() returns an error. A Go Mutex has no such state, as the second program shows: after a deferred unlock it is simply unlocked, and after a skipped one it is simply still locked.
  • The Rust library's Sharing across threads: Arc ↗ and Send and Sync (an outline so far). In Rust, the compiler decides what may be shared between threads, and a shared counter is written Arc<Mutex<i64>>. Go has no such check: any goroutine can capture any variable, locked or not.
  • The Concurrency library's chapters 02 Shared state (lost updates, mutexes and atomics) and 03 When locks go wrong (forgotten unlocks and poisoning) are planned, each in six languages.

Sources

  • sync.Mutex: Lock, Unlock and TryLock, the zero value, and "must not be copied after first use".
  • The Go Memory Model ↗: what a data race is, and the rule that access must be serialized.
  • Go wiki: Use a sync.Mutex or a channel? ↗
  • Defer, Panic, and Recover ↗: deferred calls run while a panic unwinds.
  • Katherine Cox-Buday, Concurrency in Go, chapter 3 "Go's Concurrency Building Blocks", the section on Mutex and RWMutex. Jon Bodner, Learning Go, 2nd ed., chapter 12 "Concurrency in Go", the section "When to Use Mutexes Instead of Channels". James Cutajar, Learn Concurrent Programming with Go, chapter 4 "Synchronization with mutexes".