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
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user