Copy-on-Write — Hands-on Tasks¶
Practical exercises from easy to hard. Each task says what to build, what success looks like, and a hint or expected outcome. Solutions or solution sketches are at the end.
Easy¶
Task 1 — First COW config¶
Write a Config struct with a LogLevel field and a Store type using atomic.Pointer[Config]. Implement Get() and SetLogLevel(string) methods. Verify with a test that the level changes are visible to subsequent Get calls.
- Use a writer mutex.
- Initialize with
LogLevel: "info".
Goal. Learn the basic load-copy-publish pattern.
Task 2 — Snapshot consistency¶
Add a Timeout time.Duration field to Config. Implement SetTimeout and a combined SetLevelAndTimeout method. Show that a reader who Loads once sees both fields consistent.
- One Load per logical operation.
- Combined update is one publish.
Goal. Understand snapshot consistency for multi-field reads.
Task 3 — Slice deep copy¶
Add a Hosts []string field. Implement AddHost(string). Make sure the new snapshot has a fresh backing array — don't append in place.
next.Hosts = append([]string(nil), old.Hosts...)- Run with
-raceto verify no race.
Goal. Internalise the slice deep-copy rule.
Task 4 — Map deep copy¶
Add a Features map[string]bool field. Implement SetFeature(name string, on bool). Make sure the new snapshot has a fresh map — don't mutate the old one.
next.Features = make(map[string]bool, len(old.Features))- Copy entries then update.
Goal. Internalise the map deep-copy rule.
Task 5 — Concurrent torture test¶
Write a test that spawns 100 readers and 10 writers, each looping for 1 second. Run with -race. Verify no race detected and the final state is consistent (e.g., the number of unique hosts added matches what writers added).
Goal. Learn to torture-test concurrent code.
Medium¶
Task 6 — Validation before publish¶
Add a MaxRetries int field with constraint 0 <= MaxRetries <= 100. Implement SetMaxRetries(int) error that returns an error if out of range and does not publish.
- Verify with a test that an invalid update preserves the old snapshot.
Goal. Master "validate before publish."
Task 7 — Generic Store¶
Refactor your Store into a generic Store[T any] that takes an Update(fn func(*T)) method. Use it with at least two different types.
- The library can't enforce deep-copy of inner slices/maps; document this.
Goal. Practice generic COW patterns.
Task 8 — Subscriber channel¶
Add a Subscribe() (<-chan *Config, func()) method. Writers should non-blocking-send the new snapshot to each subscriber. Returned function unsubscribes.
- Verify subscribers receive updates and unsubscribe cleans up.
Goal. Implement watcher notifications.
Task 9 — Hot reload from file¶
Write a Reload(path string) error method that reads JSON from a file, parses into Config, validates, and publishes. On error, the old snapshot remains current.
- Test with a deliberately bad file.
Goal. Implement production-style reload.
Task 10 — SIGHUP handler¶
Add a goroutine that listens for SIGHUP and calls Reload. Demonstrate from a separate terminal: edit config file, send SIGHUP, observe change.
Goal. Integrate COW with operating system signals.
Task 11 — Version tagging¶
Add a Version int64 field. Increment monotonically on every publish. Expose Version() separately. Allow optimistic concurrency via CompareAndSet(expectedVersion, fn).
Goal. Implement versioned snapshots.
Task 12 — Snapshot age metric¶
Add PublishedAt time.Time field. Implement Age() time.Duration. Emit expvar.Func so /debug/vars shows current age.
Goal. Add basic observability.
Hard¶
Task 13 — Sharded COW map¶
Implement a ShardedMap[K comparable, V any] with 16 shards, each its own atomic.Pointer + mutex. Implement Get, Set, Delete, Len (sum across shards). Benchmark write throughput vs single-shard.
- Use a fast hash to determine the shard.
- Demonstrate parallel writes scale linearly with shard count.
Goal. Practice sharded COW design.
Task 14 — COW with rollback¶
Implement an Update(fn func(*Config) error) error that allows fn to return an error to abort the update. Show that aborted updates don't publish and the previous snapshot remains current.
Goal. Implement transactional-style updates.
Task 15 — Snapshot diff¶
Add a Diff(old, new *Config) Diff that returns added, removed, and changed fields. Test that diffs are correctly computed for various field changes.
- Use
reflector write explicit code per field.
Goal. Build change-detection on top of COW.
Task 16 — Persistent HAMT (simplified)¶
Implement a simplified persistent hash map with branching factor 4 (just for clarity). Wrap it in a COW store. Compare write costs against a plain rebuild map.
- 4-way branching makes the trees small enough to debug.
- Test that old versions remain valid after updates.
Goal. Understand structural sharing concretely.
Task 17 — Batched writes¶
Implement a BatchUpdate(fns []func(*Config)) that applies all updates in one rebuild. Compare GC pressure against N individual updates.
Goal. Reduce GC pressure for high write rates.
Task 18 — Layered COW¶
Implement a Layered struct with Base *Map and Override *Map. Reads check Override first, then Base. Implement Compact that merges Override into Base. Demonstrate compaction reduces read latency over time.
Goal. Master generational COW.
Task 19 — Snapshot replication across processes¶
Build a two-process system: process A holds the canonical config; process B reads it via HTTP. Each fetch returns the current snapshot. Demonstrate that B can update its local cache from A's fetches.
Goal. Apply COW to distributed systems.
Task 20 — RCU-style with explicit reclamation¶
Implement a COW store where the old snapshot's Close() method is called when the last reader finishes. Use atomic counters; do not rely on the GC for reclamation timing.
Goal. Implement RCU semantics explicitly.
Expert¶
Task 21 — Bench COW vs RWMutex vs sync.Map¶
Write a comprehensive benchmark suite comparing COW (with atomic.Pointer), RWMutex, and sync.Map for: - Pure reads. - 90R/10W workloads. - 50/50 workloads. - 1K, 10K, 100K entry sizes.
Plot the results.
Goal. Develop benchmark intuition.
Task 22 — COW with metrics exporter¶
Build a COW config store that exports Prometheus metrics: - config_version. - config_age_seconds. - config_updates_total. - config_reload_failures_total.
Demonstrate the dashboard updates as you reload.
Goal. Integrate COW with observability tooling.
Task 23 — Lock-free CAS-based writer¶
Implement an Update(fn func(*Config) *Config) that uses CAS instead of a mutex. Show that under low contention it matches mutex performance; under high contention it suffers retries.
fnmust be idempotent and pure.
Goal. Practice lock-free programming.
Task 24 — Snapshot serialization¶
Add Marshal() and Unmarshal() methods to Config. Use them to checkpoint snapshots to disk and reload on startup. Demonstrate crash recovery.
Goal. Combine COW with persistence.
Task 25 — Production-grade COW package¶
Build a reusable Go package github.com/yourname/cow with: - Typed Store[T]. - Pluggable Source interface. - Subscriber channels with backpressure handling. - Metrics hooks. - Documentation and examples. - Race-detector-clean tests. - Benchmarks.
Publish to GitHub. Get others to use it.
Goal. Build a real, shareable COW library.
Bonus¶
Task 26 — Find-the-bug¶
Look at the buggy code in find-bug.md. Identify each bug. Suggest fixes.
Goal. Sharpen bug-spotting reflexes.
Task 27 — Optimize-the-pattern¶
Look at the slow code in optimize.md. Apply the suggested optimizations. Measure the improvement.
Goal. Apply optimization patterns.
Task 28 — Implement Go's atomic.Value from scratch¶
Without looking at the source, implement an atomic.Value using unsafe.Pointer and atomic primitives. Match the type-consistency invariant. Compare against the standard library.
Goal. Understand atomic.Value deeply.
Task 29 — Read the runtime source¶
Read src/sync/atomic/type.go, src/runtime/internal/atomic/atomic_amd64.s. Summarize what each file does in a paragraph.
Goal. Build comfort with the runtime.
Task 30 — Write a postmortem¶
Imagine your COW system caused a production incident: 5 GB memory growth in 1 hour. Write a fictional postmortem identifying root cause, contributing factors, and mitigations.
Goal. Practice incident analysis.
Solutions and Solution Sketches¶
Solution to Task 1¶
package config
import (
"sync"
"sync/atomic"
)
type Config struct {
LogLevel string
}
type Store struct {
cur atomic.Pointer[Config]
mu sync.Mutex
}
func New() *Store {
s := &Store{}
s.cur.Store(&Config{LogLevel: "info"})
return s
}
func (s *Store) Get() *Config { return s.cur.Load() }
func (s *Store) SetLogLevel(level string) {
s.mu.Lock()
defer s.mu.Unlock()
old := s.cur.Load()
next := *old
next.LogLevel = level
s.cur.Store(&next)
}
Test:
func TestSetLogLevel(t *testing.T) {
s := New()
if s.Get().LogLevel != "info" { t.Fatal("initial") }
s.SetLogLevel("debug")
if s.Get().LogLevel != "debug" { t.Fatal("after set") }
}
Solution to Task 3¶
func (s *Store) AddHost(h string) {
s.mu.Lock()
defer s.mu.Unlock()
old := s.cur.Load()
next := *old
next.Hosts = append([]string(nil), old.Hosts...)
next.Hosts = append(next.Hosts, h)
s.cur.Store(&next)
}
Race-test:
func TestRaceFreeAdd(t *testing.T) {
s := New()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); s.AddHost("x") }()
}
// concurrent readers
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); _ = len(s.Get().Hosts) }()
}
wg.Wait()
if len(s.Get().Hosts) != 100 { t.Fatal("count") }
}
Solution to Task 7¶
type Store[T any] struct {
cur atomic.Pointer[T]
mu sync.Mutex
}
func New[T any](initial *T) *Store[T] {
s := &Store[T]{}
s.cur.Store(initial)
return s
}
func (s *Store[T]) Get() *T { return s.cur.Load() }
func (s *Store[T]) Update(fn func(*T)) {
s.mu.Lock()
defer s.mu.Unlock()
old := s.cur.Load()
next := *old
fn(&next)
s.cur.Store(&next)
}
Use with multiple types:
type Config struct{ X int }
type Counters struct{ N int64 }
func main() {
cs := New(&Config{X: 1})
cs.Update(func(c *Config) { c.X = 2 })
cn := New(&Counters{N: 0})
cn.Update(func(c *Counters) { c.N++ })
}
Solution to Task 8¶
type Store[T any] struct {
cur atomic.Pointer[T]
mu sync.Mutex
chans []chan *T
}
func (s *Store[T]) Subscribe() (<-chan *T, func()) {
ch := make(chan *T, 1)
s.mu.Lock()
s.chans = append(s.chans, ch)
s.mu.Unlock()
unsub := func() {
s.mu.Lock()
defer s.mu.Unlock()
for i, c := range s.chans {
if c == ch {
s.chans = append(s.chans[:i], s.chans[i+1:]...)
close(ch)
return
}
}
}
return ch, unsub
}
func (s *Store[T]) Update(fn func(*T)) {
s.mu.Lock()
defer s.mu.Unlock()
old := s.cur.Load()
next := *old
fn(&next)
s.cur.Store(&next)
for _, ch := range s.chans {
select { case ch <- &next: default: }
}
}
Solution to Task 13¶
const NShards = 16
type ShardedMap[K comparable, V any] struct {
shards [NShards]struct {
cur atomic.Pointer[map[K]V]
mu sync.Mutex
}
}
func NewShardedMap[K comparable, V any]() *ShardedMap[K, V] {
s := &ShardedMap[K, V]{}
for i := range s.shards {
m := make(map[K]V)
s.shards[i].cur.Store(&m)
}
return s
}
func (s *ShardedMap[K, V]) shardFor(k K) int {
h := fnv.New32()
h.Write([]byte(fmt.Sprint(k)))
return int(h.Sum32()) % NShards
}
func (s *ShardedMap[K, V]) Get(k K) (V, bool) {
v, ok := (*s.shards[s.shardFor(k)].cur.Load())[k]
return v, ok
}
func (s *ShardedMap[K, V]) Set(k K, v V) {
sh := &s.shards[s.shardFor(k)]
sh.mu.Lock()
defer sh.mu.Unlock()
old := *sh.cur.Load()
next := make(map[K]V, len(old)+1)
for kk, vv := range old { next[kk] = vv }
next[k] = v
sh.cur.Store(&next)
}
Benchmark shows ~16× write throughput improvement vs single-shard for parallel writes.
Solution to Task 17¶
func (s *Store[T]) BatchUpdate(fns []func(*T)) {
s.mu.Lock()
defer s.mu.Unlock()
old := s.cur.Load()
next := *old
for _, fn := range fns {
fn(&next)
}
s.cur.Store(&next)
}
One allocation per batch, regardless of batch size. With 100 updates per batch, GC pressure reduced ~100×.
Solution to Task 23¶
func (s *Store[T]) UpdateCAS(fn func(*T) *T) {
for {
old := s.cur.Load()
next := fn(old)
if s.cur.CompareAndSwap(old, next) {
return
}
}
}
Note: fn must be pure — it may run multiple times. It should not modify old.
Solution to Task 16 (Simplified HAMT)¶
type SimpleHAMT struct {
children [4]any // either *SimpleHAMT or *kv
bitmap uint8
}
type kv struct {
key string
val any
}
func (h *SimpleHAMT) Get(key string) (any, bool) {
return h.get(hashSimple(key), 0, key)
}
func (h *SimpleHAMT) get(hash uint32, level uint, key string) (any, bool) {
if h == nil { return nil, false }
idx := (hash >> (level * 2)) & 0b11
bit := uint8(1) << idx
if h.bitmap&bit == 0 { return nil, false }
pos := popcount8(h.bitmap & (bit - 1))
child := h.children[pos]
if e, ok := child.(*kv); ok {
if e.key == key { return e.val, true }
return nil, false
}
return child.(*SimpleHAMT).get(hash, level+1, key)
}
// ... Set, Delete similarly
A 4-way branching keeps trees small and easy to visualize.
Tips for Working Through Tasks¶
- Start with the easy tasks. Even if they look trivial, they build muscle memory.
- Run with -race always. Catch bugs early.
- Add a test for every method. Tests are documentation.
- Benchmark when measuring. Numbers > opinions.
- Pair with someone if stuck. Two heads on COW bugs is dramatically more efficient.
- Read your code aloud. Bug-finding often happens when you explain code to a rubber duck.
Closing¶
These 30 tasks span the full COW curriculum. Working through 10-15 of them builds the skills described in the four levels. Working through all 30 makes you a COW expert.
Pick a few that interest you and start coding. The learning is in the doing.
Good luck.