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
2.9 KiB
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.Int64for independent counters/flags/gauges: lock-free, a single LOCK-prefixed instruction on amd64. Use the typed API (atomic.Int64), not the legacyatomic.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.