Files
learning-go-for-sre-interview/09-safe-counter
kacerrandClaude Fable 5 7219eb5a23 Initial commit: Go SRE interview exercises
Includes solved 02-worker-pool and 09-safe-counter (mutex, atomic, sharded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
2026-08-28 22:18:09 +02:00
..

09 — Safe Counters, Three Ways

Trivial-looking, but it's the interviewer's synchronization litmus test — and the benchmark at the end teaches something real about multicore hardware.

What a data race actually is (get the definition razor-sharp)

Two accesses to the same memory location, at least one a write, not ordered by a happens-before edge. Not "they happen at the same time" — unordered. The Go memory model promises basically nothing for racy programs (a racy read can observe a torn/stale/impossible value), so "it works on my machine" is not evidence of anything. -race instruments happens-before via vector clocks (ThreadSanitizer); it has ~5-10x slowdown, only catches races that occur during the run, and is still the best deal in software testing. Run integration environments with it if you can afford to.

Bonus distinction interviewers fish for: data race (memory-level, defined above) vs race condition (logic-level, e.g. check-then-act on a file that another process swaps). You can have either without the other.

Mutex vs atomic — when each

  • atomic.Int64 for independent counters/flags/gauges: lock-free, a single LOCK-prefixed instruction on amd64. Use the typed API (atomic.Int64), not the legacy atomic.AddInt64(&x, 1) — the typed one can't be misused with an unaligned or copied address.
  • The moment an operation touches two things that must stay consistent (map + list in the LRU, tokens + timestamp in the limiter), atomics stop composing and you want a mutex. "Atomics don't compose" is the sentence.
  • sync.Map: only for append-mostly maps or disjoint per-goroutine keysets (its docstring says so). A mutexed map beats it for general workloads.

Why sharding works: false sharing and cache lines

Run the benchmarks with -cpu=1,4,8. The single-mutex counter gets slower with more cores: every Inc bounces the mutex's cache line between cores (cache-coherence traffic, MESI protocol), and cores stall waiting for exclusive ownership. Sharding gives each hot key set its own mutex on its own cache line → contention divides by shard count.

The deep cut: even separate atomics hurt if they share a 64-byte cache line — false sharing. High-performance Go code pads hot fields (_ [64]byte) or uses runtime/internal-style per-P structures. This is exactly why Prometheus client_golang counters are fast: per-goroutine-ish sharded values summed on scrape.

Snapshot-as-copy: the API design point

Returning the internal map hands the caller a live reference into your critical section — they'll range over it while you write, and the runtime throws concurrent map iteration and map write (fatal, unrecoverable, not a panic you can catch). Copy under the lock, return the copy. General principle: never let references escape the mutex. Same reason you don't return internal slices from locked structs without copying.