Skip to content
Software Atlas

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.

⏱ ~45 min read14 sections10 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceWhen several goroutines change the same variable, their steps can interleave and updates silently disappear. A lock makes them take turns; an atomic makes the change happen in one unbreakable step.

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:

4 goroutines × 1000 increments
Each goroutine adds 1 to the same counter a thousand times. The right answer is 4000. Press run a few times.
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)
fmt.Println(counter)expected 4000
—
previous runs
none yet
Simulated with a random scheduler, so the exact numbers differ from a real machine — but a real Go program shows the same thing: a different wrong answer almost every run.

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

ToolWhat it doesSection
sync.MutexOne goroutine at a time in the protected code§03
sync.RWMutexMany readers at once, or one writer alone§04
sync.OnceRun some setup exactly once, no matter how many goroutines ask§05
sync/atomicMake one small update (add, swap, compare-and-swap) a single unbreakable step§06
An async mutexJavaScript'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).

Foundations§02

Why counter++ breaks

In one sentencecounter++ 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:

  1. LOAD the value from memory into a register (a tiny slot inside the core),
  2. ADD 1 to it there,
  3. 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:

counter++, one step at a time
Two goroutines, one shared counter. Step through both versions.
Code
1// counter++ is really:
2tmp := counter // 1. LOAD
3tmp = tmp + 1 // 2. ADD
4counter = tmp // 3. STORE
G1 · core 1
not started
G2 · core 2
not started
counter (memory)
41
counter is 41. Two goroutines, G1 and G2, each run counter++ once, on two different CPU cores. The answer should be 43.
1 / 8

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
}
Foundations§03

Critical sections and sync.Mutex

In one sentenceWrap every read and write of shared data in 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:

counter.go
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:

A write that is never seen
Two cores, each with its own private copy of memory. Step through both versions.
Code
1var done bool // shared, starts false
2
3// G1, on core 1:
4done = true
5
6// G2, on core 2:
7for !done {
8 // wait for G1…
9}
10fmt.Println("G2 saw it")
core 1 · G1's copy
nothing yet
core 2 · G2's copy
nothing yet
main memory (RAM)
done = false
Main memory (RAM) says 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.
1 / 4

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 mutex and its waiting line
Each Lock() button starts a new goroutine that wants the lock. Unlock() lets the current holder leave.
🔒 inside the critical sectionfree
nobody — the next Lock() gets in immediately
⏳ waiting (parked)0 waiting
nobody waiting
Try: press Lock() three times. Only the first goroutine gets in; the others line up. Then press Unlock().
Drawn as a fair first-come-first-served line to keep it simple. Real Go mutexes are usually, but not strictly, first-come-first-served (§11).

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

  1. Lock, then defer Unlock() on the very next line. Then no early return can forget to unlock.
  2. Keep the critical section tiny. Compute things, call APIs, read files before you take the lock. Inside: only touch the shared data.
  3. Every access goes through the lock — reads too. One unlocked read is a data race.
  4. Never copy a mutex. Use pointer receivers (*Counter) and pass *Counter around. A copied mutex is a different lock, so it protects nothing (§12).
  5. Don't lock twice in the same goroutine. Go mutexes are not reentrant: a second Lock() by the holder waits for itself — forever (§12).
Practitioner§04

sync.RWMutex: many readers, one writer

In one sentenceReading shared data can't break it — only writing can. An RWMutex lets any number of readers in at once, but a writer needs it completely alone.

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:

CallNameWho else may be inside at the same time?
RLock() / RUnlock()read lockother readers — but no writer
Lock() / Unlock()write locknobody
Three readers and a writer: Mutex vs RWMutex
sync.Mutex — everyone takes turns
reader 1
read
reader 2
wait
read
reader 3
wait
read
writer
waiting
write
sync.RWMutex — readers share, the writer goes alone
reader 1
RLock · read
reader 2
RLock · read
reader 3
RLock · read
writer
wait
Lock · write
Striped = waiting. With a plain mutex, readers take turns even though reading can't break anything. With an RWMutex, the three readers overlap, and the writer still gets the data alone.

In code

cache.go
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:

RWMutex: many readers or one writer
Start some readers and writers. Watch who may be inside together, and why a waiting writer stops new readers.
🔒 inside the critical sectionempty
nobody — the next Lock() gets in immediately
⏳ waiting (parked)0 waiting
nobody waiting
Try: start two readers (both get in), then a writer (it waits), then another reader — it waits too, behind the writer.

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:

  1. G1 takes RLock().
  2. A writer, G2, calls Lock() and waits for G1 to leave.
  3. G1 calls RLock() again — but a writer is waiting, so new readers must wait behind it.
  4. 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 doing RLock() 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 plain Mutex is 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.
