← Interview Prep

Go Drills — Concurrency (Advanced)

The harder concurrency drills: a bounded connection pool, single-flight deduplication, and a streaming fan-out stage. Patterns you get asked to build live, on a whiteboard or in an editor.

These sit a level above the introductory channel/worker-pool drills. Each one hinges on an invariant that must hold under arbitrary interleavings — "no more than N in flight", "fn runs exactly once", "no goroutine leaks on cancel". The tests deliberately hammer them with many goroutines, so the only way to trust your answer is to run it under the race detector:

go test -race ./... — the race detector is not optional here. A pool that hands out one too many connections, a single-flight that calls fn twice, or a stream stage that leaks a goroutine on cancel will often pass a naive test and only surface under -race and load. Design for the invariant, then let -race try to break it.

Bounded connection pool (ctx-aware Acquire)

Problem. Build a bounded connection pool — like a pool to a database or a device: at most size live connections. Connections are created lazily via a factory. When the pool is exhausted, Acquire blocks until someone calls Release, or until the context expires.

NewPool(size int, factory func() (any, error)) *Pool
(*Pool) Acquire(ctx) (any, error)  // idle conn | new (if < size) | wait
(*Pool) Release(c any)             // return a connection to the pool
(*Pool) Close()                    // afterwards Acquire returns ErrClosed

Invariants the tests check (run go test -race):

Approach

The clean pattern uses two buffered channels of capacity size, and lets the channels themselves be the accounting — no manual counter to get wrong:

Acquire is a three-tier select, in priority order:

  1. Fast path — non-blocking receive from idle. If a connection is sitting ready, take it.
  2. Grow — non-blocking send into tokens. If it succeeds, we have reserved one of the size creation slots, so call factory(). On error we roll the token back (<-p.tokens) so a failed creation does not permanently consume a slot.
  3. Wait — the pool is full and we may not create more, so block on idle or ctx.Done(), whichever fires first.

Why it is correct. The invariant "at most size live" holds because a connection only comes into existence after a successful tokens <- struct{}{}, and tokens has capacity size; failed creations return their token. "At most size checked out" holds because idle also has capacity size and every live connection is either checked out or sitting in idle — so Release's send into idle can never block. The context is honoured because tier 3 is the only blocking wait, and it selects on ctx.Done() alongside idle.

Solution (Go)

// Дрилл 10 (решение). Bounded connection pool.
package d10pool

import (
	"context"
	"errors"
	"sync"
)

var ErrClosed = errors.New("pool is closed")

type Pool struct {
	idle    chan any      // готовые к выдаче соединения
	tokens  chan struct{} // разрешения на создание (ограничивают общее число)
	factory func() (any, error)

	mu     sync.Mutex
	closed bool
}

func NewPool(size int, factory func() (any, error)) *Pool {
	if size < 1 {
		size = 1
	}
	return &Pool{
		idle:    make(chan any, size),
		tokens:  make(chan struct{}, size),
		factory: factory,
	}
}

func (p *Pool) isClosed() bool {
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.closed
}

