Goroutine Common Pitfalls — Tasks¶
Hands-on exercises. Reproduce each pitfall, observe the failure, apply the fix, verify with tests or tooling. Tasks grow in difficulty.
How to use this file¶
- Pick a task.
- Write the broken version yourself (do not copy — typing it helps internalise).
- Run it. Observe the actual failure mode (race detector output, panic, hang, wrong output).
- Write the fix.
- Verify with
go test -race,goleak, orpprofas appropriate.
A solution sketch is at the bottom of each task.
Task 1 — Reproduce and fix the captured loop variable¶
Goal. See the bug on Go ≤ 1.21 (or with a go 1.21 directive in go.mod), then fix it.
Steps.
- Write a program that spawns 5 goroutines in a
for i := 0; i < 5; i++ {}loop, each printingi. - Run with
go runon a 1.21 toolchain (or pingo 1.21ingo.mod). - Observe
5 5 5 5 5(or similar). - Fix by passing
ias a parameter. - Verify output is some permutation of
0..4.
Bonus. Make the same bug visible on Go 1.22+ using a non-loop variable: for _, item := range items { x := compute(item); go func() { use(x) }() } — the x is captured. Find a way to make it racy.
Solution sketch.
Task 2 — wg.Add(1) inside the goroutine¶
Goal. Write code where wg.Wait() returns prematurely, then fix it.
Steps.
- Write a program that spawns 100 goroutines. Each calls
wg.Add(1)inside the body, does work, callswg.Done(). - Call
wg.Wait()and print "all done." - Add a sleep inside each goroutine (~10 ms) and observe that "all done" prints before the work finishes.
- Move
wg.Add(1)to the parent beforego. - Verify "all done" prints after all work completes.
Solution sketch.
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1) // parent
go func() {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}()
}
wg.Wait()
fmt.Println("all done")
Task 3 — Forgetting wg.Done()¶
Goal. Cause wg.Wait() to deadlock, then fix with defer.
Steps.
- Write a goroutine with two return paths: one happy, one error. Call
wg.Done()only on the happy path. - Force the error path and observe the runtime detect deadlock.
- Replace with
defer wg.Done()at the top.
Solution sketch.
go func() {
defer wg.Done()
if err := work(); err != nil {
return // wg.Done still fires
}
finalize()
}()
Task 4 — Goroutine leak via unread channel¶
Goal. Create a leak, detect it, fix it.
Steps.
- Write a function
compute()that spawns a goroutine sending on an unbuffered channel, then returns early without reading. - Call
compute()in a loop 10 000 times. - Print
runtime.NumGoroutine()— observe rising count. - Add
goleak.VerifyTestMain(m)to a test and run it; observe the failure. - Fix by buffering the channel:
make(chan int, 1).
Solution sketch.
ch := make(chan int, 1) // size 1
go func() { ch <- expensive() }()
return cached // safe: goroutine can send and exit
Task 5 — Ticker not stopped¶
Goal. Leak goroutines and tickers, detect, fix.
Steps.
- In a loop, call
time.NewTicker(time.Second)withoutStop. - Wrap each ticker's consumption in a goroutine with no exit condition.
- Observe goroutine and memory growth.
- Fix:
defer t.Stop()and addselect { case <-ctx.Done(): return; case <-t.C: ... }.
Task 6 — time.Sleep for synchronisation, failing in CI¶
Goal. Show that time.Sleep-based sync fails reliably under load.
Steps.
- Write
go heavyWork(); time.Sleep(50 * time.Millisecond); fmt.Println("done"). - Make
heavyWorkdeterministically slow by addingtime.Sleep(75 * time.Millisecond)inside. - Run repeatedly; observe
doneprinted before the work finishes. - Replace with
WaitGroupordonechannel; verify.
Task 7 — Concurrent map writes¶
Goal. Crash with fatal error: concurrent map writes.
Steps.
- Spawn 100 goroutines each writing to a shared
map[int]int. - Run; observe the fatal error.
- Try to
recoverit — observe thatrecoverdoes not work for fatal errors. - Fix with
sync.Mutex. Verify with-raceshows no race.
Task 8 — Double close¶
Goal. Reproduce panic: close of closed channel, fix with single-closer.
Steps.
- Five goroutines each
defer close(ch)and send a value. - Run; observe the panic.
- Fix: remove
defer close(ch)from senders; add one closer goroutine:go func() { wg.Wait(); close(ch) }().
Task 9 — Send on closed channel¶
Goal. Cause panic: send on closed channel, diagnose, fix.
Steps.
- Producer sends in a loop; another goroutine closes the channel after 100 ms.
- Run; observe the panic.
- Fix: ensure producer finishes before closing. Use
WaitGrouporcontext.Context.
Task 10 — time.After in a hot select loop¶
Goal. Observe memory growth from per-iteration timer allocation.
Steps.
- Write a select loop receiving from a high-rate channel with
time.After(time.Second)as the timeout case. - Run for 30 seconds at 100k messages/s.
- Observe
go tool pprof http://localhost:6060/debug/pprof/heapshowing timer heap allocations. - Replace with
time.NewTimer+Reset; rerun and observe reduced allocations.
Task 11 — defer in a tight loop¶
Goal. Run out of file descriptors due to accumulated defers.
Steps.
- Loop over 10 000 files, open each,
defer f.Close(), read. - Observe "too many open files" error.
- Extract the body to a function so
deferruns per iteration. - Verify the FD count stays bounded.
Task 12 — Forgotten cancel()¶
Goal. See go vet warn; observe runtime impact.
Steps.
- Write
ctx, _ := context.WithTimeout(parent, time.Second). - Run
go vet; observe the warning. - Make the parent context long-lived; observe via pprof that the timer goroutine lives until the deadline.
- Add
defer cancel(); rerun.
Task 13 — Mutex over a syscall¶
Goal. Demonstrate latency impact, then fix.
Steps.
- A goroutine pool of 4 workers each take a global
sync.Mutex, then make an HTTP GET request that takes ~1s, then release. - Measure throughput: ~1 req/s (serialised).
- Move the HTTP GET outside the critical section.
- Measure throughput: ~4 req/s.
Task 14 — Atomic + non-atomic mixing¶
Goal. See the race detector flag the mix.
Steps.
- One goroutine does
atomic.AddInt64(&counter, 1). - Another reads
fmt.Println(counter)(plain). - Run
go test -race; observe the race report. - Fix: use
atomic.LoadInt64on the read side, oratomic.Int64typed wrapper.
Task 15 — Singleton race¶
Goal. Show that if instance == nil { instance = ... } is racy.
Steps.
- Spawn 100 goroutines each calling
Get(). - Each
Get()checks-then-creates without a mutex. - Run with
-race; observe the race. - Replace with
sync.Once. Verify no race.
Task 16 — Background goroutine outliving a request¶
Goal. Memory grows under load.
Steps.
- Build a tiny HTTP server. Each handler
go logRequest(r)and returns immediately. - Make
logRequestsleep 1 second to simulate work. - Load-test at 1000 RPS for a minute.
- Observe goroutine count and memory growing linearly.
- Fix: bounded worker pool consuming from a buffered channel.
Task 17 — WaitGroup passed by value¶
Goal. See the go vet copylocks warning, understand why.
Steps.
- Write
func spawn(wg sync.WaitGroup) { ... }. - Run
go vet; observe the warning. - Convince yourself the function's local
wgis independent from the caller's by adding logging. - Fix: take
*sync.WaitGroup.
Task 18 — Reusing WaitGroup across rounds¶
Goal. Trigger undefined behaviour.
Steps.
- One
WaitGroupoutside a loop;Add/Done/Waitper iteration. - Add a small race window: a goroutine from round N is still in
Waitwhen round N+1'sAddruns. - Observe failures (timing-dependent; may need many runs).
- Fix: fresh
WaitGroupper iteration.
Task 19 — Errgroup ignoring context¶
Goal. Show that ignoring ctx defeats fail-fast.
Steps.
errgroup.WithContext. Five tasks; one returns an error after 100 ms; others sleep 5 s ignoring the context.- Measure:
g.Wait()takes 5 s. - Modify tasks to respect
ctx.Done(). - Measure:
g.Wait()takes ~100 ms.
Task 20 — HTTP client with no timeout¶
Goal. Leak goroutines due to slow servers.
Steps.
- Use
&http.Client{}(no timeout). - Hit an endpoint that hangs (a small Go server with
time.Sleep(1 * time.Hour)). - Observe the calling goroutine stuck.
- Add
client.Timeout = 5 * time.Second(or context with deadline). - Verify the call returns with
context deadline exceeded.
Task 21 — Panic in a goroutine¶
Goal. Make a service-killing panic; recover at the boundary.
Steps.
- Write a handler that spawns a goroutine which dereferences a nil pointer.
- Observe the program crashes.
- Add
defer recover()at the top of the goroutine body; verify the program continues. - Log the recovered panic + stack trace.
Task 22 — sync.Pool without Reset¶
Goal. See cross-contamination between pool users.
Steps.
sync.Poolof*bytes.Buffer. Get, write, return without Reset.- Next Get inherits the previous content.
- Add
buf.Reset()afterGet.
Task 23 — goleak integration¶
Goal. Add goleak to a test and make a test fail.
Steps.
import "go.uber.org/goleak"and addgoleak.VerifyTestMain(m)to a test file.- Write a test that spawns a goroutine and forgets to clean up.
- Run; observe the test fails listing the leaked goroutine.
- Fix the test.
Task 24 — Build a leak detector for a service¶
Goal. Implement a leak-budget alarm.
Steps.
- Expose
runtime.NumGoroutine()on/metrics. - Track over a 5-minute window.
- If the count is monotonically rising with constant input, alarm.
- Implement, simulate a leak, confirm alarm fires.
Task 25 — Production-style pprof investigation¶
Goal. Reproduce a leak and diagnose it via pprof.
Steps.
- Build a service with a deliberate leak (unread channel in a "fast path").
- Enable pprof:
import _ "net/http/pprof"; go http.ListenAndServe("localhost:6060", nil). - Run load.
- Dump goroutine profile:
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > gor.txt. - Read it; identify the dominant blocked stack.
- Fix the bug.
- Re-run; verify the dominant stack is gone.
Task 26 — Subtle: capture in a method receiver¶
Goal. Pre-1.22 capture of receiver in a goroutine.
Steps.
for _, s := range services { go s.Start() }wheresis a value receiver.- Pre-1.22, the captured
sis the same address. - Reproduce, then fix with parameter pass or
s := sshadow.
Task 27 — Subtle: deferred close with multiple senders¶
Goal. Show that defer close(ch) from N senders panics.
Steps.
- N goroutines, each
defer close(ch); send. - Observe panic.
- Apply single-closer; observe success.
Task 28 — Subtle: cgo + LockOSThread without unlock¶
Goal. Observe M creation pressure.
Steps.
- Spawn 1000 goroutines that each
runtime.LockOSThread(), do work, return without unlock. - Observe
runtime.NumGoroutine()and OS thread count (/proc/<pid>/status). - Note threads being destroyed.
- Apply
defer runtime.UnlockOSThread(); observe reuse.
Task 29 — Build a "pitfall finder" linter¶
Goal. Write a small program that scans Go source for these pitfalls.
Steps.
- Use
go/parserandgo/astto parse a directory of.gofiles. - Find every
*ast.GoStmt(go statement). - For each, check whether its function literal references a loop variable from the enclosing
*ast.ForStmt. - Print warnings.
This is an intermediate-difficulty AST exercise. The goal is to internalise that pitfalls have syntactic shapes tools can find.
Task 30 — Capstone: stress-test your own code¶
Goal. Find your own pitfalls.
Steps.
- Pick a service you have written or one in your codebase.
- Add
goleakto its tests. - Run all tests with
-race -count=10. - Fix everything that fails.
- Add the metrics from Task 24.
- Document one pitfall you fixed and why.
Tooling cheat sheet¶
# Race detector
go test -race ./...
# Vet (catches some)
go vet ./...
# Goroutine count
curl http://localhost:6060/debug/pprof/goroutine?debug=2
# Continuous heap profile
go tool pprof -seconds=30 http://localhost:6060/debug/pprof/heap
# Trace
import "runtime/trace"
trace.Start(f); defer trace.Stop()
# then: go tool trace trace.out
# Scheduler debug
GODEBUG=schedtrace=1000,scheddetail=1 ./your-binary
# GC debug
GODEBUG=gctrace=1 ./your-binary
Wrap-up¶
Pitfall reproduction is the fastest path to recognition. Tasks 1–10 give you the "shape muscle memory" — you have seen the bug, you know its symptom, you have applied the fix. Tasks 11–20 add real-world context. Tasks 21–30 push into observability, design, and tooling — the senior skills.
After completing this file, the pitfalls in find-bug.md should feel familiar. The bugs there are dressed up; the shapes are the same.