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:
2026-08-28 22:18:09 +02:00
co-authored by Claude Fable 5
commit 7219eb5a23
43 changed files with 2865 additions and 0 deletions
+54
View File
@@ -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.
+131
View File
@@ -0,0 +1,131 @@
// Exercise 09 — Concurrency-Safe Counters (three ways)
//
// ASSIGNMENT
// ----------
// A warm-up screener that interviewers run with -race. Implement THREE
// versions of a keyed counter and one plain counter:
//
// 1. MutexCounter: map[string]int64 guarded by a sync.Mutex.
// Inc(key), Get(key), Snapshot() (a COPY of the map — why a copy? if you
// return the internal map, the caller can race on it).
// 2. AtomicCounter: a single int64 total using sync/atomic only.
// Inc(), Get(). No mutex allowed.
// 3. ShardedCounter: N shards, each its own mutex+map, key routed by hash.
// Same interface as MutexCounter. This is how you fix mutex contention
// on hot maps.
//
// Run the tests: go test -race ./09-safe-counter/
// Then benchmark: go test -bench=. -cpu=1,4,8 ./09-safe-counter/
package main
import (
"fmt"
"hash/fnv"
"sync"
"sync/atomic"
)
// ---- 1. Mutex version ----
type MutexCounter struct {
mu sync.Mutex
m map[string]int64
}
func NewMutexCounter() *MutexCounter {
return &MutexCounter{
m: make(map[string]int64),
}
}
func (c *MutexCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key]++
}
func (c *MutexCounter) Get(key string) int64 {
c.mu.Lock()
defer c.mu.Unlock()
return (c.m[key])
}
func (c *MutexCounter) Snapshot() map[string]int64 {
c.mu.Lock()
defer c.mu.Unlock()
// possibly could use maps.clone
outmap := make(map[string]int64, len(c.m))
for k, v := range c.m {
outmap[k] = v
}
return outmap
}
// ---- 2. Atomic version ----
type AtomicCounter struct {
n atomic.Int64
}
func NewAtomicCounter() *AtomicCounter { return &AtomicCounter{} }
func (c *AtomicCounter) Inc() { c.n.Add(1) }
func (c *AtomicCounter) Get() int64 { return c.n.Load() }
// ---- 3. Sharded version ----
const shards = 16
type shard struct {
mu sync.Mutex
m map[string]int64
}
type ShardedCounter struct {
s [shards]shard
// route with fnv-1a: hash(key) % shards (hash/fnv or hand-rolled)
}
func NewShardedCounter() *ShardedCounter {
c := &ShardedCounter{}
// loop over shards and initialize each shard's map
for i := 0; i < shards; i++ {
c.s[i].m = make(map[string]int64)
}
return c
}
func (c *ShardedCounter) Inc(key string) {
// find shard for the key
hashF := fnv.New32a()
hashF.Write([]byte(key))
shard := hashF.Sum32() % shards
c.s[shard].mu.Lock()
defer c.s[shard].mu.Unlock()
// fmt.Println("shard", shard, "key", key, "value", c.s[shard].m[key])
c.s[shard].m[key]++
}
func (c *ShardedCounter) Get(key string) int64 {
// find shard for the key
hashF := fnv.New32a()
hashF.Write([]byte(key))
shard := hashF.Sum32() % shards
c.s[shard].mu.Lock()
defer c.s[shard].mu.Unlock()
return c.s[shard].m[key]
}
func (c *ShardedCounter) Snapshot() map[string]int64 {
out := make(map[string]int64)
for i := 0; i < shards; i++ {
c.s[i].mu.Lock()
for k, v := range c.s[i].m {
out[k] = v
}
c.s[i].mu.Unlock()
}
return out
}
func main() {
c := NewMutexCounter()
c.Inc("requests")
c.Inc("requests")
fmt.Println(c.Get("requests"), c.Snapshot())
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"fmt"
"sync"
"testing"
)
const (
goroutines = 16
perG = 1000
)
func TestMutexCounter(t *testing.T) {
c := NewMutexCounter()
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < perG; i++ {
c.Inc(fmt.Sprintf("k%d", i%4))
}
}(g)
}
wg.Wait()
var total int64
for i := 0; i < 4; i++ {
total += c.Get(fmt.Sprintf("k%d", i))
}
if want := int64(goroutines * perG); total != want {
t.Fatalf("lost updates: want %d got %d", want, total)
}
}
func TestSnapshotIsACopy(t *testing.T) {
c := NewMutexCounter()
c.Inc("a")
snap := c.Snapshot()
if snap == nil || snap["a"] != 1 {
t.Fatalf("bad snapshot: %v", snap)
}
snap["a"] = 999 // mutating the snapshot must not affect the counter
if c.Get("a") != 1 {
t.Fatal("Snapshot returned the internal map, not a copy")
}
// And writing to the snapshot while counting must not race (-race checks).
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); for i := 0; i < 100; i++ { c.Inc("a") } }()
for i := 0; i < 100; i++ {
snap["b"] = int64(i)
}
wg.Wait()
}
func TestAtomicCounter(t *testing.T) {
c := NewAtomicCounter()
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perG; i++ {
c.Inc()
}
}()
}
wg.Wait()
if want := int64(goroutines * perG); c.Get() != want {
t.Fatalf("lost updates: want %d got %d", want, c.Get())
}
}
func TestShardedCounter(t *testing.T) {
c := NewShardedCounter()
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < perG; i++ {
c.Inc(fmt.Sprintf("key-%d", i%50))
}
}(g)
}
wg.Wait()
var total int64
for k, v := range c.Snapshot() {
_ = k
total += v
}
if want := int64(goroutines * perG); total != want {
t.Fatalf("lost updates across shards: want %d got %d", want, total)
}
if c.Get("key-0") == 0 {
t.Fatal("Get by key broken")
}
}
func BenchmarkMutexCounter(b *testing.B) {
c := NewMutexCounter()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Inc("hot")
}
})
}
func BenchmarkShardedCounter(b *testing.B) {
c := NewShardedCounter()
keys := [8]string{"a", "b", "c", "d", "e", "f", "g", "h"}
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
c.Inc(keys[i&7])
i++
}
})
}
func BenchmarkAtomicCounter(b *testing.B) {
c := NewAtomicCounter()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Inc()
}
})
}