func (p *Pool) Acquire(ctx context.Context) (any, error) {
	if p.isClosed() {
		return nil, ErrClosed
	}

	// 1) быстрый путь: готовое соединение
	select {
	case c := <-p.idle:
		return c, nil
	default:
	}

	// 2) есть ли право создать новое (пул ещё не заполнен)?
	select {
	case p.tokens <- struct{}{}:
		c, err := p.factory()
		if err != nil {
			<-p.tokens // откатить резерв при ошибке
			return nil, err
		}
		return c, nil
	default:
	}

	// 3) пул полон: ждём освобождения ИЛИ отмены контекста
	select {
	case c := <-p.idle:
		return c, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

func (p *Pool) Release(c any) {
	// idle буферизован на size, а живых соединений не больше size,
	// поэтому запись никогда не блокирует.
	p.idle <- c
}

func (p *Pool) Close() {
	p.mu.Lock()
	p.closed = true
	p.mu.Unlock()
}

Pitfalls

Single-flight: deduplicating concurrent identical requests

Problem. A Group deduplicates concurrent calls that share the same key: while one call to fn is in progress, other callers with the same key do not launch fn — they wait and receive the same result. This is the classic defence against a thundering herd (e.g. a cache miss should send exactly one request to the database, not thousands).

(*Group[K,V]) Do(key K, fn func() (V, error)) (V, error, bool)
                                                        ^ shared:
  true if the result was shared with other callers (there were duplicates).

After fn finishes the key is removed, so the next Do(key) calls fn again.

Invariants (tests + go test -race):

Approach

State is a map[K]*call[V] guarded by a sync.Mutex, where each in-flight call holds a sync.WaitGroup plus the eventual val/err. The WaitGroup is a one-shot broadcast latch — the leader does Add(1) up front and Done() when the result is ready; followers do Wait().

The critical-section discipline is what makes it correct and concurrent:

Why it is correct. "Exactly once" follows from the map + mutex: the map lookup and the insert happen atomically in one critical section, so only the first goroutine to reach it finds the key absent and becomes the leader; every later goroutine finds the key present and becomes a follower. The WaitGroup gives a happens-before edge — the leader's writes to val/err precede Done(), which precedes every follower's Wait() return — so followers read the result without a data race and without their own lock.

The load-bearing detail: fn must run OUTSIDE the mutex. If you called fn while holding g.mu, you would serialise every key through a single lock — a slow fn for key A would block unrelated key B, destroying the "different keys run independently" invariant and turning your deduplicator into a global bottleneck. The mutex protects only the map; the work runs unlocked.

Solution (Go)

// Дрилл 11 (решение). Single-flight.
package d11flight

import "sync"

type call[V any] struct {
	wg   sync.WaitGroup
	val  V
	err  error
	dups int
}

type Group[K comparable, V any] struct {
	mu sync.Mutex
	m  map[K]*call[V]
}

func (g *Group[K, V]) Do(key K, fn func() (V, error)) (V, error, bool) {
	g.mu.Lock()
	if g.m == nil {
		g.m = make(map[K]*call[V])
	}
	if c, ok := g.m[key]; ok {
		// вызов уже в работе -- ждём его результат
		c.dups++
		g.mu.Unlock()
		c.wg.Wait()
		return c.val, c.err, true
	}

	c := new(call[V])
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

	// fn вне мьютекса, иначе сериализуем все ключи
	c.val, c.err = fn()
	c.wg.Done()

	g.mu.Lock()
	delete(g.m, key)
	shared := c.dups > 0
	g.mu.Unlock()

	return c.val, c.err, shared
}

Pitfalls

Streaming bounded fan-out with backpressure and cancellation

Problem. Build StreamMap[T, R](ctx, in, workers, fn) -> <-chan Result[R]: process a stream from the input channel in concurrently, subject to four constraints:

Unlike the finite-slice fan-out drill (a known-length slice mapped to a result map), this is a stream — a channel of unknown/unbounded length turned into a stream of results, with ctx and backpressure. It is one stage of a pipeline.

Approach

Spin up exactly workers goroutines, all reading from the same in. Because there are exactly workers readers and each holds at most one item while it computes fn, the count of readers is the in-flight cap — no semaphore needed. Every worker loops on a two-way select:

Emitting the result is itself a select on out <- res or <-ctx.Done(), so a slow or vanished consumer cannot pin a worker forever.

Backpressure via an unbuffered out. Because out is unbuffered, a send blocks until the consumer actually receives. A blocked worker is not looping back to read in, so a slow consumer transitively throttles input consumption — memory stays bounded by workers, not by the size of the stream.

Closing out exactly once. The workers are the senders, so none of them may close out (a send on a closed channel panics; multiple closers panic). Instead a single closer goroutine does wg.Wait() then close(out) — it runs only after every worker has returned (either in drained or ctx cancelled), which is the precise moment the invariant "in empty AND all workers stopped" is satisfied.

Why there is no leak. Every blocking point — the receive from in and the send to out — is guarded by ctx.Done(). On cancel, each worker unblocks and returns, wg falls to zero, the closer closes out, and the whole stage tears down without a parked goroutine.

Solution (Go)

// Дрилл 12 (решение). Потоковый bounded fan-out с backpressure и отменой.
package d12stream

import (
	"context"
	"sync"
)

type Result[R any] struct {
	Val R
	Err error
}

func StreamMap[T any, R any](
	ctx context.Context,
	in <-chan T,
	workers int,
	fn func(context.Context, T) (R, error),
) <-chan Result[R] {
	if workers < 1 {
		workers = 1
	}
	out := make(chan Result[R]) // небуферизованный -> backpressure от получателя

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					return
				case t, ok := <-in:
					if !ok {
						return // in закрыт -> поток кончился
					}
					v, err := fn(ctx, t)
					select {
					case out <- Result[R]{Val: v, Err: err}:
					case <-ctx.Done():
						return
					}
				}
			}
		}()
	}

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

	return out
}

Pitfalls

Run every drill with go test -race ./.... Related: the introductory concurrency drills cover worker pools, channels, and the finite-slice fan-out these problems build on.