| name | go-perf-atomics-vs-locks |
| description | 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. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
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
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.
type Stats struct {
mu sync.Mutex
n int64
}
func (s *Stats) Inc() {
s.mu.Lock()
s.n++
s.mu.Unlock()
}
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). 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.
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:
type RWMutex struct {
w Mutex
writerSem uint32
readerSem uint32
readerCount atomic.Int32
readerWait atomic.Int32
}
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). 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) — 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.
type Config struct {
Timeout time.Duration
Hosts []string
}
type Server struct {
cfg atomic.Pointer[Config]
}
func (s *Server) timeout() time.Duration {
return s.cfg.Load().Timeout
}
func (s *Server) reload(c *Config) {
s.cfg.Store(c)
}
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).
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); 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:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml