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
55 lines
2.9 KiB
Markdown
55 lines
2.9 KiB
Markdown
# 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.
|