🧩 Go Concurrency Coding Challenges¶
Seven interview-style problems with complete, tested solutions. Each is the kind of 30–45 minute coding round a staff Go interview uses. Every solution passes
go vetandgo test -race -count=3(Go 1.26,golang.org/x/syncv0.23).Read Concurrency Notes first for the primitives.
How to run these in an interview¶
- Clarify ordering, error handling, cancellation, and expected scale before typing.
- State the concurrency contract: who owns each goroutine, who closes each channel, how it stops.
- Write the simplest correct version, then say what you'd harden.
- Name the failure modes (leaks, races, send-on-closed, stampedes, unbounded memory) before the interviewer asks.
All seven solutions are in one package (package chal); the imports shown at the top of each block are the only ones it needs.
Challenge 1: Bounded parallel map (errgroup)¶
Problem: Fan out N calls with at most K in flight, keep result order, cancel on first error.
// c1_parallel.go
package chal
import (
"context"
"golang.org/x/sync/errgroup"
)
// ParallelMap applies fn to every input with at most `limit` goroutines in
// flight. Results keep input order; the first error cancels the rest.
func ParallelMap[In, Out any](ctx context.Context, in []In, limit int, fn func(context.Context, In) (Out, error)) ([]Out, error) {
out := make([]Out, len(in)) // each goroutine writes its own index: no lock needed
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
stopped := false
for i, v := range in {
if gctx.Err() != nil { // first error or caller cancel: don't start more work
stopped = true
break
}
g.Go(func() error { // Go 1.22+: i and v are per-iteration
r, err := fn(gctx, v)
if err != nil {
return err
}
out[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
if stopped { // cancelled by the caller with no fn error: don't return a partial result as success
return nil, context.Cause(ctx)
}
return out, nil
}
What to say:
- Why
out[i]needs no mutex: every goroutine writes a distinct index, andg.Wait()establishes happens-before for the reader. SetLimitblocksg.Gowhen K goroutines are running, so the loop itself provides backpressure. No semaphore channel needed.- Follow-up: "Return all errors, not just the first?" Collect with
errors.Joinunder a mutex and don't rely on the derived ctx for cancellation. - Follow-up: "Unbounded input?" Stream from a channel rather than a slice so memory stays O(K).
Challenge 2: Token-bucket rate limiter¶
Problem: Allow rate events/s with bursts, thread-safe, testable without sleeping.
// c2_ratelimit.go
package chal
import (
"sync"
"time"
)
// TokenBucket allows `rate` events per second with bursts up to `burst`.
// Tokens are refilled lazily on each call, so there is no background goroutine
// to leak or stop. The clock is injectable so tests need no sleeping.
type TokenBucket struct {
mu sync.Mutex
rate float64 // tokens per second
burst float64
tokens float64
last time.Time
now func() time.Time
}
func NewTokenBucket(rate float64, burst int, now func() time.Time) *TokenBucket {
if now == nil {
now = time.Now
}
return &TokenBucket{rate: rate, burst: float64(burst), tokens: float64(burst), last: now(), now: now}
}
func (b *TokenBucket) Allow() bool { return b.AllowN(1) }
func (b *TokenBucket) AllowN(n int) bool {
b.mu.Lock()
defer b.mu.Unlock()
t := b.now()
b.tokens = min(b.burst, b.tokens+t.Sub(b.last).Seconds()*b.rate)
b.last = t
if b.tokens < float64(n) {
return false
}
b.tokens -= float64(n)
return true
}
What to say:
- Lazy refill (compute tokens from elapsed time) means no ticker goroutine to leak or stop.
- Injectable clock makes the test deterministic. Interviewers like hearing this unprompted.
- Production note: use
golang.org/x/time/rate(it also hasWait(ctx)and reservations). Per-key limits need a map with eviction (LRU or TTL) or you leak memory under key churn. - Distributed version: a Redis Lua script holding
(tokens, last)per key, or a sliding-window counter. A local limiter is per-instance, so the real limit israte × instances.
Lazy refill: tokens are recomputed from elapsed time on each call, so no ticker goroutine is needed.
flowchart TD
A["Allow()"] --> L["Lock"]
L --> E["elapsed = now - last"]
E --> T["tokens = min(burst, tokens + elapsed * rate)"]
T --> C{"tokens >= 1?"}
C -->|yes| Y["tokens -= 1, return true"]
C -->|no| N["return false"]
Challenge 3: Cache with stampede protection (singleflight)¶
Problem: Many goroutines miss on the same key at once: hit the backend once.
// c3_cache.go
package chal
import (
"context"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
type entry[V any] struct {
val V
expires time.Time
}
// LoadingCache returns cached values, and on a miss runs at most one concurrent
// load per key no matter how many goroutines ask at once (cache-stampede protection).
type LoadingCache[V any] struct {
mu sync.RWMutex
items map[string]entry[V]
ttl time.Duration
load func(ctx context.Context, key string) (V, error)
group singleflight.Group
now func() time.Time
}
func NewLoadingCache[V any](ttl time.Duration, load func(context.Context, string) (V, error)) *LoadingCache[V] {
return &LoadingCache[V]{items: map[string]entry[V]{}, ttl: ttl, load: load, now: time.Now}
}
func (c *LoadingCache[V]) Get(ctx context.Context, key string) (V, error) {
c.mu.RLock()
e, ok := c.items[key]
c.mu.RUnlock()
if ok && c.now().Before(e.expires) {
return e.val, nil
}
// All concurrent callers for this key share one execution of the closure.
// The shared load must not die because the *first* caller's ctx was cancelled,
// so it runs on a detached context with its own timeout.
v, err, _ := c.group.Do(key, func() (any, error) {
// Re-check: a flight that finished after our miss may have filled the cache,
// and we would otherwise start a second load for the same key.
c.mu.RLock()
if e, ok := c.items[key]; ok && c.now().Before(e.expires) {
c.mu.RUnlock()
return e.val, nil
}
c.mu.RUnlock()
lctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
val, err := c.load(lctx, key)
if err != nil {
return nil, err // errors are not cached
}
c.mu.Lock()
c.items[key] = entry[V]{val: val, expires: c.now().Add(c.ttl)}
c.mu.Unlock()
return val, nil
})
if err != nil {
var zero V
return zero, err
}
return v.(V), nil
}
What to say:
singleflight.Group.Docollapses concurrent calls per key into one execution; everyone gets the same result.- The subtle bug: if the shared load used the first caller's ctx, that caller cancelling would fail every waiter.
context.WithoutCancelplus its own timeout fixes it. - Errors are not cached here. For a failing backend, add negative caching with a short TTL or you will hammer it on every request.
- Missing on purpose (say it out loud): no max size or eviction, no stale-while-revalidate, no jittered TTLs (synchronized expiry causes the next stampede).
singleflight collapses concurrent misses on one key into a single backend load, and every waiter gets the same result.
sequenceDiagram
participant G1 as Caller 1
participant G2 as Caller 2
participant G3 as Caller 3
participant SF as singleflight.Group
participant DB as Backend
G1->>SF: Do(key)
SF->>DB: load (first caller leads)
G2->>SF: Do(key)
Note over SF: load in flight, caller 2 waits
G3->>SF: Do(key)
Note over SF: caller 3 waits
DB-->>SF: value
SF-->>G1: value
SF-->>G2: same value
SF-->>G3: same value
Challenge 4: Worker pool with backpressure and graceful shutdown¶
Problem: Fixed workers, bounded queue, Submit respects ctx, Shutdown drains.
// c4_pool.go
package chal
import (
"context"
"sync"
)
// Pool runs submitted jobs on a fixed number of workers. Submit applies
// backpressure (blocks when the queue is full) and respects ctx; Shutdown
// stops intake, drains queued work, and waits for the workers.
type Pool struct {
jobs chan func()
wg sync.WaitGroup
mu sync.RWMutex // guards closed so Submit never sends on a closed channel
done bool
}
func NewPool(workers, queue int) *Pool {
p := &Pool{jobs: make(chan func(), queue)}
for range workers {
p.wg.Go(func() {
for job := range p.jobs {
job()
}
})
}
return p
}
type ErrPoolClosed struct{}
func (ErrPoolClosed) Error() string { return "pool closed" }
func (p *Pool) Submit(ctx context.Context, job func()) error {
p.mu.RLock()
defer p.mu.RUnlock()
if p.done {
return ErrPoolClosed{}
}
select {
case p.jobs <- job:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (p *Pool) Shutdown() {
p.mu.Lock()
if !p.done {
p.done = true
close(p.jobs)
}
p.mu.Unlock()
p.wg.Wait()
}
What to say:
- The classic crash:
send on closed channel. TheRWMutexmakesSubmitandShutdownmutually exclusive so a send can never race a close. - Backpressure over unbounded queues: a full queue blocks (or fails) the producer instead of eating memory.
wg.Go(Go 1.25) replacesAdd(1)/go/defer Done().- Trade-off:
Submitholds a read lock while blocked, and a concurrentShutdownwaits for it. If that matters, returnErrQueueFullusingselectwithdefault.
Bounded queue gives backpressure on Submit; Shutdown stops intake under the lock, closes the queue, and waits for workers to drain it.
flowchart LR
P["Producers: Submit(ctx, job)"] --> M{"RWMutex read lock: pool open?"}
M -->|closed| Er["Return error"]
M -->|open| Q["Bounded jobs channel"]
Q -->|"full: blocks or ctx.Done"| P
Q --> W1["Worker 1"]
Q --> W2["Worker 2"]
Q --> W3["Worker N"]
SD["Shutdown"] -->|"write lock, mark closed, close(jobs)"| Q
W1 --> WG["wg.Wait returns after queue drained"]
W2 --> WG
W3 --> WG
Challenge 5: In-process pub/sub with a slow-consumer policy¶
Problem: Topics, bounded subscriber buffers, safe unsubscribe and close.
// c5_pubsub.go
package chal
import "sync"
// Broker fans messages out to subscribers. Each subscriber has a bounded
// buffer; if it is full the message is DROPPED for that subscriber, so one slow
// consumer can never stall the publisher or the other subscribers.
type Broker[T any] struct {
mu sync.RWMutex
subs map[string]map[*Sub[T]]struct{}
closed bool
}
type Sub[T any] struct {
C <-chan T
ch chan T
topic string
b *Broker[T]
dropped int // guarded by b.mu
}
func NewBroker[T any]() *Broker[T] { return &Broker[T]{subs: map[string]map[*Sub[T]]struct{}{}} }
func (b *Broker[T]) Subscribe(topic string, buf int) *Sub[T] {
ch := make(chan T, buf)
s := &Sub[T]{C: ch, ch: ch, topic: topic, b: b}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
close(ch)
return s
}
if b.subs[topic] == nil {
b.subs[topic] = map[*Sub[T]]struct{}{}
}
b.subs[topic][s] = struct{}{}
return s
}
// Publish returns how many subscribers received the message.
func (b *Broker[T]) Publish(topic string, msg T) int {
b.mu.Lock() // write lock: we mutate s.dropped; sends below never block
defer b.mu.Unlock()
n := 0
for s := range b.subs[topic] {
select {
case s.ch <- msg:
n++
default:
s.dropped++
}
}
return n
}
func (s *Sub[T]) Dropped() int {
s.b.mu.RLock()
defer s.b.mu.RUnlock()
return s.dropped
}
// Unsubscribe is safe to call more than once. Channels are closed only while
// holding the lock that Publish takes, so no send can race with the close.
func (s *Sub[T]) Unsubscribe() {
s.b.mu.Lock()
defer s.b.mu.Unlock()
if set, ok := s.b.subs[s.topic]; ok {
if _, ok := set[s]; ok {
delete(set, s)
if len(set) == 0 {
delete(s.b.subs, s.topic) // don't leak an empty map per churned topic
}
close(s.ch)
}
}
}
func (b *Broker[T]) Close() {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
b.closed = true
for _, set := range b.subs {
for s := range set {
close(s.ch)
}
}
b.subs = map[string]map[*Sub[T]]struct{}{}
}
What to say:
- The design decision is the slow-consumer policy: drop (here), block (stalls everyone), or disconnect the subscriber. State the choice and why.
- Close channels only while holding the lock
Publishtakes, otherwiseUnsubscriberaces a send and panics. UnsubscribeandCloseare idempotent. Double close panics, so make it impossible.- At-least-once delivery or replay? That is a different system (a log like Kafka). Mention offsets and consumer groups.
Challenge 6: Pipeline that never leaks goroutines¶
Problem: Generator → stage → fan-in, with early exit.
// c6_pipeline.go
package chal
import (
"context"
"sync"
)
// Gen emits nums until ctx is cancelled. Every stage selects on ctx.Done() when
// sending, which is what prevents leaked goroutines if the consumer stops early.
func Gen(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func Square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// Merge fans several channels into one; it closes `out` only after every input
// is drained, which is the WaitGroup-then-close idiom.
func Merge[T any](ctx context.Context, ins ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
for _, in := range ins {
wg.Go(func() {
for v := range in {
select {
case out <- v:
case <-ctx.Done():
return
}
}
})
}
go func() { wg.Wait(); close(out) }()
return out
}
What to say:
- Every blocking send is paired with
<-ctx.Done(). If the consumer stops reading, stages exit instead of blocking forever. defer close(out)in the producer; the receiver never closes.- Fan-in closes after
wg.Wait()in a separate goroutine. - Prove it: in tests use
testing/synctest(1.25) or compareruntime.NumGoroutine()before/after, and thegoroutineleakprofile (1.27) in production.
Pipeline ownership: each producer closes its own output, every send is paired with ctx.Done(), and fan-in closes only after all workers finish.
flowchart LR
Gen["Generator: defer close(out)"] --> S1["Stage workers"]
S1 --> S2["Stage workers"]
S2 --> FI["Fan-in: close after wg.Wait"]
FI --> Con["Consumer"]
Ctx["ctx.Done()"] -.->|"unblocks every send"| Gen
Ctx -.-> S1
Ctx -.-> S2
Challenge 7: Retry with exponential backoff and full jitter¶
Problem: Retry transient failures without causing a retry storm.
// c7_retry.go
package chal
import (
"context"
"math/rand/v2"
"time"
)
// Retry calls fn up to attempts times with exponential backoff and full jitter
// (sleep a random duration in [0, min(maxDelay, base*2^i))). Full jitter avoids
// synchronized retry storms. Permanent errors stop immediately.
func Retry(ctx context.Context, attempts int, base, maxDelay time.Duration, retryable func(error) bool, fn func(context.Context) error) error {
var err error
for i := 0; i < attempts; i++ {
if err = fn(ctx); err == nil || !retryable(err) {
return err
}
if i == attempts-1 {
break
}
ceiling := base // double per attempt, stopping at maxDelay (base<<i would overflow)
for j := 0; j < i && ceiling < maxDelay; j++ {
ceiling *= 2
}
ceiling = max(time.Nanosecond, min(ceiling, maxDelay)) // rand.N panics on <= 0
timer := time.NewTimer(rand.N(ceiling))
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return context.Cause(ctx)
}
}
return err
}
What to say:
- Full jitter (random in
[0, cap)) spreads clients out; plain exponential backoff keeps them synchronized. - Retry only idempotent operations, or send an idempotency key. Retrying a non-idempotent POST double-charges.
- Cap attempts and total time; respect
ctx. Use aTimerandStopit on the ctx path to free it promptly (needed before Go 1.23 to avoid a leak). - Retry budgets: at scale, cap retries to a fraction of traffic (e.g. ~10%), or a partial outage becomes a full one.
Exponential backoff with full jitter: the cap doubles each attempt, the actual sleep is random in [0, cap), and ctx or the attempt limit stops it.
flowchart TD
St["Attempt n"] --> Ok{"Success?"}
Ok -->|yes| Done["Return result"]
Ok -->|no| Tr{"Retryable and attempts left?"}
Tr -->|no| Fail["Return last error"]
Tr -->|yes| Cap["cap = min(max, base * 2^n)"]
Cap --> J["sleep random in [0, cap) using a Timer"]
J --> Cx{"ctx done?"}
Cx -->|yes| Fail
Cx -->|no| St
The test file¶
The race detector is the grader. Run it with go test -race -count=3 ./....
chal_test.go (all seven tests)
package chal
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestParallelMap(t *testing.T) {
var cur, peak atomic.Int32
in := make([]int, 50)
for i := range in {
in[i] = i
}
out, err := ParallelMap(context.Background(), in, 4, func(_ context.Context, v int) (int, error) {
n := cur.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
time.Sleep(time.Millisecond)
cur.Add(-1)
return v * 2, nil
})
if err != nil || out[49] != 98 || peak.Load() > 4 {
t.Fatal(err, out[49], peak.Load())
}
boom := errors.New("boom")
_, err = ParallelMap(context.Background(), in, 4, func(_ context.Context, v int) (int, error) {
if v == 7 {
return 0, boom
}
return v, nil
})
if !errors.Is(err, boom) {
t.Fatal(err)
}
}
func TestTokenBucket(t *testing.T) {
now := time.Unix(0, 0)
b := NewTokenBucket(10, 5, func() time.Time { return now })
for i := 0; i < 5; i++ {
if !b.Allow() {
t.Fatal("burst should pass", i)
}
}
if b.Allow() {
t.Fatal("should be empty")
}
now = now.Add(300 * time.Millisecond) // 3 tokens
if !b.AllowN(3) || b.Allow() {
t.Fatal("refill wrong")
}
now = now.Add(time.Hour)
if b.AllowN(6) || !b.AllowN(5) {
t.Fatal("cap at burst")
}
}
func TestLoadingCacheSingleflight(t *testing.T) {
var loads atomic.Int32
c := NewLoadingCache(time.Minute, func(_ context.Context, k string) (string, error) {
loads.Add(1)
time.Sleep(50 * time.Millisecond)
return "v-" + k, nil
})
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
v, err := c.Get(context.Background(), "a")
if err != nil || v != "v-a" {
t.Error(v, err)
}
})
}
wg.Wait()
if loads.Load() != 1 {
t.Fatal("loads =", loads.Load())
}
}
func TestPool(t *testing.T) {
p := NewPool(3, 2)
var n atomic.Int32
for range 20 {
if err := p.Submit(context.Background(), func() { time.Sleep(time.Millisecond); n.Add(1) }); err != nil {
t.Fatal(err)
}
}
p.Shutdown()
if n.Load() != 20 {
t.Fatal(n.Load())
}
if err := p.Submit(context.Background(), func() {}); !errors.As(err, &ErrPoolClosed{}) {
t.Fatal(err)
}
p.Shutdown() // idempotent
ctx, cancel := context.WithCancel(context.Background())
q := NewPool(1, 0)
block := make(chan struct{})
_ = q.Submit(ctx, func() { <-block })
cancel()
if err := q.Submit(ctx, func() {}); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
close(block)
q.Shutdown()
}
func TestBroker(t *testing.T) {
b := NewBroker[int]()
fast, slow := b.Subscribe("t", 10), b.Subscribe("t", 1)
for i := 0; i < 5; i++ {
b.Publish("t", i)
}
if len(fast.C) != 5 || slow.Dropped() != 4 {
t.Fatal(len(fast.C), slow.Dropped())
}
slow.Unsubscribe()
slow.Unsubscribe()
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
for i := 0; i < 200; i++ {
b.Publish("t", i)
}
})
}
wg.Go(func() { fast.Unsubscribe() })
wg.Wait()
b.Close()
b.Close()
}
func TestPipeline(t *testing.T) { // checks results and early stop; add synctest.Test for a real leak check
ctx, cancel := context.WithCancel(context.Background())
out := Merge(ctx, Square(ctx, Gen(ctx, 1, 2, 3)), Square(ctx, Gen(ctx, 4, 5, 6)))
sum := 0
for v := range out {
sum += v
}
if sum != 91 {
t.Fatal(sum)
}
cancel()
// early stop: read one value then cancel; stages must exit
ctx2, cancel2 := context.WithCancel(context.Background())
o2 := Square(ctx2, Gen(ctx2, 1, 2, 3, 4, 5))
<-o2
cancel2()
}
func TestRetry(t *testing.T) {
calls := 0
err := Retry(context.Background(), 5, time.Millisecond, 4*time.Millisecond, func(error) bool { return true },
func(context.Context) error {
calls++
if calls < 3 {
return errors.New("transient")
}
return nil
})
if err != nil || calls != 3 {
t.Fatal(err, calls)
}
perm := errors.New("perm")
calls = 0
err = Retry(context.Background(), 5, time.Millisecond, time.Millisecond, func(e error) bool { return e != perm },
func(context.Context) error { calls++; return perm })
if err != perm || calls != 1 {
t.Fatal(err, calls)
}
}
Other problems to practise¶
Same toolbox, different wrapper. Try each yourself before looking up solutions.
| Problem | Core idea |
|---|---|
| Thread-safe LRU cache | Mutex + container/list + map; then shard by key hash to cut contention |
| Concurrent web crawler with dedupe | Visited set under a mutex, bounded workers, termination detection via a pending counter |
Semaphore with Acquire(ctx) |
Buffered channel of tokens, or x/sync/semaphore for weighted |
| Debounce / throttle | time.Timer reset in a single owner goroutine |
| Ordered output from unordered workers | Index results, or a reorder buffer keyed by sequence number |
| Barrier / "N goroutines wait for each other" | sync.Cond broadcast, or channel close as a one-shot signal |
| Dining philosophers / deadlock avoidance | Global lock ordering, or a "waiter" semaphore of N-1 |
| Rate-limited fan-out HTTP client | errgroup.SetLimit + rate.Limiter.Wait(ctx) |
Common review comments (what interviewers flag)¶
go func() { ... }()with no way to stop it or learn its error.time.Afterin a loop on Go < 1.23 (timers live until they fire). On 1.23+ it is collectable, but a reusabletime.TimerwithResetstill avoids a per-iteration allocation.- Closing a channel from the receiver, or from more than one sender.
- Holding a mutex across a channel operation, a network call, or a callback.
- Passing
context.Contextin a struct field instead of the first parameter. - Unbounded
goper item. Bound it, even if "it's only a few hundred." - Sharing a
*rand.Randor map across goroutines without synchronization.