Practitioner§05

sync.Once: set up exactly once

In one sentencesync.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

config.go
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:

Three goroutines call getConfig() at the same moment
G1
Do
loadConfig()
use cfg
G2
Do
waits for G1
use cfg
G3
Do
waits
use cfg
G4 (later)
Do
use cfg
Only the first one runs 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)
}
Practitioner§06

Atomics and the compare-and-swap loop

In one sentenceAn atomic operation does a whole read-modify-write as one step that no other core can get in the middle of. No lock, no waiting line — but it only works on a single small value.

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:

hits.go
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:

MethodDoes, 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:

  1. Read the current value.
  2. Compute the new value from it, in your own variable.
  3. 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:

You are the scheduler
G1 wants to add 1 to v, G2 wants to add 10. v starts at 5, so the right answer is 16. Click the goroutines in any order you like — try to break it.
G1G21old := v // 1. read
2next := old + n // 2. compute
3v = next // 3. write — no questions asked
shared vexpected 16
5
G1 · adds 1
old = —
next = —
G2 · adds 10
old = —
next = —
Try the unlucky order: G1 read → G2 read → G2 compute → G2 write → G1 compute → G1 write.

Here's a real CAS loop: tracking the maximum latency seen across all goroutines.

max.go
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 -1

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

Staff-level§07

What “lock-free” really means (and the myths)

In one sentenceLock-free doesn't mean "uses no 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.)

B

Blocking (locks)

If the paused thread holds a lock, everyone who needs that lock waits until it's resumed. One slow thread can stall all of them.
LF

Lock-free

Some thread always makes progress. A CAS loop is the classic example: if my CAS fails, it's because someone else's succeeded. A paused thread holds nothing others need. Individual threads can still lose over and over.
WF

Wait-free

Every thread finishes within a fixed number of its own steps, no matter what the others do. The strongest guarantee — and the hardest to build. A hardware atomic add is effectively one.

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:

Four cores, one atomic counter vs one counter per core
One shared atomic counter: the cache line visits one core at a time
core 1
+1
+1
+1
core 2
+1
+1
+1
core 3
+1
+1
core 4
+1
+1
One counter per core: no sharing, no waiting
core 1
+1 +1 +1 +1 +1 +1 +1 +1 +1 +1
core 2
+1 +1 +1 +1 +1 +1 +1 +1 +1 +1
core 3
+1 +1 +1 +1 +1 +1 +1 +1 +1 +1
core 4
+1 +1 +1 +1 +1 +1 +1 +1 +1 +1
Before a core can change a value, it needs the only writable copy of the 64-byte cache line holding it. With one shared counter, that line bounces from core to core and the cores mostly wait for it. With one counter per core (each on its own cache line), they all run at once — add them up when you need the total.

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.
The ABA problem, step by step
Two threads pop from a lock-free stack. Every CAS succeeds — and the stack still ends up broken.
Code
1// pop(): a lock-free stack, simplified
2for {
3 old := top.Load() // 1. read the top node
4 next := old.next // 2. read the node under it
5 // 3. move top down — if top is still old
6 if top.CompareAndSwap(old, next) {
7 return old
8 }
9}
thread T1
not started
thread T2
not started
the stack (top first)
top →
A
B
C
A stack of three nodes: A on top, then B, then C. Each node points at the one below it (A.next = B). Two threads will pop using the CAS loop on the left.
1 / 8
Practitioner§08

JavaScript: one thread — so why locks?

In one sentencePlain JavaScript can't have a data race: one thread runs your code, and nothing interrupts it in the middle. But every 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 await in 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:

  1. Best: no await between 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.
  2. 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:

mutex.js
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:

Inside the async mutex: a line of promises
Three calls to lock() in a row. Watch where #tail points.
Code
1class Mutex {
2 #tail = Promise.resolve(); // already resolved
3
4 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}
13
14const m = new Mutex();
15m.lock(); // call A
16m.lock(); // call B
17m.lock(); // call C
call A
not started
call B
not started
call C
not started
the promises, oldest first
#tail →
P0 ✓ resolved
A new mutex. #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.
1 / 9

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:

The withdraw race, fixed with an async mutex
Same two withdrawals of 80 from a balance of 100. Step through it.
Code
1const lock = new Mutex();
2let balance = 100;
3
4async 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 lock
9 balance -= n;
10 } finally {
11 release(); // always let the next caller in
12 }
13}
14
15withdraw(80); // call A
16withdraw(80); // call B
call A
not started
call B
not started
balance · lock
100
🔒 free
The bank-account race from Async/Await §07 — but this time the check-then-act sits inside a lock. Only one of the two withdrawals of 80 should succeed.
1 / 9

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-mutex npm package provides Mutex (with runExclusive(fn)) and Semaphore.
  • 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 conditional UPDATE … WHERE balance >= 80.
