← Interview Prep

Go Drills — Concurrency

Five short concurrency drills for a live-coding interview: worker pool, fan-in merge, a thread-safe registry, cancel-on-first-error, and a channel semaphore. All from the network-automation domain, stdlib only.

These are not toy puzzles — every one is designed so that a wrong answer looks correct until the race detector speaks. So the acceptance bar is simple: each solution must pass go test -race with the detector completely silent. Half the point of these drills is to make a data race impossible by construction, not to patch one after the fact.

The golden rule: the sender closes the channel. A receiver never closes; closing signals "no more values will be sent," which only the sending side can promise. Closing too early gives you a panic: send on closed channel; forgetting to close leaves receivers blocked forever on range. When many goroutines feed one channel, the pattern is always the same: wg.Wait() in a dedicated goroutine, then close(out).

1. Worker pool: fan-out / fan-in

Problem statement. Implement

PollAll(devices, workers, poll) (map[string]int, error)

Poll every device concurrently, but with no more than workers goroutines running at once. Collect the results into a map[device]value. Don't lose errors: return them aggregated with errors.Join, and put only the successful ones into the map. If workers < 1, treat it as 1. Empty input → an empty map and nil error. The patterns drilled here: a jobs channel plus a pool of workers (fan-out); collecting results through a channel (fan-in) so there is no race on the map; sync.WaitGroup with correct channel closing; and error aggregation via errors.Join / %w. Running go test -race is mandatory: if you wrote into a shared map from the workers without protection, the detector would catch it — here only the collector writes the map.

Approach

Classic fan-out / fan-in. A single unbuffered jobs channel is fed by one dispatcher goroutine, which closes jobs once every device is queued. workers worker goroutines range over jobs (fan-out) and push a result struct onto a shared out channel (fan-in). Why it's race-free: no shared mutable state is touched concurrently. Each worker only sends on channels; the single main goroutine is the only writer of the result map, reading one value at a time off out. Closing is textbook: the dispatcher closes jobs (it's the sole sender), which lets workers exit their range and call wg.Done(); a separate goroutine blocks on wg.Wait() and then closes out, which terminates the collector's range out. Nothing writes the map but the collector, so the detector stays silent.

Solution (Go)

// Дрилл 5 (решение). Worker pool: fan-out / fan-in.
package d5pool

import (
	"errors"
	"fmt"
	"sync"
)

func PollAll(devices []string, workers int, poll func(string) (int, error)) (map[string]int, error) {
	if workers < 1 {
		workers = 1
	}
	type result struct {
		dev string
		val int
		err error
	}

	jobs := make(chan string)
	out := make(chan result)

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for d := range jobs {
				v, err := poll(d)
				out <- result{dev: d, val: v, err: err}
			}
		}()
	}

	// раздатчик заданий
	go func() {
		for _, d := range devices {
			jobs <- d
		}
		close(jobs)
	}()

	// закрыть out, когда все воркеры закончат
	go func() {
		wg.Wait()
		close(out)
	}()

	results := make(map[string]int)
	var errs []error
	for r := range out {
		if r.err != nil {
			errs = append(errs, fmt.Errorf("%s: %w", r.dev, r.err))
			continue
		}
		results[r.dev] = r.val
	}
	return results, errors.Join(errs...)
}

Pitfalls

2. Fan-in: merge channels

Problem statement. Implement

Merge[T](chans ...<-chan T) <-chan T

Return a single channel that receives the values from all of the input channels. The output channel must be closed only after every input channel has been closed (otherwise the receiver either hangs or loses data). The ordering of values across channels is not guaranteed. What this drills: a per-channel forwarder goroutine, a sync.WaitGroup, and the main move — "close out after wg.Wait()." The classic mistake is closing out too early or forgetting the WaitGroup entirely.

Approach

One forwarder goroutine per input channel. Each forwarder ranges over its own input and copies each value into the single shared out channel; because a range loop ends exactly when that input closes, each forwarder naturally calls wg.Done() at the right moment. It's race-free for the same reason as the pool: goroutines communicate only by sending on a channel, and Go's channel send/receive is the synchronization point. The lifetime logic is the golden rule made concrete — out has many senders, so no single sender may close it; instead a dedicated goroutine waits for all senders to finish (wg.Wait()) and only then closes out. Close earlier and a still-running forwarder panics on send; never close and the consumer's range blocks forever.

Solution (Go)

// Дрилл 6 (решение). Fan-in: слияние каналов.
package d6merge

import "sync"

func Merge[T any](chans ...<-chan T) <-chan T {
	out := make(chan T)
	var wg sync.WaitGroup

	for _, c := range chans {
		wg.Add(1)
		go func(c <-chan T) {
			defer wg.Done()
			for v := range c {
				out <- v
			}
		}(c)
	}

	go func() {
		wg.Wait()
		close(out)
	}()

	return out
}

Pitfalls

3. Thread-safe registry

Problem statement. Registry is a concurrent map of counters (for example, an event counter keyed by device). All methods are safe to call from many goroutines:

NewRegistry() *Registry
(*Registry) Incr(key string)          // +1
(*Registry) Add(key string, n int)    // +n
(*Registry) Get(key string) int
(*Registry) Snapshot() map[string]int // an INDEPENDENT copy

Snapshot must return a copy: mutating it must not affect the registry, and vice versa. What this drills: sync.Mutex/RWMutex, the read/write distinction, and "copy under the lock." go test -race is mandatory — without a lock, a test with parallel Incr calls will trip the race detector.

