Articles
Go Concurrency Interview Exercise: Find a Data Race and a Goroutine Leak
Practice Go concurrency debugging with runnable race and goroutine leak exercises, atomic update traps, stack evidence, cancellation, and worker-exit tests.
A service can return the correct answer while racing on shared memory. It can also pass Go's race detector while leaving a worker blocked after every abandoned request. In a Go concurrency interview exercise, these are separate problems, and each needs its own reproduction, repair, and evidence.
This guide develops two original exercises: a shared counter and a result sender whose consumer disappears. Official facts come from Go documentation linked where the contracts matter. Exercise observations come from local Go 1.27.1 runs; design recommendations are labeled as reasoning. No candidate reports are used, and neither exercise is presented as a company's reported interview question.
Start with Identify and handle race conditions, then explain your diagnosis aloud before changing the code. The central question is: what makes the state correct, and what guarantees that the work ends?
Exercise one: find the counter race
Save this self-contained program as race.go. Two workers each contribute 10,000 increments; the intended final count is 20,000.
package main
import (
"fmt"
"sync"
)
func main() {
var n int
var wg sync.WaitGroup
wg.Add(2)
for worker := 0; worker < 2; worker++ {
go func() {
defer wg.Done()
for j := 0; j < 10000; j++ {
n++
}
}()
}
wg.Wait()
fmt.Println(n)
}
Before running it, identify the shared location and every access. Both closures refer to the same n. Incrementing requires a read and a write. Waiting at the end orders completion before printing, but does not serialize the two workers' increments.
Official fact: the Go memory model defines a data race through conflicting memory accesses without the required synchronization. A program's apparent timing is not that synchronization. Saying that the workers “probably run one after another” cannot establish correctness. See the Go memory model.
Run the following in a scratch directory:
go run -race race.go
Exercise observation: our Go 1.27.1 run reported WARNING: DATA RACE, including the conflicting accesses in the increment loop, and the command failed. Your printed count and goroutine identifiers may differ. Even an observed count of 20,000 would not invalidate the report.
Read both access stacks, then the goroutine-creation stacks. The important relationship is two paths touching one location, not whichever goroutine number appears first. Go's race detector documentation explains this report format and warns that detection depends on executed paths.
A useful interview answer names the defect before naming the primitive: “Both workers modify the same counter without ordering their read-modify-write operations. The final join only waits for their completion.”
Repair the invariant, not only the warning
For this single independent counter, replace n with atomic.Int64, use n.Add(1) in the loop, and print n.Load() after the join. Add sync/atomic to the imports. The operation being protected is one increment, not an arbitrary collection of related fields.
Official fact: the atomic package provides a single atomic addition operation. Its separate load and store methods are individually atomic operations; combining them does not turn the pair into an atomic increment. See sync/atomic.
This tempting rewrite is therefore still wrong for the counting requirement:
old := n.Load()
n.Store(old + 1)
Consider an original controlled schedule, starting at zero:
| Step | Worker A | Worker B |
|---|---|---|
| 1 | Load 0 | Wait |
| 2 | Wait | Load 0 |
| 3 | Store 1 | Wait |
| 4 | Wait | Store 1 |
Two contributions have produced one. This is a logical concurrency bug even though the accesses use atomic methods. Our fixture uses a channel barrier after both loads, then releases both stores; the result is deterministically one, and that fixture passes -race.
For a balance and an associated ledger entry, my design recommendation is to protect the complete invariant with a mutex or give one goroutine ownership of the operation. An atomic counter cannot ensure that a second structure changes consistently with it.
Official fact: mutex synchronization and WaitGroup completion are different contracts in sync. A WaitGroup should cover worker lifetime; a mutex should cover the relevant critical section. For the original program, keep Add before starting the workers, keep Done on every exit, and keep the join before reading the final result.
Test the business result as well as the detector outcome. Our repaired atomic counter reaches exactly 20,000 after both workers exit. A passing race run with the wrong count would still fail the repair.
Exercise two: find the abandoned sender
Now remove shared mutable data entirely. The following worker has a private value and sends it through a channel:
func startResult() (<-chan int, <-chan struct{}) {
out := make(chan int)
done := make(chan struct{})
go func() {
defer close(done)
out <- 7
}()
return out, done
}
Suppose the caller starts this worker, then returns without receiving. Which statement allows the worker to finish? There is none. The unbuffered send needs a receiver, and done closes only after that send completes.
This is a lifetime failure without a data race. In a long-lived service, repeating the abandoned operation can leave more workers waiting. A successful HTTP response elsewhere does not show that these workers exited. Process termination would hide the symptom by ending everything.
To reproduce it safely, retain the returned channels in the test. Start the worker, inspect its blocked stack, and finally receive the value and wait for done during cleanup. That artificial cleanup receiver makes the demonstration reversible; it does not mean the abandoned production caller supplies one.
Avoid making a fixed sleep your proof. Sleeping for ten milliseconds neither establishes that the worker reached the send nor shows that it can never progress. Inspect the relevant stack or coordinate entry into the operation, and use a timeout only to bound a failing test.
Exercise observation: our fixture captured the named sender in a [chan send] stack state. Its completion channel remained open until cleanup supplied a receiver. The test then joined the worker, leaving no intentionally blocked worker behind.
Choose the diagnostic that answers your question
Official fact: the goroutine profile describes current goroutine stacks. The block profile records sampled time spent waiting on synchronization operations. These are different views in runtime/pprof.
For a small executable, write readable current stacks to standard error:
if err := pprof.Lookup("goroutine").WriteTo(os.Stderr, 2); err != nil {
log.Fatal(err)
}
Imports are runtime/pprof, os, and log. In an interview, locate your worker's function, its blocking operation, and the party that could unblock it. Do not call every idle goroutine a leak: an intentionally waiting worker pool can be healthy.
For blocking evidence in tests, enable collection explicitly:
go test -run TestBlockedSenderEvidence -blockprofile=block.out
There is an important implementation boundary. In the inspected Go 1.27.1 channel-send path, the runtime records the blocking event after the parked sender resumes. A sender still waiting forever can therefore be absent from this profile. The runtime channel source supports this interpretation; it is not a promise that all profiles capture unfinished waits.
Exercise observation: our blocked sender appeared in the goroutine stack snapshot but not in the block profile captured before cleanup. The absence of a block sample did not clear the worker.
Go 1.27 also makes the goroutineleak profile generally available. Official fact: its detection uses reachability to identify certain permanently blocked goroutines and can miss cases involving reachable synchronization primitives. The Go 1.27 release notes describe those limits. Check the target runtime version before promising this diagnostic in an interview.
My diagnostic recommendation is to combine repeated workload observations with ownership reasoning. Increasing goroutine counts suggest where to investigate; persistent stacks after the workload has drained identify candidates. Neither a single count nor an empty specialized profile proves the whole service leak-free.
Repair the sender and prove worker exit
For an operation whose result may be abandoned, give the owner a cancellation path and make the blocking send observe it:
func sendResult(ctx context.Context, out chan<- int,
done chan<- struct{}) {
defer close(done)
select {
case out <- 7:
case <-ctx.Done():
}
}
This helper imports context. The caller creates the channels, starts exactly one sender, and owns cancellation. After abandoning the result, it cancels and waits for done, with a bounded test timeout. Here the worker owns closing done; it does not close the caller's output channel.
This is an engineering repair for the stated contract: either deliver the one result or stop when canceled. It is not a guarantee that delivery never happens after cancellation becomes ready. Official fact: if both select cases can proceed, there is no cancellation priority under the Go select specification. A stricter delivery boundary needs an explicit protocol. The adjacent Go context guide develops cancellation ownership and deadline propagation.
Buffering deserves a conditional answer. A capacity-one channel lets this exact one-send worker finish even if nobody receives. For a stream, finite capacity merely postpones blocking unless the producer has an exit path. Explain the maximum number of sends, acceptable retained results, and consumer ownership before choosing the buffer.
Also inspect work before the select. Replacing the literal seven with an indefinitely blocking function call does not make that function cancelable. Every potentially blocking phase needs its own termination contract.
Our repair tests cover successful delivery and cancellation with no receiver. Both explicitly observe worker completion. They passed under -race; this is evidence about those exercised paths, not a production-wide proof.
Before calling the repair complete, separate three assertions. First, did the expected output appear on the successful path? Second, did the canceled path release its worker when output was unavailable? Third, did the test wait for that worker rather than merely observe that cancellation was requested?
A useful follow-up is to ask who closes the shared result channel when there are several producers. Closing it from the first producer to finish can make a later producer panic. My recommendation is to let a coordinator close it after all producers have returned. That changes the ownership model, so it deserves a separate test instead of being smuggled into the single-sender example.
For cancellation stress tests, vary whether the receiver is ready and when the cancel signal arrives. Assert only outcomes permitted by the contract: delivery may win a simultaneous race, but all workers must eventually finish. Do not demand a particular scheduler order. When a test times out, preserve the relevant goroutine stacks so the failure explains which operation still prevents completion.
What to say when the interviewer asks for stronger evidence
Use a short evidence statement: “I reproduced the unsynchronized increment with the race detector, replaced it with an atomic addition, and asserted the exact joined count. Separately, I observed the abandoned sender's stack, added a cancellation-aware send, and tested that the worker exits without a receiver.”
Then state the remaining limits. We did not load-test a server, benchmark lock contention, or test a database driver. Go's diagnostics guidance offers additional profiling and tracing tools when the question expands from correctness to scheduling or performance.
For your own test suite, keep deliberately racing examples out of the normal passing package. Run them as an expected-failure child command. Keep a cleanup path for intentionally blocked examples. Otherwise, the demonstration itself can contaminate later evidence.
| Question | Practice focus |
|---|---|
| Identify and handle race conditions | Identify conflicting shared accesses |
| Fix race condition in concurrent deposit | Protect the complete update |
| Design a Thread-Safe Bounded Blocking Queue | Explain blocking and shutdown |
| Debug a Concurrent Job Scheduler | Separate scheduling from completion |
| Improve concurrency beyond a single lock | Preserve correctness while partitioning |
For a final rehearsal, use Debug a Concurrent Job Scheduler and describe both the state transition you protect and the event that ends each worker. Your answer should make those obligations independently testable.