Staff-level§09

Real shared memory in JS: SharedArrayBuffer + Atomics

In one sentenceA worker thread normally shares nothing with the main thread: messages are copied. A SharedArrayBuffer opts out of that — and as soon as two threads share memory, JavaScript has data races just like Go.
Workers: copying messages vs sharing memory
postMessage(data) — the default
main thread
its own memory
copy →
← copy
worker
its own memory
SharedArrayBuffer — opt-in
main thread
shared bytes
Int32Array view
worker
both read and write the same memory → use Atomics
postMessage sends a copy: each thread keeps its own private data, so there's nothing to race on. A SharedArrayBuffer is the same bytes seen by every thread — fast, but now you have Go's problem, and need Go's tools.

A SharedArrayBuffer is a block of raw bytes. You don't read it directly; you put a typed array "view" on top, like new Int32Array(buffer), which treats every 4 bytes as one 32-bit integer. When you pass the buffer to a worker, the worker gets the same memory, not a copy.

The counter race, in JavaScript

Here's §01's experiment as a Node program: four workers each add 1 to a shared integer 100,000 times.

counter.mjs — run with: node counter.mjs
import { Worker, isMainThread, workerData } from "node:worker_threads";

if (isMainThread) {
  const shared = new SharedArrayBuffer(4);       // 4 bytes = room for one Int32
  const counter = new Int32Array(shared);
  const workers = [1, 2, 3, 4].map(
    () => new Worker(new URL(import.meta.url), { workerData: shared }) // this same file
  );
  await Promise.all(workers.map((w) => new Promise((done) => w.on("exit", done))));
  console.log(counter[0]); // 400000 with Atomics.add — try counter[0]++ and watch it drop
} else {
  const counter = new Int32Array(workerData);    // same bytes as the main thread's view
  for (let i = 0; i < 100_000; i++) {
    Atomics.add(counter, 0, 1);                  // ← indivisible, like Go's atomic Add
  }
}

Replace Atomics.add(counter, 0, 1) with counter[0]++ and you'll get a different number below 400,000 on most runs — the same LOAD/ADD/STORE interleaving as §02.

The Atomics toolbox

Atomics.…Go equivalentDoes
load(a, i) / store(a, i, v)Load() / Store(v)read / write, visibly to other threads
add, sub, and, or, xorAdd(d) (Add(-d) to subtract); And, Or (Go 1.23+)read-modify-write as one step (JS returns the old value; Go's Add returns the new one)
exchange(a, i, v)Swap(v)write v, return the old value
compareExchange(a, i, expected, v)CompareAndSwap(old, new)write v only if the value is expected; returns the value it found
wait(a, i, v) / notify(a, i, n)parking / waking a goroutineif a[i] === v, sleep until notified (or a timeout); wake up to n sleepers

Building a mutex out of Atomics

wait and notify are enough to build a real, blocking mutex over shared memory — the same idea Go's sync.Mutex is built on (§11): try a CAS, and if the lock is taken, sleep until someone notifies you.

shared-mutex.js
const UNLOCKED = 0, LOCKED = 1;

function lock(m) {                                 // m = Int32Array over a SharedArrayBuffer
  while (Atomics.compareExchange(m, 0, UNLOCKED, LOCKED) !== UNLOCKED) {
    Atomics.wait(m, 0, LOCKED);                    // if still LOCKED, sleep until notified
  }
}

function unlock(m) {
  Atomics.store(m, 0, UNLOCKED);
  Atomics.notify(m, 0, 1);                         // wake one sleeping thread, if any
}

wait checks the value once and then sleeps; it doesn't re-check for you. That's why lock wraps it in a while: after waking, try the CAS again — another thread may have grabbed the lock first. (A production version also tracks whether anyone is actually waiting, to skip the notify call — but this one is correct.)

Staff-level§10

Contention, granularity and lock ordering

In one sentenceA lock turns part of your program into a single-lane road. However many cores you add, only one goroutine at a time gets through — so make the lane short, or build more lanes.

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:

Lock contention, live
8 goroutines in a loop: do some work on their own, then lock the shard for a random key, update it, unlock. Change the number of locks and how long each one is held.
Locks:
Each lock is held:
goroutines
G1
working
G2
working
G3
working
G4
working
G5
working
G6
working
G7
working
G8
working
working without the lockholding a lockwaiting
the lock
#0
·
updates finished
0
updates per tick
0.00
time spent waiting
0%
Tick 0/400 · simulated. With one lock, at most one update can finish per tick — no matter how many goroutines or cores you add.

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.

✕ Don't
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
}
✓ Do
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.

