Skip to content

Decision Tree — Tasks

← Back

Ten mini-scenarios. For each, the task is the same: pick a primitive and implement it. The "primitive" line names the answer; the "implementation" block is what you should write before reading further. Do not memorize the implementations — close the page, write your own, then diff.

Task 1 — Process counter visible to monitoring

Scenario: A server processes incoming requests. Each handler increments a counter; a /metrics endpoint reads it.

Primitive: atomic.Int64.

Implementation:

type Server struct {
    requests atomic.Int64
}

func (s *Server) handle(req *Request) {
    s.requests.Add(1)
    // ... handle request ...
}

func (s *Server) metricsHandler(w http.ResponseWriter, _ *http.Request) {
    fmt.Fprintf(w, "requests=%d\n", s.requests.Load())
}

Why not a channel? A channel of increment-events forces every handler through a single consumer goroutine and adds allocation. Atomic Add is one CPU instruction.

Why not a mutex? A mutex around the int64 works, but adds an unnecessary lock acquisition on every request. Atomic is strictly cheaper for a single-value update.

Task 2 — Fan-out HTTP fetches with bounded concurrency

Scenario: Given 1000 URLs, fetch them all in parallel but cap concurrent requests at 20. Return the first error if any.

Primitive: errgroup.Group with SetLimit(20).

Implementation:

func fetchAll(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(20)

    results := make([]Response, len(urls))
    for i, u := range urls {
        i, u := i, u
        g.Go(func() error {
            resp, err := fetch(ctx, u)
            if err != nil {
                return err
            }
            results[i] = resp
            return nil
        })
    }
    return g.Wait()
}

Why not buffered channel + WaitGroup? That is what errgroup.SetLimit does internally, with the bonus of error short-circuit and context cancellation. Reimplementing it is busywork.

Task 3 — Lazy initialization of an expensive client

Scenario: A database client takes 200 ms to construct. Some request paths never need it. Only construct it on first use; subsequent users must wait if construction is in progress.

Primitive: sync.OnceValue (Go 1.21+) or sync.Once (any version).

Implementation:

var dbClient = sync.OnceValue(func() *DBClient {
    return newDBClient()
})

func handler(w http.ResponseWriter, r *http.Request) {
    client := dbClient() // blocks all callers until first init completes
    client.Query(...)
}

Why not atomic.Pointer[DBClient] with a CAS init? That pattern requires double-checked locking to handle concurrent first-time callers without constructing twice. sync.OnceValue handles it correctly, including panic propagation.

Task 4 — Bounded job queue with N workers

Scenario: Queue accepts up to 100 pending jobs; 8 workers consume them. Producer should block if the queue is full.

Primitive: Buffered chan Job.

Implementation:

type Pool struct {
    jobs chan Job
    wg   sync.WaitGroup
}

func NewPool(workers, queueDepth int) *Pool {
    p := &Pool{jobs: make(chan Job, queueDepth)}
    p.wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer p.wg.Done()
            for j := range p.jobs {
                process(j)
            }
        }()
    }
    return p
}

func (p *Pool) Submit(j Job) { p.jobs <- j } // blocks if full
func (p *Pool) Shutdown()    { close(p.jobs); p.wg.Wait() }

Why not a []Job with a mutex and cond? The buffered channel encapsulates the bounded queue, producer blocking, consumer signaling, and shutdown semantics in 5 lines. The hand-rolled version is 30 lines and has at least one bug.

Task 5 — Read-mostly config snapshot

Scenario: A config struct is read on every request. A background goroutine reloads it from disk every 30 seconds.

Primitive: atomic.Pointer[Config].

Implementation:

type Config struct {
    Timeout time.Duration
    MaxConn int
}

var current atomic.Pointer[Config]

func Get() *Config { return current.Load() }

func reloadLoop(ctx context.Context) {
    t := time.NewTicker(30 * time.Second)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-t.C:
            c, err := loadFromDisk()
            if err != nil {
                log.Printf("reload failed: %v", err)
                continue
            }
            current.Store(c)
        }
    }
}

func init() {
    c, _ := loadFromDisk()
    current.Store(c)
}

Why not sync.RWMutex? Atomic load is one instruction; RWMutex.RLock involves a CAS on a shared cache line. At 100K+ reads/sec the difference shows.

Task 6 — Wait for first successful RPC out of three

Scenario: Issue an RPC to three replicas in parallel; return as soon as one succeeds; cancel the others.

Primitive: Goroutines + buffered result channel + context.WithCancel.

Implementation:

func firstSuccess(ctx context.Context, replicas []string) ([]byte, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    type result struct {
        data []byte
        err  error
    }
    results := make(chan result, len(replicas))
    for _, r := range replicas {
        r := r
        go func() {
            d, e := rpc(ctx, r)
            select {
            case results <- result{d, e}:
            case <-ctx.Done():
            }
        }()
    }

    var lastErr error
    for i := 0; i < len(replicas); i++ {
        r := <-results
        if r.err == nil {
            return r.data, nil
        }
        lastErr = r.err
    }
    return nil, lastErr
}

