Mutex vs Atomic — Find the Bug¶
Ten buggy snippets. Each compiles. Each is broken. Spot the bug before you read the explanation.
Bug 1 — Mixed Atomic and Non-Atomic Access¶
var counter int64
func add() { atomic.AddInt64(&counter, 1) }
func read() int64 { return counter } // <-- plain read
The bug. read() does a non-atomic load of a word that other goroutines write atomically. The Go memory model explicitly does not define this. The race detector flags it with Go 1.19+.
The fix. All accesses must be atomic, or none of them:
Or use the typed atomic, which makes plain access impossible:
Bug 2 — Lost Update via Load-Modify-Store¶
var n atomic.Int64
func incrementIfPositive() {
v := n.Load()
if v > 0 {
n.Store(v + 1) // <-- lost-update window between Load and Store
}
}
The bug. Between Load and Store, another goroutine can have its own Load followed by Store. Both see v=5, both write 6. The counter increments once instead of twice.
The fix. A CAS loop:
func incrementIfPositive() {
for {
v := n.Load()
if v <= 0 {
return
}
if n.CompareAndSwap(v, v+1) {
return
}
}
}
Bug 3 — 32-bit ARM Alignment Crash¶
type Stats struct {
Name string
Counter int64 // <-- not first; on 32-bit ARM may not be 8-byte aligned
}
func (s *Stats) Inc() {
atomic.AddInt64(&s.Counter, 1) // panics on 32-bit ARM
}
The bug. string is 8 bytes on 64-bit but 8 bytes (header) on 32-bit too — wait, the issue is more subtle. On 32-bit ARM the struct header begins at an 8-byte boundary (heap allocation guarantee), Name occupies 8 bytes (two 4-byte words: pointer + length), so Counter ends up at offset 8, which is aligned. Try harder:
This struct: Tag at offset 0, Counter at offset 4. On 32-bit ARM, atomic.AddInt64(&s.Counter, 1) invokes LDREXD on an offset-4 address, which raises a hardware alignment fault. The Go runtime catches it and panics with unaligned 64-bit atomic operation.
The fix (1.19+). Use atomic.Int64, which carries align64 and forces correct alignment:
The fix (pre-1.19). Put 64-bit atomics first:
Bug 4 — ABA in a Hand-Rolled Pool¶
type Pool struct {
head atomic.Pointer[node]
}
type node struct {
next *node
buf [4096]byte
}
// Returned nodes are placed in a sync.Pool for reuse to save allocations.
var nodePool = sync.Pool{New: func() any { return new(node) }}
func (p *Pool) Get() *node {
for {
old := p.head.Load()
if old == nil {
return nodePool.Get().(*node)
}
if p.head.CompareAndSwap(old, old.next) {
return old
}
}
}
func (p *Pool) Put(n *node) {
nodePool.Put(n) // <-- the bug: caller reused too early
}
Wait — the bug is wider. The Treiber stack above is correct in Go (GC keeps nodes alive). The bug is that Put recycles into a sync.Pool, which then hands the same *node back to another goroutine that pushes it onto the stack while a third goroutine is mid-CAS holding the same address. Classic ABA.
The bug. Hand-rolled object pooling breaks Go's GC-based ABA protection. The same *node address can be observed by two goroutines in two states.
The fix. Do not pool nodes used in lock-free structures. Let the GC handle it. If allocation pressure is real, switch to a different structure (e.g. an array-based queue with a tag).
Bug 5 — Compound Invariant Treated as Atomic¶
type Account struct {
Balance atomic.Int64
History []Tx
mu sync.Mutex
}
func (a *Account) Withdraw(amt int64, tx Tx) {
a.Balance.Add(-amt)
a.mu.Lock()
a.History = append(a.History, tx)
a.mu.Unlock()
}
The bug. Balance and history are updated separately. Another goroutine reading Balance then History (under its own protocol) can see balance decremented but no matching transaction. Atomicity of one word does not give atomicity of two fields.
The fix. Protect both under the same mutex:
func (a *Account) Withdraw(amt int64, tx Tx) {
a.mu.Lock()
defer a.mu.Unlock()
a.Balance -= amt // now plain int64; the mutex protects it
a.History = append(a.History, tx)
}
(And change Balance from atomic.Int64 to int64 — having both is worse than either alone.)
Bug 6 — Forgetting the CAS Result¶
var head atomic.Pointer[Node]
func Push(n *Node) {
n.next = head.Load()
head.CompareAndSwap(n.next, n) // <-- ignored return value
}
The bug. If CAS fails (another goroutine pushed first), this just drops the push. The new node is lost. CompareAndSwap returns false on failure; the caller must loop.
The fix.
func Push(n *Node) {
for {
old := head.Load()
n.next = old
if head.CompareAndSwap(old, n) {
return
}
}
}
Bug 7 — Reading Inside a Mutex, Caching the Pointer¶
var (
mu sync.RWMutex
cfg *Config
)
func Get() *Config {
mu.RLock()
defer mu.RUnlock()
return cfg
}
func Reload(c *Config) {
mu.Lock()
cfg = c
mu.Unlock()
}
The bug. This is correct but pays for RWMutex on every read. The author wrote it because they assumed pointer reads need protection. Pointer reads of one word do not need a mutex — they need an atomic.Pointer.
Performance fix.
var cfg atomic.Pointer[Config]
func Get() *Config { return cfg.Load() }
func Reload(c *Config) { cfg.Store(c) }
10-50x faster under read pressure, no contention on the lock's internal state.
Bug 8 — Atomic Bool Reused as a Mutex¶
var locked atomic.Bool
func tryLock() bool {
if locked.Load() {
return false
}
locked.Store(true)
return true
}
The bug. Load then Store is not atomic. Two goroutines can both see Load() == false, both call Store(true), both think they hold the lock.
The fix. One-shot CAS:
func tryLock() bool {
return locked.CompareAndSwap(false, true)
}
func unlock() {
locked.Store(false)
}
(Although: if you need a lock, use sync.Mutex. This pattern is a spinlock and is almost always a mistake in Go because it does not integrate with the scheduler.)
Bug 9 — atomic.Value Type Mismatch¶
var v atomic.Value
func init() {
v.Store(uint32(0))
}
func tick() {
v.Store(int32(1)) // panics: store inconsistent type
}
The bug. atomic.Value requires every Store to use the same dynamic type. The first Store fixed uint32; the second Store with int32 panics with sync/atomic: store of inconsistently typed value into Value.
The fix. Be consistent (use int32 everywhere) or use atomic.Int32/atomic.Uint32 which is type-checked at compile time. In new code, prefer the typed atomics.
Bug 10 — Padding Forgotten on Hot Atomics¶
The bug. Requests and Errors share a cache line. Two goroutines, each on a different core, both incrementing their respective counter, cause false sharing: every write invalidates the other CPU's cache line. Performance collapses by 5-10x compared to padded counters.
The fix.
type Metrics struct {
Requests atomic.Int64
_ [56]byte // pad to cache line (64 bytes total)
Errors atomic.Int64
_ [56]byte
}
A subtle related bug: cache lines are 64 bytes on amd64, ARM64, and most modern CPUs, but some Intel CPUs prefetch in 128-byte pairs. The Go runtime conservatively pads to 64 in runtime/internal/cpu.CacheLinePadSize. For hot performance code, profile and consider 128.
Summary Table¶
| Bug | Root cause | Fix |
|---|---|---|
| 1 | Mixed atomic/non-atomic | Atomic everywhere |
| 2 | Load-modify-store gap | CAS loop |
| 3 | 32-bit ARM alignment | atomic.Int64 or first-field |
| 4 | ABA from hand-rolled pooling | Trust the GC |
| 5 | Compound invariant | One mutex over all fields |
| 6 | Ignored CAS return | Loop until CAS succeeds |
| 7 | RWMutex for one-pointer publish | atomic.Pointer[T] |
| 8 | Load-then-store TryLock | CompareAndSwap |
| 9 | atomic.Value type mismatch | Typed atomics |
| 10 | Cache-line false sharing | Pad to 64 bytes |
Bug 11 — Atomic on a Copied Struct¶
type Counter struct {
n atomic.Int64
}
func (c Counter) Inc() { // receiver is VALUE, not pointer
c.n.Add(1)
}
func main() {
var c Counter
for i := 0; i < 1000; i++ {
go c.Inc()
}
// ... wait ...
fmt.Println(c.n.Load()) // always 0
}
The bug. Inc has a value receiver, so each call gets its own copy of Counter. Every Add increments a temporary stack-local atomic. The original c.n is never touched.
go vet catches this with the -copylocks checker: atomic.Int64 embeds noCopy, and vet warns about copying it.
The fix.
Pointer receiver. Or: pass *Counter to goroutines explicitly.
Bug 12 — Type Mismatch in atomic.Value¶
var cfg atomic.Value
func init() {
cfg.Store(&Config{...})
}
func reset() {
cfg.Store(nil) // panics: store of nil concrete value
}
The bug. atomic.Value.Store(nil) panics because the dynamic type of nil is <nil>, which differs from the first-stored type (*Config).
The fix. Store a typed nil:
Or use atomic.Pointer[Config], where Store(nil) works correctly because the pointer type is fixed at compile time.
Bug 13 — Forgetting Memory Order Across a Channel¶
var ready bool
done := make(chan struct{})
go func() {
ready = true
close(done)
}()
<-done
if ready {
// ...
}
Wait — is this a bug? Reading the Go memory model carefully: a channel send (close) is synchronised before the receive that observes it. Writes done before the close are visible after the receive. So ready = true is visible after <-done.
This is actually CORRECT. The bug is the assumption that you need atomic here. You do not. Channels carry memory order.
The bug PATTERN this teaches: do not slap atomic.Bool on every shared bool. If a channel or mutex already orders the access, you do not need atomics. Over-atomicising is a real code smell.
Bug 14 — Two Atomic Counters Read Non-Jointly¶
type Stats struct {
Sent atomic.Int64
Received atomic.Int64
}
func (s *Stats) RoundTrip() (sent, received int64) {
return s.Sent.Load(), s.Received.Load()
}
The bug. Each Load is atomic. The pair is not. Between the two loads, sends and receives may happen. If Sent.Load() = 100 and then between the two loads goroutine X sends and the recipient receives (so both increment), Received.Load() = 100 even though only 99 of those receives are for the 100 sends already observed.
For most monitoring this is fine. For invariants (e.g., "sent >= received"), it is broken.
The fix. One atomic.Pointer[Stats] published as a snapshot. Or a mutex held over both loads.
Summary Table (extended)¶
| Bug | Root cause | Fix |
|---|---|---|
| 11 | Value receiver copies atomic | Pointer receiver, vet catches |
| 12 | atomic.Value nil type mismatch | Typed nil, or atomic.Pointer |
| 13 | Atomic where channel/mutex suffices | Trust the existing synchronisation |
| 14 | Joint snapshot expectation | Snapshot struct under atomic.Pointer |