Locks, Mutexes & Atomics
Why counter++ breaks when two goroutines share it, how mutexes, RWMutex, sync.Once and atomics fix it, what “lock-free” really promises — and when JavaScript needs a lock at all.
The 30-second version
Here's the problem in one widget. Four goroutines each add 1 to a shared counter a thousand times. Run it a few times with plain counter++, then switch to the other two versions:
var counter int
var wg sync.WaitGroup
for g := 0; g < 4; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
counter++ // ← unprotected
}
}()
}
wg.Wait()
fmt.Println(counter)Nothing crashed, and there was no error message — the program just printed a different wrong number each run. That's what makes this kind of bug so nasty: it can pass every test on your laptop and then show up in production as "the numbers are a bit off".
The toolbox, at a glance
| Tool | What it does | Section |
|---|---|---|
sync.Mutex | One goroutine at a time in the protected code | §03 |
sync.RWMutex | Many readers at once, or one writer alone | §04 |
sync.Once | Run some setup exactly once, no matter how many goroutines ask | §05 |
sync/atomic | Make one small update (add, swap, compare-and-swap) a single unbreakable step | §06 |
| An async mutex | JavaScript's version, for await-shaped races | §08 |
And one question to keep asking all the way through: does this data really need to be shared? Often the best lock is none — give the data to one goroutine and send it messages instead (Channels covers that choice).
Why counter++ breaks
counter++ looks like one action, but the CPU does it in three steps. If two goroutines' steps get mixed together, one of the increments is lost.One line of Go, three CPU steps
A CPU core does its arithmetic on values held inside the core, not in memory. So even when counter++ compiles to a single machine instruction, the core internally does three things:
- LOAD the value from memory into a register (a tiny slot inside the core),
- ADD 1 to it there,
- STORE the result back to memory.
So counter++ is a read-modify-write. Between step 1 and step 3, the core is working on a private copy. If another core changes counter in that window, this core doesn't notice — and step 3 overwrites the other core's work.
You don't even need two cores. On a single core, the Go scheduler — the part of the runtime that decides which goroutine runs next — can pause a goroutine right after its LOAD, run another goroutine's counter++, and later resume the first one, which then STOREs its stale result.
Step through it with real numbers. counter starts at 41, two goroutines each do counter++ once, so the right answer is 43:
1// counter++ is really:2tmp := counter // 1. LOAD3tmp = tmp + 1 // 2. ADD4counter = tmp // 3. STORE
counter is 41. Two goroutines, G1 and G2, each run counter++ once, on two different CPU cores. The answer should be 43.This is called a data race
Two goroutines touching the same variable at the same time, at least one of them writing, with nothing coordinating them, is a data race. Lost updates are just the most visible symptom. In Go, a data race is a bug even if it seems to give the right answer, for two more reasons:
-
Stale values. Each core keeps private copies of the values it's using: in its registers, and in its own CPU cache (a small, fast copy of recently used memory). The compiler is also allowed to read a variable once and keep it in a register. So without coordination, one goroutine may not see another's write for a long time — or ever:
var done bool go func() { done = true }() for !done { } // may spin forever: nothing tells this loop to re-read done§03 shows, step by step, why this can hang and how a lock fixes it.
-
Torn values. Values bigger than one machine number (8 bytes on a 64-bit CPU) are written in several pieces. A Go string, for example, is a pointer to the bytes plus a length, stored separately. A reader can catch the new pointer with the old length — and read memory it shouldn't, which can crash the program.
Does JavaScript have this problem?
Not in plain JavaScript: your code runs on one thread, and a line like count++ is never interrupted halfway. But the same shape of bug comes back whenever there's an await between reading a value and writing it back. You'll meet that in §08.
var counter int
func handle() {
counter++ // ⚠️ called from many goroutines → data race
}Critical sections and sync.Mutex
mu.Lock() … mu.Unlock(). Only one goroutine can be between those two calls at a time.The lines between Lock() and Unlock() are called the critical section. The guarantee that only one goroutine is ever inside it is called mutual exclusion — "mutex" is just short for that.
The standard shape
In Go you almost always put the mutex inside the struct it protects, right above the fields it guards:
type Counter struct {
mu sync.Mutex // guards n — by convention, the fields below mu
n int
}
func (c *Counter) Inc() {
c.mu.Lock() // wait here until we hold the lock
defer c.mu.Unlock() // ← released when Inc returns, however it returns
c.n++ // the critical section: one goroutine at a time
}
func (c *Counter) Value() int {
c.mu.Lock() // reads need the lock too
defer c.mu.Unlock()
return c.n
}
func main() {
var c Counter // ready to use: an unlocked mutex and n = 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println(c.Value()) // 1000, every time
}Why does Value() lock, if it only reads?
Because a read can race with a write too (§02): without the lock, Value() might see a stale number. A mutex doesn't just make goroutines take turns — it also makes writes visible. Remember the for !done {} loop from §02 that could spin forever? Step through why, and what the lock changes:
1var done bool // shared, starts false23// G1, on core 1:4done = true56// G2, on core 2:7for !done {8 // wait for G1…9}10fmt.Println("G2 saw it")
done = false. Each core also keeps private copies of values it uses — in its registers and its own CPU cache (a small, fast copy of recently used memory). Drawn here as one box per core. This is a simplified picture: the exact hardware details vary, the effect doesn't.The rule to remember: everything a goroutine wrote before Unlock() is guaranteed to be seen by the next goroutine after its Lock(). That's called a happens-before guarantee, and it's half of what a lock is for. It only works if both sides use the lock — that's why Value() locks too.
Watch the line form
Every Lock() button below starts a new goroutine that wants in. The first one gets the lock; the rest wait at the door:
A goroutine waiting in Lock() doesn't burn CPU (apart from a very brief spin first, §11). Go parks it — sets it aside — and runs other goroutines on that thread until the lock is released. Waiting is cheap; but it's still time that goroutine isn't doing useful work.
Five rules that prevent most mutex bugs
- Lock, then
defer Unlock()on the very next line. Then no earlyreturncan forget to unlock. - Keep the critical section tiny. Compute things, call APIs, read files before you take the lock. Inside: only touch the shared data.
- Every access goes through the lock — reads too. One unlocked read is a data race.
- Never copy a mutex. Use pointer receivers (
*Counter) and pass*Counteraround. A copied mutex is a different lock, so it protects nothing (§12). - Don't lock twice in the same goroutine. Go mutexes are not reentrant: a second
Lock()by the holder waits for itself — forever (§12).
sync.RWMutex: many readers, one writer
A plain Mutex makes readers take turns even though two readers can't harm each other. If your data is read far more often than it's written — a config, a cache, a routing table — that's wasted waiting. A RWMutex has two kinds of lock:
| Call | Name | Who else may be inside at the same time? |
|---|---|---|
RLock() / RUnlock() | read lock | other readers — but no writer |
Lock() / Unlock() | write lock | nobody |
In code
type Cache struct {
mu sync.RWMutex
m map[string]string
}
func NewCache() *Cache {
return &Cache{m: map[string]string{}} // maps must be created before writing to them
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // shared: many Gets can run at once
defer c.mu.RUnlock()
v, ok := c.m[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // exclusive: waits for all readers to leave
defer c.mu.Unlock()
c.m[key] = value
}The writer-priority rule
Here's a subtle one. Imagine readers arrive non-stop, each overlapping the last. A writer waiting for "no readers inside" might wait forever. To prevent that, Go's RWMutex says: once a writer is waiting, new readers have to wait too, behind it. Try it — start two readers, then a writer, then one more reader:
Two ways to deadlock an RWMutex
1. Upgrading a read lock. You can't turn an RLock() into a Lock(). If a goroutine holding a read lock calls Lock(), Lock() waits until all readers have left — including the goroutine that is calling it, which can't leave because it's stuck in Lock(). It waits for itself forever. (The fix is in §12, pitfall #5.)
2. Taking RLock() twice in the same goroutine. This one is caused by the writer-priority rule:
- G1 takes
RLock(). - A writer, G2, calls
Lock()and waits for G1 to leave. - G1 calls
RLock()again — but a writer is waiting, so new readers must wait behind it. - G1 waits for G2, and G2 waits for G1. Deadlock.
It only happens when a writer arrives at the wrong moment, so it can pass every test and then hang in production.
When RWMutex actually helps
It's tempting to use RWMutex everywhere "for performance". Often it's slower than a plain Mutex:
- Every
RLock()still writes to shared memory. It has to update a reader count, so all the cores doingRLock()fight over that same counter (why that's slow: §07). With very short critical sections (a map lookup), that bookkeeping costs as much as the work — and a plainMutexis simpler and about as fast or faster. - It wins when reads are frequent and not tiny — e.g. walking a big data structure or formatting a response while holding the read lock — and writes are rare.
sync.Once: set up exactly once
sync.Once runs a function exactly once, even if a hundred goroutines ask at the same moment — and makes every one of them wait until it's done.A common need: load something expensive (a config file, a database connection pool) the first time it's needed, not at startup. The obvious code has a race:
var config *Config
func getConfig() *Config {
if config == nil { // two goroutines can both see nil here…
config = loadConfig() // …and both load it. Also a data race on config.
}
return config
}This is "check, then act" with a gap in between — the same shape as the lost update in §02. Two goroutines both pass the nil check before either one sets config.
The fix: sync.Once
var (
configOnce sync.Once
config *Config
)
func getConfig() *Config {
configOnce.Do(func() { // only the first caller runs this function
config = loadConfig()
})
return config // every caller sees the loaded config
}Do guarantees two things: the function runs once, and no call to Do returns until it has finished. So a goroutine that arrives while the loading is in progress waits, instead of getting a half-built config:
loadConfig(). The others wait (striped) until it has finished — then all three get the same value. Every later call returns instantly.Go 1.21 added shortcuts that bundle the Once and the variable together:
// at package level:
var getConfig = sync.OnceValue(loadConfig) // a function: the first call runs loadConfig,
// every call returns its result
// inside any function:
cfg := getConfig()The same idea in JavaScript
In JavaScript the danger isn't two threads — it's two async calls both starting the load before the first one finishes (the same "race across await" from Async/Await §07). The fix is to cache the promise, not the value: the first caller creates it, every later caller gets the same promise.
var getConfig = sync.OnceValues(loadConfig) // loadConfig() (*Config, error)
func handler() error {
cfg, err := getConfig() // first call loads; others wait, then reuse
if err != nil {
return err
}
return serve(cfg)
}Atomics and the compare-and-swap loop
Remember the three steps of counter++ (§02)? Modern CPUs have special instructions that do LOAD + ADD + STORE as one indivisible unit. While a core runs one, no other core can read or write that memory location halfway through. That's an atomic operation, and Go exposes them in sync/atomic:
var hits atomic.Int64 // zero value is 0, ready to use
func handle() {
hits.Add(1) // one indivisible step: safe from any number of goroutines
}
func report() {
fmt.Println(hits.Load()) // reads must be atomic too
}The typed atomics (atomic.Int32, atomic.Int64, atomic.Bool, atomic.Pointer[T], …) arrived in Go 1.19. Each has the same small set of methods:
| Method | Does, as one step |
|---|---|
Load() | read the value |
Store(v) | write v |
Add(d) | add d, return the new value |
Swap(v) | write v, return the old value |
CompareAndSwap(old, new) | if the value is still old, write new and return true; otherwise change nothing and return false |
(The integer types also gained And and Or methods in Go 1.23. There's no Sub: use Add(-d).)
When there's no atomic for what you need: CAS
Add covers counters. But what about "keep the largest latency seen so far", or "add 1, but never above 100"? There's no single instruction for that. Instead, you build it from compare-and-swap (CAS) in a loop:
- Read the current value.
- Compute the new value from it, in your own variable.
- Compare-and-swap: "if the value is still what I read, replace it with mine". If someone changed it in between, the CAS fails and writes nothing — so you go back to step 1 and try again with the fresh value.
Try it. You control the scheduler: click the two goroutines' steps in any order. First break the plain version, then try the same order with CAS:
G1G21old := v // 1. read2next := old + n // 2. compute3v = next // 3. write — no questions asked
next = —
next = —
Here's a real CAS loop: tracking the maximum latency seen across all goroutines.
var maxLatency atomic.Int64
func record(ms int64) {
for {
old := maxLatency.Load()
if ms <= old {
return // not a new maximum: nothing to do
}
if maxLatency.CompareAndSwap(old, ms) {
return // ← it was still old, so our value is in
}
// another goroutine changed it between Load and CAS: loop and re-check
}
}What atomics can't do
An atomic protects one operation on one value. Two atomic operations in a row are not atomic together:
if stock.Load() > 0 { // ✓ atomic read…
stock.Add(-1) // ✓ atomic write… but another goroutine can buy the last
} // item between these two lines → stock goes to -1The fix is a single CAS loop on stock, so the check and the write are tied together:
func tryBuy() bool {
for {
n := stock.Load()
if n <= 0 {
return false // sold out
}
if stock.CompareAndSwap(n, n-1) { // only if nobody bought in between
return true
}
}
}(Or simply a mutex around the check and the subtraction.) And as soon as you need to keep two variables consistent with each other — a balance and a transaction log, a map and its size — atomics are the wrong tool. Use a mutex.
What “lock-free” really means (and the myths)
Mutex" or "fast". It means: even if one thread is frozen mid-operation, the others can still finish theirs.Three levels of progress guarantee
What happens to everyone else if one thread gets paused at the worst moment — say the OS stops it for 50 ms to run something else while it's halfway through an update? (Thread or goroutine: same idea. This section says "thread" because these terms come from the wider world of programming.)
Blocking (locks)
Lock-free
Wait-free
These are the precise meanings of lock-free and wait-free. Notice that none of them says anything about speed.
Myth 1: "Lock-free is faster"
Sometimes, often not. To change a value, a core needs exclusive ownership of the cache line that holds it. When many cores hammer one atomic variable, that cache line bounces from core to core, and each core spends most of its time waiting for it to arrive:
A CAS loop makes this worse: under heavy contention (many threads wanting the same thing at once), most CASes fail and retry, burning CPU on work that gets thrown away. A mutex, by contrast, parks waiters so they use no CPU. And Go's sync.Mutex already uses a single CAS on its fast path (§11), so an uncontended Lock/Unlock costs about as much as an atomic — roughly tens of nanoseconds, depending on the machine.
The real fix for a hot counter is usually to stop sharing it: let each worker goroutine count in its own local variable and add that to the shared total once, when it finishes (or every few thousand events). That's the "one counter per core" row above.
Myth 2: "Lock-free means nobody waits"
A failed CAS that loops is waiting — just busily. And code that avoids sync.Mutex by spinning on a flag is still a lock:
// A homemade spinlock: no sync.Mutex in sight — and NOT lock-free.
var locked atomic.Bool
for !locked.CompareAndSwap(false, true) {
// spin until we flip it from false to true
}
// … critical section …
locked.Store(false)If the goroutine holding this spinlock is paused, every other goroutine spins at 100% CPU until it comes back. That's blocking, plus wasted CPU. In Go, prefer sync.Mutex, which spins only briefly and then parks.
Myth 3: "I used atomics, so it's thread-safe"
Each atomic is safe on its own. A sequence of them isn't (§06's stock example). Correctness comes from the design, not from the word atomic.
Myth 4: "Lock-free data structures are the pro move"
Writing your own lock-free queue or map is one of the hardest things in programming. Two classic traps:
- The ABA problem. A CAS checks "is the value still A?" — but it can't tell "never changed" from "changed to B and back to A". The stepper below shows how that breaks a lock-free stack.
- Freeing memory. When can a removed node be freed if another thread may still be reading it? Go's garbage collector solves this one for you (a node isn't freed while anyone still references it) — but C++ and Rust need extra machinery.
1// pop(): a lock-free stack, simplified2for {3 old := top.Load() // 1. read the top node4 next := old.next // 2. read the node under it5 // 3. move top down — if top is still old6 if top.CompareAndSwap(old, next) {7 return old8 }9}
A.next = B). Two threads will pop using the CAS loop on the left.JavaScript: one thread — so why locks?
await is a gap where other code runs — and a gap between "check" and "act" is a race.What one thread buys you
In Node and the browser, all your JavaScript runs on a single thread through the event loop. A piece of synchronous code always runs to the end before anything else starts. So:
count++can't be torn in half. No lost updates from it, ever.- A function with no
awaitin it is effectively one big critical section — for free.
That's why you rarely see locks in JS code.
Where JS races come back
The moment a function contains an await, it runs in pieces, and other code runs between the pieces. If you read something before the await and act on it after, the world may have changed in between. Async/Await §07 walks through the classic example: two withdrawals of 80 from a balance of 100 both pass the "enough money?" check, and the balance ends at −60.
That's not a data race (nothing ran at the same time), it's a logical race: a check-then-act that isn't protected as one unit. Two fixes:
- Best: no
awaitbetween the check and the act. Check and subtract first, then do the slow work — and add the money back if that work fails. Free, and no waiting. - When you can't, because the slow step must happen in the middle (e.g. "read the file, change it, write it back"), make callers take turns with an async mutex.
An async mutex in ten lines
An async mutex is just a queue of promises. Each caller waits for the previous caller's "I'm done" promise, and leaves its own "I'm done" promise for the next one:
class Mutex {
#tail = Promise.resolve(); // resolves when the last caller in line is done
async lock() {
let release;
const done = new Promise((resolve) => (release = resolve)); // my "I'm done" signal
const before = this.#tail; // the caller ahead of me
this.#tail = done; // ← set synchronously: the next caller waits for ME
await before; // wait until the caller ahead of me releases
return release; // call it to let the next caller in
}
}Those ten lines are the hardest on the page, so here they are in slow motion with three callers:
1class Mutex {2 #tail = Promise.resolve(); // already resolved34 async lock() {5 let release;6 const done = new Promise((r) => (release = r));7 const before = this.#tail;8 this.#tail = done;9 await before;10 return release;11 }12}1314const m = new Mutex();15m.lock(); // call A16m.lock(); // call B17m.lock(); // call C
#tail starts as Promise.resolve(): a promise that is already resolved. Awaiting it will continue right away (on the next microtask), which is why the first caller doesn't wait.Using it looks like Go's Lock + defer Unlock, with try/finally playing the role of defer:
var mu sync.Mutex
var balance = 100
func withdraw(n int) error {
mu.Lock()
defer mu.Unlock()
if balance < n {
return errors.New("no funds")
}
audit(n) // slow call while holding the lock (see §10 on why to avoid this)
balance -= n
return nil
}Step through the two withdrawals again, now with the lock:
1const lock = new Mutex();2let balance = 100;34async function withdraw(n) {5 const release = await lock.lock();6 try {7 if (balance < n) throw new Error("no funds");8 await audit(n); // ⏸ pause — still holding the lock9 balance -= n;10 } finally {11 release(); // always let the next caller in12 }13}1415withdraw(80); // call A16withdraw(80); // call B
Ready-made versions
- Browsers have one built in: the Web Locks API.
navigator.locks.request("account-42", async () => { … })holds a named lock until your async callback finishes — and it works across tabs of the same origin (same scheme, host and port). - In Node, the small
async-mutexnpm package providesMutex(withrunExclusive(fn)) andSemaphore. - Across several server processes, an in-memory lock protects nothing: each process has its own. Use the database instead — a database transaction, a
SELECT … FOR UPDATE(locks the selected rows until the transaction ends), or a conditionalUPDATE … WHERE balance >= 80.
Contention, granularity and lock ordering
The math of one lock
Say each request holds a global lock for 2 µs (microseconds — millionths of a second). Then at most 1 s ÷ 2 µs = 500,000 requests per second can pass through it — in total, across all cores. On 4 cores that might be fine. On 64 cores, most of them will be waiting in line. That waiting is called lock contention, and it's why "we doubled the cores and it didn't get faster" happens.
(Real numbers are worse: handing a contended lock from one core to another takes time too, so this is a ceiling, not a promise.)
Play with the two knobs that matter — how many locks there are, and how long each one is held:
Three ways to reduce contention
1. Hold the lock for less time. Do everything you can before taking the lock — and never wait for I/O while holding it.
func (c *Cache) Refresh(key string) {
c.mu.Lock()
defer c.mu.Unlock()
v := fetchFromDB(key) // 20 ms of I/O… with
c.m[key] = v // every other goroutine stuck
}func (c *Cache) Refresh(key string) {
v := fetchFromDB(key) // slow part: no lock held
c.mu.Lock()
c.m[key] = v // fast part: microseconds
c.mu.Unlock()
}(The right-hand version skips defer: there's no return between Lock and Unlock and the one line can't fail, so a plain Unlock() is fine — and it releases the lock before the function ends, not after.)
2. Use more, smaller locks — sharding. Instead of one map with one lock, keep 16 maps, each with its own lock, and pick one by hashing the key. Two goroutines only wait for each other if their keys land in the same shard.
const shardCount = 16
type shard struct {
mu sync.Mutex
m map[string]int
}
type ShardedCounter struct {
shards [shardCount]shard
}
func NewShardedCounter() *ShardedCounter {
c := &ShardedCounter{}
for i := range c.shards {
c.shards[i].m = map[string]int{}
}
return c
}
func (c *ShardedCounter) Inc(key string) {
h := fnv.New32a() // from "hash/fnv": turns bytes into a number
h.Write([]byte(key))
s := &c.shards[h.Sum32()%shardCount] // same key → always the same shard
s.mu.Lock()
s.m[key]++
s.mu.Unlock()
}The trade-off: anything that needs all keys at once — a total count, a snapshot — must now lock every shard.
3. For read-mostly data, publish immutable snapshots. If something is read millions of times and replaced once a minute (a config, a routing table), don't lock readers at all. Build a fresh copy, then swap a pointer atomically:
var current atomic.Pointer[Config] // Go 1.19+
func reload() {
cfg := loadConfig() // build a brand-new value
current.Store(cfg) // publish it in one step
}
func handle() {
cfg := current.Load() // readers never wait, never lock
use(cfg) // ⚠️ never modify cfg — it's shared
}
// Call reload() once at startup: until the first Store, Load() returns nil.If two goroutines might reload at the same time and both start from the old value, guard reload with a plain mutex — readers still never touch it.
Lock ordering: how two locks become a deadlock
Moving money between two accounts needs both accounts' locks. The natural code:
type Account struct {
id int
mu sync.Mutex // guards balance
balance int
}
func transfer(from, to *Account, amount int) {
from.mu.Lock()
defer from.mu.Unlock()
to.mu.Lock() // ← may wait forever
defer to.mu.Unlock()
from.balance -= amount
to.balance += amount
}
// goroutine 1: transfer(alice, bob, 10)
// goroutine 2: transfer(bob, alice, 5)Each goroutine holds one lock and waits for the other's: a deadlock. Go only detects it when every goroutine is stuck; in a server, those two requests just hang forever, holding their locks, and every later request touching Alice or Bob piles up behind them.
The fix is lock ordering: decide on one global order — say, by account ID — and always lock in that order:
func transfer(from, to *Account, amount int) {
if from == to {
return // locking the same mutex twice would deadlock too
}
first, second := from, to
if second.id < first.id {
first, second = second, first // always lock the lower ID first
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
from.balance -= amount
to.balance += amount
}Deadlocks have a few more causes (and some cousins, like goroutines that are busy but never finish). The Races & Deadlocks deep-dive covers them in full.
Production: race detector, mutex profiles, and how sync.Mutex works
The race detector: run it in CI, always
Go ships a data-race detector. Add -race to go test, go run or go build:
go test -race ./...It watches every memory access while the program runs. When two goroutines touch the same memory, at least one writes, and no lock, channel or atomic ordered them, it prints a report like this:
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 8:
main.(*Counter).Inc()
/app/counter.go:14 +0x44
Previous read at 0x00c0000b4010 by goroutine 7:
main.(*Counter).Value()
/app/counter.go:19 +0x3aTwo stack traces: who wrote, and who touched the same memory before without coordination. (main.(*Counter).Inc is how Go names the Inc method with a *Counter receiver in package main; counter.go:14 is the file and line.) That's usually enough to find the missing lock in minutes.
Three things to know:
- It only finds races that actually happen during that run. Code your tests never execute isn't checked. Write tests that run the concurrent paths — several goroutines calling the same methods at once.
- It's slow: the Go docs say roughly 2–20× more CPU time and 5–10× more memory. That's fine for tests and CI. Some teams also run one
-raceinstance in a staging environment. - Treat every report as a real bug, even if the output "looks right". The detector reports accesses that actually happened unordered; it doesn't guess.
go vet ./... catches a related bug without running anything: copying a lock. (go test runs only a few of vet's checks automatically, and this isn't one of them — run go vet or a linter like golangci-lint in CI too.) Its copylocks check flags value receivers and assignments that copy a sync.Mutex (or any struct containing one).
Finding contention: the mutex profile
When a service is slow but its CPUs are mostly idle, goroutines are often waiting — on locks, channels or I/O. Go's built-in profiler, pprof, can record where lock waiting happens. It's off by default; turn it on with a sampling rate:
import (
"net/http"
_ "net/http/pprof" // registers the /debug/pprof/ pages on the default server
"runtime"
)
func main() {
runtime.SetMutexProfileFraction(5) // record ~1 in 5 contention events
go http.ListenAndServe("localhost:6060", nil) // profiling endpoint, local only
// … the rest of your server …
}go tool pprof http://localhost:6060/debug/pprof/mutex
# pprof opens an interactive prompt, "(pprof)". Type:
top # the functions that made others wait the longestHow to read it: the mutex profile charges the waiting time to the code that was holding the lock (where it called Unlock), not to the waiters. That points you straight at the critical section that's too long. In tests, go test -mutexprofile=mutex.out produces the same profile. Its sibling, the block profile (runtime.SetBlockProfileRate), records waiting on sync primitives, channels and select — but not network or file I/O, or time.Sleep.
For a cheap always-on signal, Go 1.20+ also exposes the metric /sync/mutex/wait/total:seconds in runtime/metrics: total time goroutines have spent waiting on mutexes. A steadily climbing value is a contention alarm.
Inside sync.Mutex
sync.Mutex is a single integer state plus a parking mechanism. Its behavior explains several things on this page:
- Fast path.
Lock()tries one compare-and-swap (CAS): "if state is unlocked, make it locked". With no contention, that's all it does — about the cost of one atomic operation. - Spin briefly. If the lock is taken and the machine has several cores, the goroutine may spin a few times, betting that the holder is about to release it.
- Park. Still taken? The goroutine joins the mutex's wait queue and is parked: no CPU used until
Unlockwakes it. - Normal mode vs starvation mode. A woken waiter must compete with goroutines that are just arriving — and the newcomers usually win, because they're already running on a CPU. Good for throughput, unfair to the waiter — it risks starvation. So if a waiter has waited more than 1 ms, the mutex switches to starvation mode:
Unlockhands ownership directly to the waiter at the front of the queue, and newcomers go to the back. It switches back to normal mode when the waiter that gets the lock is the last one in the queue, or had waited less than 1 ms.
That's why the widget in §03 is "usually, but not strictly" first-come-first-served.
Pitfalls gallery
Copying a mutex
A sync.Mutex must not be copied after first use. A value receiver copies the whole struct — mutex included — on every call, so each call locks its own private copy, which protects nothing. (Here, the increment also lands on the copy and is thrown away.)
func (c Counter) Inc() { // value receiver: c is a copy
c.mu.Lock()
defer c.mu.Unlock()
c.n++ // changes the copy
}func (c *Counter) Inc() { // pointer receiver
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}The same happens when you pass a struct containing a mutex by value to a function, or range over a slice of them (for _, s := range shards copies each element). go vet reports all of these (§11).
Forgetting to unlock on an early return
Every return between Lock and Unlock is a chance to leave the lock held forever. The next Lock() — maybe minutes later, in another request — hangs.
func (c *Cache) Get(key string) string {
c.mu.Lock()
if v, ok := c.m[key]; ok {
return v // 🔒 still locked. Forever.
}
v := load(key)
c.m[key] = v
c.mu.Unlock()
return v
}func (c *Cache) Get(key string) string {
c.mu.Lock()
defer c.mu.Unlock() // every return path, and panic
if v, ok := c.m[key]; ok {
return v
}
v := load(key)
c.m[key] = v
return v
}(The right-hand version still calls load under the lock — see #3. defer fixes the leak; it doesn't make the section short.)
Holding a lock during I/O
A network call, a database query or a file read under a lock makes every other goroutine that needs the lock wait for that I/O — often milliseconds, sometimes a 30-second timeout. Do the slow work first, then lock only to read or write the shared data (§10). If you must avoid duplicate loads of the same key, use golang.org/x/sync/singleflight rather than a lock around the load: it makes concurrent requests for the same key share one call, without blocking requests for other keys.
Locking twice in the same goroutine
Go mutexes are not reentrant. A locked method calling another locked method on the same struct waits for itself:
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
c.trim() // calls c.mu.Lock() again → stuck
}func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
c.trimLocked() // assumes the caller holds c.mu
}Convention: an unexported method (lowercase name, so only code in the same package can call it) whose name ends in Locked doesn't lock — it documents that the caller must already hold the lock.
Upgrading an RLock to a Lock
Release the read lock first, then take the write lock — and check again after taking it, because another goroutine may have written in the gap (§04):
func (c *Cache) GetOrLoad(k string) string {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.m[k]
if !ok {
c.mu.Lock() // waits for all readers — including us
defer c.mu.Unlock()
v = load(k)
c.m[k] = v
}
return v
}func (c *Cache) GetOrLoad(k string) string {
c.mu.RLock()
v, ok := c.m[k]
c.mu.RUnlock() // let go of the read lock first
if ok {
return v
}
c.mu.Lock()
defer c.mu.Unlock()
if v, ok := c.m[k]; ok { // check again: someone may have
return v // loaded it while we had no lock
}
v = load(k)
c.m[k] = v
return v
}Letting guarded data escape the lock
Returning a map or slice from a locked method returns a reference to the same data. The caller then reads it with no lock while other goroutines write to it — a data race the mutex can't see.
func (c *Cache) All() map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
return c.m // caller now shares c.m, unlocked
}func (c *Cache) All() map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
return maps.Clone(c.m) // a private copy (Go 1.21+)
}Mixing atomic and plain access
atomic.AddInt64(&n, 1) in one place and if n > 100 in another is still a data race: the plain read isn't coordinated. Use the typed atomic.Int64, whose value can only be touched through Load, Add, and friends (§06).
JavaScript: a lock without try/finally
const release = await lock.lock();
await save(data); // throws → release() never runs
release(); // every later caller hangsconst release = await lock.lock();
try {
await save(data);
} finally {
release(); // runs on success and on throw
}Check yourself
Predict the answer before you click. Q1 is covered in §02, Q2 and Q3 in §03 and §12, Q4 in §06, Q5 and Q6 in §08.
var n int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n++
}()
}
wg.Wait()
fmt.Println(n)type Counter struct {
mu sync.Mutex
n int
}
func (c Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func main() {
var c Counter
c.Inc()
c.Inc()
fmt.Println(c.n)
}var mu sync.Mutex
func a() {
mu.Lock()
defer mu.Unlock()
b()
}
func b() {
mu.Lock()
defer mu.Unlock()
fmt.Println("in b")
}
func main() { a() }var v atomic.Int64
v.Store(5)
ok1 := v.CompareAndSwap(5, 6)
ok2 := v.CompareAndSwap(5, 7)
fmt.Println(ok1, ok2, v.Load())let count = 0;
await Promise.all(
Array.from({ length: 1000 }, async () => { count++; })
);
console.log(count);let count = 0;
async function inc() {
const current = count;
await sleep(10); // any await at all
count = current + 1;
}
await Promise.all([inc(), inc(), inc()]);
console.log(count);Cheat sheet
mu.Lock(); defer mu.Unlock() on consecutive lines. Reads need the lock too.go test -race in CI, go vet for copied locks, the mutex profile for contention.| I want to… | Use |
|---|---|
| Protect a few fields shared by goroutines | sync.Mutex in the struct, pointer receivers, defer Unlock() |
| Count events from many goroutines | atomic.Int64 with Add / Load |
| Read a lot, write rarely, big read sections | sync.RWMutex — then measure against Mutex |
| Swap in a new config / routing table | atomic.Pointer[T] to an immutable snapshot |
| Run setup exactly once, lazily | sync.Once, or sync.OnceValue(s) (Go 1.21+); JS: cache the promise with ??= |
| Update one value with custom logic (max, capped add) | CAS loop with CompareAndSwap |
| A map with heavy contention | Sharded map (N locks by key hash), or sync.Map for its two documented cases |
| Move work between goroutines | A channel, not a lock |
| Make async JS callers take turns | An async mutex (§08), navigator.locks in browsers |
| Share memory between JS workers | SharedArrayBuffer + Atomics |
| Find unprotected sharing | go test -race ./... |
| Find which lock is slowing things down | runtime.SetMutexProfileFraction + go tool pprof …/debug/pprof/mutex |