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.
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user