# Go Perf Atomics Vs Locks

> Guides the atomics-vs-locks performance tradeoff in Go — typed sync/atomic ops (atomic.Int64/Pointer/Bool, 1.19) are a single CAS-style instruction, cheaper than a mutex uncontended but still cache-line-bouncing when contended; sync.Mutex's fast path is itself a CAS that only parks the goroutine when contended; RWMutex wins only for long read sections and is slower (scales worse) than a plain Mutex for short ones; the atomic.Pointer copy-on-write config swap; when lock-free isn't worth the correctness risk. Fires on "atomic vs mutex", "is RWMutex faster", "lock-free counter", "reduce locking overhead", "atomic.Pointer config". Routes copylock/atomic-API correctness to go-sync-primitives, memory ordering to go-race-and-memory-model, measuring to go-perf-block-mutex-profiles.

- Skill: `ctoth/go-perf-atomics-vs-locks` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-atomics-vs-locks`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-atomics-vs-locks/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-atomics-vs-locks

---


# Go Performance: Atomics vs Locks

> "These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the [sync] package."
> — [pkg.go.dev/sync/atomic](https://pkg.go.dev/sync/atomic)

This skill owns the **performance tradeoff** between `sync/atomic` and `sync.Mutex`/`RWMutex`: what each compiles to, when an atomic beats a lock, why `RWMutex` is so often a pessimization, and when going lock-free costs more in correctness risk than it buys. The **correctness** layer is owned elsewhere — the no-copy/`copylocks` rule, "prefer typed atomics over the free functions," `sync.Map`'s two cases, and the "an atomic protects exactly one word" boundary all live in **`go-sync-primitives`**; *why* unsynchronized access is a data race and the happens-before/memory-ordering guarantees live in **`go-race-and-memory-model`**. Read those first for "is this correct"; read this for "is this fast." Every choice here is a **measured** one — the "should I even go lock-free" judgment routes to **`go-perf-methodology`**.

All Go below builds clean under `gofmt`, `go vet`, and `go test` on Go 1.26.

---

## 1. The Cost Model — What Each Primitive Actually Costs

The whole tradeoff rests on what these primitives compile to, contended vs uncontended.

**An uncontended `sync.Mutex.Lock` is a single CAS.** The runtime's `Lock` fast path is one atomic compare-and-swap, with the expensive part split out so the fast path inlines: "`// Fast path: grab unlocked mutex.`" then `atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)`, and "`// Slow path (outlined so that the fast path can be inlined)`" (`src/internal/sync/mutex.go`). So an uncontended mutex is *not* expensive — it is roughly one atomic instruction plus a deferred unlock.

**The cost shows up only under contention.** When the CAS fails, the goroutine enters `lockSlow`: it spins briefly, then **parks** on a semaphore, handing the P to the scheduler. After a waiter is starved for >1 ms the mutex flips to **starvation mode** — "ownership of the mutex is directly handed off from the unlocking goroutine to the waiter at the front of the queue" — because "Starvation mode is important to prevent pathological cases of tail latency" (`src/internal/sync/mutex.go`). That park/wake/handoff is the real lock cost, and it only appears when goroutines actually collide.

**A typed atomic is a single hardware instruction, every time.** `atomic.Int64.Add` lowers to one `LOCK`-prefixed (x86) or LL/SC (arm64) instruction — no park, no scheduler, no spin. So for a **single word**, an atomic strictly dominates a mutex: it does in one instruction what the mutex's fast path does, and skips the slow path entirely.

The consequence: **the atomic-vs-mutex question is decided by how much state you protect, not by a blanket "atomics are faster."** One word → atomic. A multi-field invariant → mutex (an atomic cannot keep two fields consistent — that boundary is `go-sync-primitives`).

---

## 2. Atomic Counters and Flags Beat a Mutex for One Word

A counter, a sequence number, a "ready" flag, a swappable pointer — each is one word, so a typed atomic (Go 1.19+) is both simpler and faster than guarding it with a `Mutex`.

```go
// WRONG — a Mutex to guard a single counter: the lock's bookkeeping buys nothing
type Stats struct {
	mu sync.Mutex
	n  int64
}

func (s *Stats) Inc() {
	s.mu.Lock()
	s.n++
	s.mu.Unlock()
}

// RIGHT — one word, so one atomic instruction; zero value ready, no constructor
type Stats struct{ n atomic.Int64 }

func (s *Stats) Inc()         { s.n.Add(1) }
func (s *Stats) Value() int64 { return s.n.Load() }
```

