The Bounded Parallelism Question, Answered in Ten Minutes
The prompt is always some variant of: run these 10,000 jobs, but never more than 20 at a time, and tell me when all are done. Unbounded goroutines are the naive answer — spawn 10,000, watch memory and the downstream API collapse. The interviewer is watching three things: whether the limit is a real guarantee, who closes the channel, and whether cancellation propagates. Here is the solution that survives all three probes:
func runPool(ctx context.Context, jobs []Job, limit int, fn func(context.Context, Job) error) error {
jobCh := make(chan Job)
errCh := make(chan error, limit) // buffered: workers never block on send
var wg sync.WaitGroup
for i := 0; i < limit; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobCh {
if err := fn(ctx, j); err != nil {
select {
case errCh <- fmt.Errorf("job %d: %w", j.ID, err):
case <-ctx.Done():
}
return
}
}
}()
}
go func() {
defer close(jobCh)
for _, j := range jobs {
select {
case jobCh <- j:
case <-ctx.Done():
return // producer stops feeding; range loops drain and exit
}
}
}()
go func() { wg.Wait(); close(errCh) }()
for err := range errCh { // first error wins; keeps draining to unblock
return err
}
return ctx.Err()
}
Walk the design decisions in the order an interviewer will poke at them.
The limit is the number of consumer goroutines, not a semaphore. Exactly limit workers exist, each running fn one job at a time — so at most limit calls are ever in flight. The alternative (a buffered channel used as a semaphore) is also correct; being able to produce both on demand is a good look.
The producer closes jobCh. This is the question that separates fluent from correct: for j := range jobCh inside each worker blocks until the channel is closed, and only the goroutine that finishes sending knows when feeding is complete. Closing from a consumer (or from main before the producer finishes) panics with close of closed channel or cuts off work. State the rule outright: the sender closes; receivers only range.
Cancellation reaches both ends. The producer's select on ctx.Done() stops feeding; workers exit when the channel closes. The select in the error send matters too — with an unbuffered send and everyone else gone, the worker would block forever; the buffered errCh plus the select makes every send path bounded.
Cleanup runs even on the error path. wg.Wait() in its own goroutine closing errCh means for err := range errCh terminates when workers finish — no leaked waiter if all jobs succeed. Returning the first error while still draining the channel is deliberate: dropping the range early leaves the error senders blocked.
Expect the follow-ups. What if a job panics? — recover inside the worker (defer recover() per job) or one panic takes down the pool. What if jobs produce results, not errors? — a second out channel closed by a wg.Wait() goroutine, the fan-in shape from Fan-Out and Fan-In. How do you test the concurrency limit? — an atomic counter recording maximum simultaneous entries in fn, asserted ≤ limit, which also proves the claim empirically instead of rhetorically.
Pipelines That Respect Deadlines: Rate Limits and First Error
Two follow-ups travel with every pool question: how would you rate-limit it? and how would you fail fast? Both are pipeline problems, and both have answers a candidate can write from memory.
Rate limiting without a library is a ticker and a select — the same shape you would write in production before pulling in golang.org/x/time/rate:
func withRate(ctx context.Context, jobs []Job, every time.Duration, fn func(Job) error) error {
tick := time.NewTicker(every)
defer tick.Stop()
for _, j := range jobs {
select {
case <-ctx.Done():
return ctx.Err()
case <-tick.C:
if err := fn(j); err != nil {
return fmt.Errorf("job %d: %w", j.ID, err)
}
}
}
return nil
}
Every iteration waits for a tick before doing work, so throughput is one job per interval regardless of loop speed — a pacing guarantee, not an average. The ctx.Done() case is what makes it cancellable; without it, a shutdown blocks until the next tick. If the interviewer knows x/time/rate, the conversation improves: rate.NewLimiter(rate.Every(100*time.Millisecond), burst) adds burst capacity and Wait(ctx) instead of a hand-rolled ticker — knowing when to reach for the library versus when the ten-line version suffices is itself the answer.
Fail-fast fan-out is where errgroup earns its mention:
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, limit)
for _, shard := range shards {
shard := shard
g.Go(func() error {
sem <- struct{}{}
defer func() { <-sem }()
return process(ctx, shard)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("pipeline failed: %w", err)
}
errgroup.WithContext gives one shared, cancel-on-first-error context: the first g.Go function to return an error cancels it, every other worker's ctx.Done() fires, and g.Wait() returns that error to the caller. The per-g.Go closure (shard := shard) is the loop-variable capture detail that still costs candidates points on older Go semantics — worth stating even if the interviewer waves it off.
The structural questions that follow are predictable, and good answers pre-empt them. What happens to jobs already in flight when the first error lands? — they see cancellation and should check it; there is no preemption. Is partial work a problem? — if side effects matter, make each unit idempotent or record progress, because fail-fast leaves the world half-updated. What if errors should be collected instead of first-wins? — drop WithContext, keep a goroutine-safe slice with a mutex, and return errors.Join(errs...); fail-fast versus collect is a product decision, not a style one. Each of these answers shows the same trait the whole phase is training: knowing which guarantee — bounded concurrency, pacing, or fail-fast — the code actually provides, and saying it before being asked (errgroup covers the primitive in depth).
Find the Leak: The Debugging Question with No Multiple Choice
The interviewer pastes forty lines and asks what happens after the request finishes. The giveaway is a number: goroutines climbing from 12 to 400 over an afternoon, never falling. Three leak shapes account for most interview versions — and for most production ones.
Shape 1: a send nobody receives.
func fanIn(ctx context.Context, in ...<-chan int) <-chan int {
out := make(chan int)
for _, c := range in {
go func(c <-chan int) {
for v := range c {
out <- v // blocks forever once the consumer stops reading
}
}(c)
}
return out
}
The consumer finishes, the copiers remain parked on out <- v. Diagnosis from a goroutine?debug=2 dump: N identical stacks ending in chan send inside your function. Fix: make the send select on ctx.Done(), or close out (once, from a waiter that knows all inputs finished — the ownership rule from the pool chapter).
Shape 2: the missing defer cancel().
resp, err := fetchWithTimeout(url) // internally: ctx, cancel := context.WithTimeout(...)
With cancel never called, the timer lives until the deadline, and the context's internal resources are released only then. Under load, timers and their goroutines pile up for the full timeout duration. The rule interviewers want to hear: every context.WithCancel/WithTimeout call pairs with a deferred cancel in the same function, even when the derived context is only passed onward.
Shape 3: a blocked goroutine waiting on something that stops happening — a worker <-jobs after the producer closed its interest differently, a sync.WaitGroup.Wait() whose Done was skipped on an error branch (every Add needs a Done on every exit path, which is why defer wg.Done() is the habit).
How to demonstrate the diagnosis rather than just assert it:
func TestNoLeak(t *testing.T) {
before := runtime.NumGoroutine()
runPipeline(context.Background(), jobs)
done := make(chan struct{})
go func() {
for runtime.NumGoroutine() > before+2 { // allow scheduler noise
time.Sleep(10 * time.Millisecond)
}
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
t.Fatalf("goroutines leaked:\n%s", buf[:n])
}
}
runtime.Stack(buf, true) dumps every goroutine; the failure output shows which stack is stuck — the same artifact you would paste into a bug report. The conversation then turns to prevention: select on ctx.Done() around every potentially-blocked operation, close channels at their single owner, and wire cancellation through the whole tree — the production version of these habits lives in Goroutine Leaks, while the interview version is spotting the chan send frame in ten seconds.
Is This Code Safe? Explaining Races in Happens-Before Terms
A snippet, a counter, and the question every Go interview eventually asks: this code has a race — why, and how do you fix it? The fix is easy to name (mutex, atomic, channel). The part being graded is the why: an explanation in terms of ordering, not vibes.
var count int
func handler() {
go func() { count++ }()
count++
fmt.Println(count) // 1, 2, or 3 — all valid outputs
}
The Go memory model states that a read must observe the write it happens-before, and unordered accesses racing on one variable are undefined — not "probably fine on my machine." Two goroutines with no synchronization between them have no guaranteed order, so both reads can see the pre-increment value, and each write can clobber the other. The honest phrasing: neither increment happens-before the other; both read and both write are unordered, so any of 1, 2, or 3 may appear — and under the race detector, this is a report, not a surprise.
Edges you are expected to name:
| Operation A | happens-before B | What it guarantees |
|---|---|---|
goroutine creation (go f) |
f starts |
everything before go is visible inside |
ch <- v |
<-ch receives v |
writes before the send visible to receiver |
mu.Unlock() |
mu.Lock() |
critical section serializes |
wg.Wait() returns |
— | every wg.Done() completed first |
Three fixes, three different invariants:
// 1. Mutual exclusion: protect a compound invariant (read-modify-write).
mu.Lock(); count++; mu.Unlock()
// 2. Atomic: a single scalar's update, contended but simple.
atomic.AddInt64(&count, 1)
// 3. Channel: ownership transfer — the goroutine that holds the value
// is the only one that writes it.
counts := make(chan int, 1)
The distinction to articulate: a mutex protects data, a channel protects ownership. When the invariant spans several fields (total and entries must agree), a mutex (or doing all mutation in one goroutine fed by a channel) is the answer; when it is one counter incremented from many goroutines, atomic is lighter. Choosing a channel for its own sake — "channels for everything" — is a common misstep, and saying why not earns as many points as saying why.
Then the proof: go test -race ./... fails the test on the unsynchronized version and passes the fixed one. Interviewers who have the environment ask you to run it; those who do not ask what it detects — the answer being unsynchronized conflicting accesses where the detector can prove no happens-before edge exists (races it cannot see, like two truly independent variables, are not reported). A complete answer closes the loop: name the unordered operations, state which happens-before edge is missing, pick the primitive that creates it, and verify with the detector — the same sequence used in production debugging (The Race Detector covers the tool's internals).
Quiz
1. In a worker pool, who should close the jobs channel, and why?
2. Your errgroup pipeline should stop the moment one shard fails. What does errgroup.WithContext provide?
3. A goroutine profile shows 200 identical stacks ending in `chan send` inside your fanIn function. What is the diagnosis and the fix?
4. Two goroutines increment the same int with no synchronization. Why is `atomic.AddInt64` the right fix for this case but a mutex the right fix for updating `total` and `entries` together?
Flashcards
Question
What three guarantees must a correct bounded worker pool demonstrate?
Click to reveal answer
Answer
1) Bounded concurrency: at most N in flight, by having exactly N worker goroutines (or an N-slot semaphore). 2) Correct channel ownership: the producer closes jobs, workers only range. 3) Cancellation: the producer selects on ctx.Done() so shutdown stops feeding, and workers exit when the channel closes.
Question
How does errgroup.WithContext implement fail-fast across parallel tasks?
Click to reveal answer
Answer
It returns a context cancelled as soon as the first g.Go function returns a non-nil error, and g.Wait() returns that first error. Sibling tasks keep running but see ctx.Done() and should return early — fail-fast is cooperative, leaving partial side effects as a design consideration for idempotency or progress records.
Question
What are the three most common goroutine leak shapes, and one fix for each?
Click to reveal answer
Answer
1) Blocking send with no receiver — select on ctx.Done() around the send. 2) Missing defer cancel() on a derived context — pair every WithCancel/WithTimeout with a deferred cancel. 3) WaitGroup imbalance — an Add without a Done on some exit path — use defer wg.Done() so every branch releases.
Question
Name the happens-before edges Go's memory model guarantees for goroutines, channels, mutexes, and WaitGroups.
Click to reveal answer
Answer
Everything before `go f` is visible when f starts; a channel send happens-before the corresponding receive; mu.Unlock happens-before the next mu.Lock; every wg.Done happens-before wg.Wait returning. A data race is a conflicting access with none of these edges ordering it — which is why ordering, not probability, is the explanation.
Question
Mutex versus channel: how do you choose and defend the choice in an interview?
Click to reveal answer
Answer
A mutex protects data when a compound invariant spans fields and many goroutines read-modify-write it; a channel protects ownership when a value is mutated by exactly one goroutine and others communicate it by sending. Channels also carry the orchestration semantics (close, select) a lock cannot; choosing either by reflex rather than the invariant is what gets marked down.
Revision Notes
Key Takeaways
- 1. Bounded concurrency = exactly N workers; the channel close belongs to the producer, always
- 2. Fail-fast is cooperative: a cancel-on-first-error context plus workers that check ctx.Done()
- 3. Leaks are identified by blocked frames — chan send, select, Wait — and fixed by giving the blocked call an exit path
- 4. A race explanation needs the missing happens-before edge named, not just 'it's unsafe'
- 5. Mutex guards data, channel guards ownership; the invariant, not habit, picks the primitive
Interview Tips
- • "Implement a worker pool" — write it, then narrate the three guarantees unprompted: bound, close ownership, cancellation; pre-empt the panic-recovery and results-channel follow-ups
- • "How would you stop early on failure?" — errgroup.WithContext: one error cancels the shared context, g.Wait returns it; mention that in-flight work finishes cooperatively and partial side effects need idempotency
- • "Find the bug" — read the goroutine dump habitually: identical stacks ending in chan send means blocked senders; name the fix as select-with-context, not a bigger buffer
- • "Why is this a race?" — structure it as: operations with no ordering edge between them, the specific edge that's missing, the primitive that creates it, and `-race` as the proof
Cheat Sheet
Concurrency Interview Problems
Problem patterns:
| Prompt | Shape |
|---|---|
| "N jobs, limit concurrency" | N workers ranging over producer-closed channel + WaitGroup |
| "Fail if any fails" | errgroup.WithContext + g.Wait() |
| "Only X per second" | ticker + select (or x/time/rate) |
| "This code leaks" | chan send with no receiver / missing cancel / missing Done |
| "Is this safe?" | name unordered ops → missing happens-before edge → primitive → -race |
Worker pool template:
jobCh := make(chan Job)
for i := 0; i < limit; i++ {
wg.Add(1)
go func() { defer wg.Done()
for j := range jobCh { _ = fn(ctx, j) }
}()
}
go func() { defer close(jobCh)
for _, j := range jobs {
select { case jobCh <- j: case <-ctx.Done(): return }
}
}()
wg.Wait()
Happens-before edges: go f → f start · ch <- v → receive v · mu.Unlock → next mu.Lock · wg.Done → wg.Wait returns.
Fix chooser: single scalar contended → atomic · compound invariant → mutex · single-writer value → channel ownership.
Verification: go test -race, runtime.NumGoroutine before/after with runtime.Stack(buf, true) on failure.
Continue Learning
Before You Start
- No prerequisites listed.