Approach

A plain map guarded by a sync.RWMutex. Writers (Add, and Incr which just delegates to Add) take the exclusive Lock(); readers (Get, Snapshot) take the shared RLock(), so many reads proceed in parallel while any write is exclusive. That's what makes it race-free: every access to the map — read or write — happens while holding the lock, so no two goroutines touch the map's internal state concurrently. Snapshot is the subtle one: it allocates a fresh map and copies the entries while still holding the read lock, so the returned map shares no backing storage with the registry. Returning the internal map directly would leak an unsynchronized alias that callers could read/write after the lock is released — an instant race.

Solution (Go)

// Дрилл 7 (решение). Потокобезопасный реестр.
package d7registry

import "sync"

type Registry struct {
	mu     sync.RWMutex
	counts map[string]int
}

func NewRegistry() *Registry {
	return &Registry{counts: make(map[string]int)}
}

func (r *Registry) Incr(key string) {
	r.Add(key, 1)
}

func (r *Registry) Add(key string, n int) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.counts[key] += n
}

func (r *Registry) Get(key string) int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.counts[key]
}

func (r *Registry) Snapshot() map[string]int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	out := make(map[string]int, len(r.counts))
	for k, v := range r.counts {
		out[k] = v
	}
	return out
}

Pitfalls

4. Cancel on first error

Problem statement. Implement

RunAll(ctx, tasks) error

Run all tasks concurrently. As soon as any one returns an error, cancel the context passed to the remaining tasks and return the first error. If all succeed → nil. Wait for every goroutine to finish before returning (no leaks). This is a hand-rolled version of the semantics of golang.org/x/sync/errgroup. What it drills: context.WithCancel, sync.WaitGroup, and sync.Once for "remember the first error and cancel exactly once." go test -race applies.

Approach

Derive a cancellable context with context.WithCancel and always defer cancel() (so no goroutine is left waiting on a ctx.Done() that never fires). Launch one goroutine per task, each passed the derived ctx. The race-free part hinges on sync.Once: many tasks may fail near-simultaneously, but once.Do guarantees the closure that writes firstErr and calls cancel() runs for exactly one of them, and it establishes a happens-before edge, so writing firstErr inside it is safe. The main goroutine only reads firstErr after wg.Wait(), which happens-after every wg.Done() — so the read can't race the write. The cancel() broadcasts to every other task via its ctx.Done() so they can bail out cooperatively; wg.Wait() guarantees they've all returned before we do, so there are no leaked goroutines.

Solution (Go)

// Дрилл 8 (решение). Отмена по первой ошибке.
package d8errgroup

import (
	"context"
	"sync"
)

func RunAll(ctx context.Context, tasks []func(context.Context) error) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	var (
		wg       sync.WaitGroup
		once     sync.Once
		firstErr error
	)

	for _, task := range tasks {
		wg.Add(1)
		go func(t func(context.Context) error) {
			defer wg.Done()
			if err := t(ctx); err != nil {
				once.Do(func() {
					firstErr = err
					cancel() // отменяем остальных
				})
			}
		}(task)
	}

	wg.Wait()
	return firstErr
}

Pitfalls

5. Semaphore on a buffered channel

Problem statement. Semaphore limits the number of goroutines running at once to n, implemented on a buffered channel (the idiomatic Go approach):

NewSemaphore(n int) *Semaphore     // n < 1 -> panic or n=1 (your choice)
(*Semaphore) Acquire()             // blocks until a slot is free
(*Semaphore) Release()             // frees a slot
(*Semaphore) TryAcquire() bool     // non-blocking: true if acquired
(*Semaphore) AcquireCtx(ctx) error // waits for a slot OR ctx cancellation

What this drills: a buffered channel as a counting semaphore, select with a default branch (TryAcquire), and select with ctx.Done() (cancellable waiting).

Approach

A buffered channel slots of capacity n is the counting semaphore: sending an empty struct acquires a slot (it blocks once the buffer is full — i.e. when n holders are active), and receiving releases one. It's race-free because the entire state lives in the channel: channel send/receive is already synchronized by the runtime, so there is no shared counter or mutex to get wrong, and no data of our own to race on. TryAcquire uses select with a default so it never blocks — it grabs a slot if one is immediately available, otherwise returns false. AcquireCtx uses select between the slot send and <-ctx.Done(), so a caller waiting for capacity can be unblocked by context cancellation and get back ctx.Err() instead of hanging. struct{} is used because it occupies zero bytes — we only care about the count, not the payload.

Solution (Go)

// Дрилл 9 (решение). Семафор на буферизованном канале.
package d9sema

import "context"

type Semaphore struct {
	slots chan struct{}
}

func NewSemaphore(n int) *Semaphore {
	if n < 1 {
		n = 1
	}
	return &Semaphore{slots: make(chan struct{}, n)}
}

func (s *Semaphore) Acquire() {
	s.slots <- struct{}{}
}

func (s *Semaphore) Release() {
	<-s.slots
}

func (s *Semaphore) TryAcquire() bool {
	select {
	case s.slots <- struct{}{}:
		return true
	default:
		return false
	}
}

func (s *Semaphore) AcquireCtx(ctx context.Context) error {
	select {
	case s.slots <- struct{}{}:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

Pitfalls

Golden rule to repeat at the whiteboard: the sender closes the channel, and every one of these must survive go test -race in silence.