Use the **typed** `atomic.Int64`/`Bool`/`Pointer[T]` (1.19), not the legacy free functions — the docs steer you there: "Consider using the more ergonomic and less error-prone `Int64.Add` instead" of `atomic.AddInt64` ([sync/atomic](https://pkg.go.dev/sync/atomic)). That "prefer typed" rule is correctness-owned by `go-sync-primitives`; the perf point here is that the typed value *is* the single-instruction path.

---

## 3. Contended Atomics Still Bounce Cache Lines

"Atomic" does not mean "free under contention." A `LOCK`-prefixed instruction must own the cache line exclusively, so when many cores hammer the *same* atomic word, the line ping-pongs between their caches and throughput collapses — the same cache-coherency tax a contended mutex pays, just without the parking.

```go
// A single global counter every core increments does NOT scale, atomic or not:
// the one cache line holding `hits` bounces across every core on every Add.
var hits atomic.Int64
```

The fix is **not** "switch back to a mutex" — it is to stop sharing the one hot line: shard the counter across N cache-line-padded cells and sum on read, or accumulate per-goroutine and flush. Padding hot per-shard fields to a cache line so independent counters don't collide is **false sharing** — owned by **`go-perf-false-sharing`**. The deeper "shard the contended state" pattern is **`go-perf-contention-and-sharding`**. The lesson for *this* skill: an atomic removes the lock's park cost but not the coherency cost, so a contended single atomic can be as unscalable as a contended lock.

---

## 4. RWMutex Is Usually the Wrong Reflex

`RWMutex` looks like a free win for read-heavy data — many readers run concurrently. In practice it is **slower than a plain `Mutex` for short critical sections** and can scale *worse*. Two reasons:

**The read path has its own atomic bookkeeping.** `RLock` is not free: it does an atomic add on a shared counter and may touch a semaphore — `rw.readerCount.Add(1)` and, if a writer is pending, `runtime_SemacquireRWMutexR(...)` (`src/sync/rwmutex.go`). The struct is heavier than a `Mutex` — a full `Mutex` plus two semaphores plus two atomic counters:

```go
type RWMutex struct {
	w           Mutex        // held if there are pending writers
	writerSem   uint32       // semaphore for writers to wait for completing readers
	readerSem   uint32       // semaphore for readers to wait for completing writers
	readerCount atomic.Int32 // number of pending readers
	readerWait  atomic.Int32 // number of departing readers
}
```

So every `RLock`/`RUnlock` pair does *more* atomic work than a single `Mutex` Lock/Unlock. If the critical section is a few instructions (a map read, a field load), that overhead dominates and `RWMutex` loses.

**It scales poorly because every reader writes the same word.** All readers do an atomic `Add` on the *one* shared `readerCount`, so they contend on that single cache line. The Go team documents this: issue **"sync: RWMutex scales poorly with CPU count"** reports that `R{Lock,Unlock}` "degrades by a factor of 8x as it saturates threads and cores, presumably due to cache contention on `&rw.readerCount`" ([golang/go#17973](https://github.com/golang/go/issues/17973)). More reader cores can make an `RWMutex` *slower*, not faster.

**And writers can starve.** A pending writer blocks new readers — "If any goroutine calls `Lock` while the lock is already held by one or more readers, concurrent calls to `RLock` will block until the writer has acquired (and released) the lock" ([sync](https://pkg.go.dev/sync#RWMutex)) — so under a steady read stream the latency picture is worse than it looks.

`RWMutex` wins only when **reads vastly outnumber writes AND each read critical section is long** (long enough that real reader concurrency outweighs the per-`RLock` atomic overhead). Default to `sync.Mutex`; switch to `RWMutex` only with a **measured** read-heavy profile (`go-sync-primitives` states the same default from the correctness side). For read-mostly state, the next section is usually better than either.

---

## 5. `atomic.Pointer` Copy-on-Write: No Lock at All

For **read-mostly** shared state — config, a routing table, a feature-flag set — the fastest "lock" is no lock. Publish the whole value behind an `atomic.Pointer[T]`: readers do one atomic load and see an immutable snapshot; a writer builds a *new* value and swaps the pointer in one store. Readers never block readers, and there is no `RLock` bookkeeping at all.

```go
type Config struct { // treat as immutable once published
	Timeout time.Duration
	Hosts   []string
}

type Server struct {
	cfg atomic.Pointer[Config] // zero value is a nil *Config
}

// Reader hot path: one atomic load, no lock, no reader-vs-reader contention.
func (s *Server) timeout() time.Duration {
	return s.cfg.Load().Timeout
}

// Writer (rare): build a whole new Config, publish it with one atomic store.
func (s *Server) reload(c *Config) {
	s.cfg.Store(c) // never mutate a Config after Store; replace it wholesale
}
```

The discipline that makes this safe: **once a `*Config` is stored, it is immutable** — a writer never mutates the pointed-to value, it always allocates a fresh one and swaps. That immutability is what lets readers skip synchronization entirely; the publish/load pair carries the happens-before (the memory-model guarantee is `go-race-and-memory-model`). This beats both `RWMutex` (no reader bookkeeping, no writer starvation of readers) and a `Mutex` (readers never block) for the read-mostly case. The cost is one allocation per update — fine when updates are rare, which is the whole premise.

---

## 6. When Lock-Free Is Not Worth It

The pull toward hand-rolled lock-free structures (CAS loops, lock-free queues, ABA-dodging tagged pointers) is strong and usually wrong. The package itself warns: atomics "require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the `sync` package" ([sync/atomic](https://pkg.go.dev/sync/atomic)).

The decision is a **risk-vs-reward** one, and it routes to `go-perf-methodology`:

- **A correctly-used `Mutex` is cheap uncontended (§1).** If the lock is not a *measured* bottleneck (a mutex profile — `go-perf-block-mutex-profiles`), a lock-free rewrite buys nothing and adds a class of bugs (`-race` cannot always catch a subtly-wrong lock-free algorithm) that a mutex never had.
- **Atomics span one word.** The moment your invariant covers two fields, a CAS loop has to encode both into one word or you are back to needing a lock — and the encoding is where lock-free bugs breed.
- **The win has to clear the bar.** A lock-free structure that is 10% faster on a path that is 2% of runtime is Amdahl-noise paid for in permanent fragility (`go-perf-methodology` owns this gate).

The honest order: a plain `Mutex`, then a typed atomic for a single word, then `atomic.Pointer` copy-on-write for read-mostly state — and only a measured, profile-proven contention problem justifies anything more exotic.

---

## 7. Routing to Related Skills

**This plugin (`go-perf-*` depth):**

- `go-perf-block-mutex-profiles` — measuring lock/contention cost (mutex & block profiles; enable them first) so the atomic-vs-lock choice is data-driven.
- `go-perf-false-sharing` — padding hot per-shard atomic counters to a cache line so independent words stop ping-ponging (§3).
- `go-perf-contention-and-sharding` — the larger "shard the contended state" fix when one atomic or one lock is the bottleneck.
- `go-perf-methodology` — the "should I even go lock-free / is this contention measured" judgment (§6) and the Amdahl gate.

**Sibling marketplace (`go-*` correctness):**

- `go-sync-primitives` — the no-copy/`copylocks` rule, "prefer typed atomics over the free functions," the "atomic protects one word" boundary, `RWMutex`/`Mutex`/`sync.Map`/`sync.Pool` correctness. Read it for "is this correct."
- `go-race-and-memory-model` — *why* unsynchronized access is a data race, happens-before, and the ordering guarantees the `atomic.Pointer` publish/load (§5) relies on.

---

## 8. Don't

- **Don't assume an uncontended mutex is slow** — its fast path is a single CAS that inlines (§1); reserve "this lock is expensive" for a measured mutex profile.
- **Don't guard a single word with a `Mutex`** where a typed `atomic.Int64`/`Bool`/`Pointer` does it in one instruction (§2).
- **Don't assume atomics are free under contention** — a hot shared atomic word bounces the same cache line a hot lock does; shard or pad it (§3, `go-perf-false-sharing`).
- **Don't reach for `RWMutex` by reflex.** For short read sections it is slower than a plain `Mutex` and scales worse on many cores ([golang/go#17973](https://github.com/golang/go/issues/17973)); it pays only for long, read-dominated sections, measured (§4).
- **Don't mutate a value after publishing it through `atomic.Pointer`** — copy-on-write requires the pointed-to value be immutable; build a new one and swap (§5).
- **Don't hand-roll a lock-free structure for an unmeasured bottleneck** — a correct `Mutex` is cheap, and lock-free bugs evade `-race` (§6, `go-perf-methodology`).
- **Don't eyeball memory ordering** — the *correctness* of atomic ordering (happens-before, acquire/release) is owned by `go-race-and-memory-model`, not something to reason about by hand here.

---

## 9. Reference Files

High-frequency atomics-vs-locks performance mistakes in LLM-generated Go, each with a wrong/right contrast and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