Why a buffered channel? Capacity equal to the number of replicas guarantees late senders never block, so they can exit cleanly even after the receiver has returned. With an unbuffered channel and a cancel() before the senders complete, the senders would leak unless they also selected on ctx.Done().

Task 7 — Per-key serialization

Scenario: Many goroutines update records keyed by string ID. Updates to the same key must serialize; updates to different keys must parallelize.

Primitive: sync.Map[string, *sync.Mutex] (or a sharded map[string]*sync.Mutex protected by a meta-mutex).

Implementation:

type KeyLocker struct {
    locks sync.Map // key string -> *sync.Mutex
}

func (k *KeyLocker) Lock(key string) func() {
    m, _ := k.locks.LoadOrStore(key, &sync.Mutex{})
    mu := m.(*sync.Mutex)
    mu.Lock()
    return mu.Unlock
}

// Usage:
unlock := keyLocker.Lock(recordID)
defer unlock()
mutateRecord(recordID)

Why sync.Map? Per-key access is exactly the "disjoint key sets" use case the godoc lists. Different goroutines mostly touch different keys, so contention on a single global lock would be the bottleneck.

Gotcha: This map grows monotonically. For unbounded key spaces, add a periodic sweep that deletes locks not currently held — but that itself requires care; consult golang.org/x/sync/singleflight for a related pattern that handles cleanup.

Task 8 — Producer-consumer with priority

Scenario: Producers enqueue tasks with priorities; consumers must dequeue highest-priority first.

Primitive: container/heap.Interface + sync.Mutex + sync.Cond.

Implementation:

type PriorityQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    heap  taskHeap // implements heap.Interface
}

func New() *PriorityQueue {
    pq := &PriorityQueue{}
    pq.cond = sync.NewCond(&pq.mu)
    return pq
}

func (pq *PriorityQueue) Push(t Task) {
    pq.mu.Lock()
    heap.Push(&pq.heap, t)
    pq.mu.Unlock()
    pq.cond.Signal()
}

func (pq *PriorityQueue) Pop() Task {
    pq.mu.Lock()
    defer pq.mu.Unlock()
    for pq.heap.Len() == 0 {
        pq.cond.Wait()
    }
    return heap.Pop(&pq.heap).(Task)
}

Why Cond and not channel? Channels are FIFO; they cannot reorder by priority. The heap reorders. The Cond is the right primitive to wake a consumer when the heap goes from empty to non-empty.

Task 9 — Broadcast a value to all current and future subscribers

Scenario: A weather service publishes the current temperature. Subscribers join at any time and should immediately see the latest value; thereafter they should receive every update.

Primitive: atomic.Pointer[Snapshot] for the latest value + per-subscriber chan Snapshot for the stream.

Implementation:

type Broker struct {
    latest atomic.Pointer[Temp]

    mu   sync.Mutex
    subs []chan Temp
}

func (b *Broker) Latest() *Temp { return b.latest.Load() }

func (b *Broker) Subscribe() <-chan Temp {
    ch := make(chan Temp, 8)
    b.mu.Lock()
    b.subs = append(b.subs, ch)
    b.mu.Unlock()
    if t := b.latest.Load(); t != nil {
        ch <- *t // deliver current snapshot immediately
    }
    return ch
}

func (b *Broker) Publish(t Temp) {
    b.latest.Store(&t)
    b.mu.Lock()
    defer b.mu.Unlock()
    for _, s := range b.subs {
        select {
        case s <- t:
        default: // drop if subscriber is slow
        }
    }
}

Why not a single shared channel? A send delivers to one receiver, not all. Broadcasting to N requires either N channels or sync.Cond (and Cond does not carry data).

Task 10 — Graceful shutdown of a worker pool

Scenario: Workers process jobs from a channel. On shutdown, stop accepting new jobs, drain the queue, then exit.

Primitive: Close the jobs channel + sync.WaitGroup for worker completion.

Implementation:

type Pool struct {
    jobs chan Job
    wg   sync.WaitGroup
}

func (p *Pool) Start(n int) {
    p.wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer p.wg.Done()
            for j := range p.jobs { // exits when channel closed and drained
                process(j)
            }
        }()
    }
}

func (p *Pool) Submit(j Job) error {
    select {
    case p.jobs <- j:
        return nil
    default:
        return errors.New("queue full")
    }
}

func (p *Pool) Shutdown() {
    close(p.jobs) // no more sends allowed; workers drain remaining
    p.wg.Wait()   // wait for all workers to finish
}

Why this combo? Channel close gives "no more work" broadcast; range naturally drains and exits. WaitGroup tells the orchestrator the pool is fully quiesced. No sync.Cond, no context.Done needed for this specific shape — though both would also work if the requirements were different (e.g., abort in-flight work instead of draining).

How to use this page

Pick a task at random. Write the implementation from a blank page. Run the tests below (you can sketch them yourself — they are short). Then compare with the reference implementation. Note where you reached for a heavier primitive than necessary; the next time the same scenario appears in production code, you will catch it in your own review.