Prompt and context
A batch processor handles many objects concurrently. The parent goroutine waits for all tasks and stops dispatching when a task fails or the context is canceled. Use Go 1.25 sync.WaitGroup.Go to define the lifecycle, and explain its relationship to Add, Done, and Wait, its panic contract, error propagation, and cancellation boundaries. This is a coding question because the core skill is concurrent task lifecycle design.
What the interviewer evaluates
First, whether you bind task creation to counting instead of placing Add where it can race with Wait.
Second, whether you understand the WaitGroup.Go contract: the function must not panic, and the counter is decremented when it returns; no error or cancellation propagation is provided.
Third, whether you can design bounded concurrency, stop dispatching, and collect results without leaks, races, or unbounded queues.
Fourth, whether you can identify when errgroup.WithContext is a better fit than treating WaitGroup as a full orchestration primitive.
Fifth, whether tests cover success, cancellation, panic protection, and wait races while the Go version and CI contract are explicit.
Questions to clarify first
- Is the compiler pinned to Go 1.25 or later?
- What is the concurrency limit, and can input be an unbounded stream?
- Should the first error cancel siblings, or should all errors be collected?
- May a task panic, and which layer recovers it if not?
- Must results preserve input order, and do errors need object identifiers?
- Do downstream calls support context cancellation and idempotent retries?
A 30-second answer
“I use WaitGroup.Go to bind each launch to its counter and a semaphore to cap concurrency. Each task checks context and writes results or errors through a protected collector. WaitGroup only waits; it does not propagate errors or cancellation, so the first error must explicitly cancel a derived context. If first-error cancellation is the standard policy, I would use errgroup.WithContext. The task entry point must either recover allowed panics into errors or make the process-level policy explicit. Tests cover cancellation, the concurrency peak, convergence, and races.”
Detailed solution
Step 1: Pin the API contract
Go 1.25 added Go(f func()) to sync.WaitGroup. It starts f and performs the equivalent of Done when f returns; the documentation requires that f not panic. Pin the version in go.mod, CI images, and local tooling so older compilers cannot silently enter the workflow.
Step 2: Put the concurrency limit at the task boundary
Use a buffered semaphore before calling Go or inside the task. If dispatch can block, make acquisition cancelable so a canceled context does not leave the dispatcher waiting forever.
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
for _, item := range items {
if err := ctx.Err(); err != nil { break }
sem <- struct{}{}
item := item
wg.Go(func() {
defer func() { <-sem }()
if ctx.Err() != nil { return }
process(item)
})
}
wg.Wait()Step 3: Define error and cancellation channels
WaitGroup stores no errors and does not cancel siblings. Derive a cancelable context; the first task that records an error calls cancel. Use a channel or mutex for the collector, and read it after Wait returns to avoid concurrent access.
Step 4: Make the panic boundary explicit
If the service treats panic as a recoverable task failure, a deferred recover can convert it to an error with a stack trace and trigger cancellation. If panic indicates a broken invariant, do not silently recover; document logging, alerting, and process-exit behavior.
Step 5: Avoid dispatch-and-wait races
Do not call Wait while another goroutine may still call Go unless a lifecycle protocol makes that safe. A batch processor should have one dispatcher decide when no more tasks are added, then call Wait. Dynamic recursive tasks must state when nested Go calls are allowed.
Step 6: Compare errgroup
errgroup.WithContext provides the first non-nil error, derived cancellation, and an optional limit, so it fits request fan-out where one failure stops siblings. WaitGroup.Go fits independent tasks, custom error aggregation, or lifecycles managed by another component.
Step 7: Verify the invariants
Tests should prove every launched task eventually decrements the count; cancellation stops new dispatch; one error triggers cancellation once; the peak stays within the limit; and results stop changing after Wait. Run go test -race to detect collector races.
A high-quality sample answer
“I pin Go 1.25 first. The dispatcher acquires a semaphore while the context is live, then calls wg.Go; the task releases the semaphore, checks cancellation, and performs the work. WaitGroup supplies lifecycle waiting only, so I add a derived context, an error channel carrying the object ID, and one-shot cancellation for first-error policy. If panic is recoverable, I convert it to an error with its stack; otherwise process-level handling remains explicit. I stop dispatching before Wait, aggregate results afterward, and run go test -race. For first-error cancellation plus a limit, I would choose errgroup.WithContext.”
Common mistakes
- Treating
WaitGroup.Goas an error group → errors are not propagated → collect them explicitly or use errgroup. - Blocking on the semaphore after cancellation → the dispatcher cannot exit → select on context while acquiring.
- Letting the task panic directly → it violates the contract and may abort the process → convert allowed panics or use an explicit process policy.
- Appending to one slice from many goroutines → a data race results → use a channel, mutex, or post-wait aggregation.
- Calling Wait before dispatch ends → dynamic additions race with waiting → define a stop-dispatch protocol.
- Asserting only the final count → cancellation and peak concurrency bugs hide → assert each invariant and run the race detector.
- Ignoring the Go version → local and CI behavior diverge → pin the module, image, and toolchain.
- Using WaitGroup for every orchestration concern → retries, deadlines, and first-error policy become scattered → choose errgroup or a dedicated scheduler when appropriate.
Follow-up questions
Follow-up 1: Can Go receive a function that panics?
The official documentation requires that the function not panic. If panic is a recoverable business failure, recover at a defined boundary and return an error; otherwise preserve panic semantics and rely on the process-level recovery and alerting policy.
Follow-up 2: How do you guarantee no task starts after cancellation?
Check ctx.Err() before dispatch, use a cancelable select while acquiring the semaphore, and check context again at task entry. Already-running work still depends on downstream APIs honoring cancellation.
Follow-up 3: When is errgroup better?
Choose errgroup.WithContext for first-error propagation, sibling cancellation, and unified waiting. Choose WaitGroup.Go for independent tasks or custom error aggregation.
Follow-up 4: Can Go be called while waiting?
Only with a lifecycle protocol that prevents the counter from reaching zero before new work is added. A normal batch should stop dispatching before Wait to avoid zero-count races.
Follow-up 5: How do you preserve result order?
Assign each input an index and let a task write to its slot or send an indexed result. Aggregate by index after waiting; do not share an append-only slice across tasks.
Follow-up 6: How do you test panic recovery?
Inject a panicking task and assert that the recovery layer emits an identified error, triggers cancellation, and lets other tasks converge. Separately test the non-recovery path against the agreed monitoring and process policy.