sharded.go
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)
Two transfers, opposite directions
Each goroutine locks "from" first, then "to"
G1 A→B
lock alice ✓
lock bob… waits forever (G2 has it)
G2 B→A
lock bob ✓
lock alice… waits forever (G1 has it)
Both lock the lower ID first (alice, then bob)
G1 A→B
lock alice ✓
lock bob ✓
move $
G2 B→A
lock alice… waits
alice ✓
bob ✓
move $
G1 holds Alice's lock and waits for Bob's. G2 holds Bob's and waits for Alice's. Each waits for the other — forever. Locking in a fixed order (lowest ID first) makes this circle impossible.

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.

Staff-level§11

Production: race detector, mutex profiles, and how sync.Mutex works

In one sentenceTwo questions in production: "is any data shared without a lock?" — the race detector answers it. "Which lock are goroutines waiting on?" — the mutex profile answers it.

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 +0x3a

Two 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 -race instance 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:

main.go
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 longest

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

  1. 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.
  2. 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.
  3. Park. Still taken? The goroutine joins the mutex's wait queue and is parked: no CPU used until Unlock wakes it.
  4. 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: Unlock hands 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.

Practitioner§12

Pitfalls gallery

#1

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.)

✕ Don't
func (c Counter) Inc() { // value receiver: c is a copy
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++                // changes the copy
}
✓ Do
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).

#2

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.

✕ Don't
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
}
✓ Do
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.)

#3

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.

#4

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:

✕ Don't
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
}
✓ Do
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.

#5

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

✕ Don't
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
}
✓ Do
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
}
#6

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.

✕ Don't
func (c *Cache) All() map[string]string {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.m           // caller now shares c.m, unlocked
}
✓ Do
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+)
}
#7

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).

#8

JavaScript: a lock without try/finally

✕ Don't
const release = await lock.lock();
await save(data);   // throws → release() never runs
release();          // every later caller hangs
✓ Do
const release = await lock.lock();
try {
  await save(data);
} finally {
  release();        // runs on success and on throw
}
Practitioner§13

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.

QUESTION 1 / 6
What does it print?
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)
QUESTION 2 / 6
A method that locks
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)
}
QUESTION 3 / 6
One method calls another
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() }
QUESTION 4 / 6
Two CAS calls
var v atomic.Int64
v.Store(5)
ok1 := v.CompareAndSwap(5, 6)
ok2 := v.CompareAndSwap(5, 7)
fmt.Println(ok1, ok2, v.Load())
QUESTION 5 / 6
JavaScript, no awaits inside
let count = 0;
await Promise.all(
  Array.from({ length: 1000 }, async () => { count++; })
);
console.log(count);
QUESTION 6 / 6
JavaScript, an await in the middle
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);
Answered 0 / 6
Foundations§14

Cheat sheet

🧮
counter++ is three stepsLoad, add, store. Two goroutines interleaving them lose updates — silently.
🔒
Lock, then defer Unlockmu.Lock(); defer mu.Unlock() on consecutive lines. Reads need the lock too.
✂️
Short critical sectionsCompute and do I/O before locking. Inside: only touch the shared data.
🚫
Never copy, never re-lockPointer receivers for structs with a mutex. Go mutexes aren't reentrant: a second Lock() waits forever.
📖
RWMutex: measure firstHelps when reads are frequent and not tiny. No upgrading RLock → Lock.
⚛️
Atomics: one value, one stepGreat for counters and flags. Two atomics in a row aren't atomic together.
🔁
CAS loopLoad → compute → CompareAndSwap; on false, retry with the fresh value.
🧭
Lock in a fixed orderSeveral locks? Always the same order (e.g. lowest ID first). No cycles, no deadlock.
🟨
JS: races live at awaitNo data races in plain JS. Check-then-act across an await needs reordering or an async mutex with try/finally.
🔍
Let tools find itgo test -race in CI, go vet for copied locks, the mutex profile for contention.
I want to…Use
Protect a few fields shared by goroutinessync.Mutex in the struct, pointer receivers, defer Unlock()
Count events from many goroutinesatomic.Int64 with Add / Load
Read a lot, write rarely, big read sectionssync.RWMutex — then measure against Mutex
Swap in a new config / routing tableatomic.Pointer[T] to an immutable snapshot
Run setup exactly once, lazilysync.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 contentionSharded map (N locks by key hash), or sync.Map for its two documented cases
Move work between goroutinesA channel, not a lock
Make async JS callers take turnsAn async mutex (§08), navigator.locks in browsers
Share memory between JS workersSharedArrayBuffer + Atomics
Find unprotected sharinggo test -race ./...
Find which lock is slowing things downruntime.SetMutexProfileFraction + go tool pprof …/debug/pprof/mutex
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Concurrency & AsyncWorker Pools & Backpressure
Read →