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 apanic: send on closed channel; forgetting to close leaves receivers blocked forever onrange. When many goroutines feed one channel, the pattern is always the same:wg.Wait()in a dedicated goroutine, thenclose(out).
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.
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.
// Дрилл 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...)
}
map/variable from goroutines without a mutex → a data race.
Collect results through a channel, or guard with a sync.Mutex.wg.Wait()) → panic: send on
closed channel or lost data. The sender always closes.close(jobs) → workers hang on range jobs and
wg.Wait() never returns.wg.Add inside the goroutine instead of before launching it → a race on the
WaitGroup counter.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.
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.
// Дрилл 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
}
wg.Wait()) → panic: send on
closed channel or lost data. With many senders on out, only a dedicated
wg.Wait() goroutine may close it — the sender closes.out, or forgetting to close it at all →
the consumer's range out hangs forever.wg.Add inside the forwarder goroutine instead of before launching it → a
race on the counter and a possible premature close.out: switching to a buffered channel doesn't fix a wrong close —
it just hides the deadlock until the buffer fills.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.
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.
// Дрилл 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
}
Get needs at least an RLock().Snapshot instead of a copy under the lock → the
caller reads/writes it after the lock is released, racing with writers.nil map (forgetting the make in the constructor) → a
panic.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.
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.
// Дрилл 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
}
cancel() (defer cancel()) → a
goroutine leak of tasks waiting on a ctx.Done() that never fires.firstErr before wg.Wait(), or writing it without
sync.Once/a mutex → a data race when several tasks fail at once.wg.Add inside the goroutine instead of before launching it → a race on the
counter and a wg.Wait() that returns too early.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).
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.
// Дрилл 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()
}
}
n. An unbuffered
channel makes every Acquire() block on a matching Release() — instant
deadlock.Release() (no defer sem.Release()) → slots
leak and later callers block forever.AcquireCtx without a cancel() in the caller →
a leaked timer/goroutine, same as the errgroup drill.go test -race in silence.