Finding a Goroutine Leak
The bugs in the last lesson were wrong answers — a shadowed err, an
aliased slice. This one is a different shape entirely: the function runs,
returns the right thing, and quietly leaves a goroutine behind that will
never finish, ever. A goroutine leak doesn't show up in the return value at
all. It shows up later, as memory and scheduler load that only grows,
usually noticed in production long after the function that caused it
shipped.
func Watch(ctx context.Context, in <-chan int, report func(int)) (done chan struct{}) {
done = make(chan struct{})
go func() {
defer close(done)
for v := range in { // BROKEN: only exits when in is closed — ignores ctx entirely
report(v)
}
}()
return done
}
Watch is supposed to stop its background goroutine as soon as ctx is
cancelled. for v := range in doesn't check ctx at all — it exits only
when in is closed, which nothing in this bug's test ever does. Call
cancel() and the goroutine is still there afterward, parked on the next
receive from in, forever. The function you called returned. The goroutine
it started did not.
Making a leak into something a test can catch
A leak that never affects its caller is invisible to a test that only
checks the caller's return value — which is exactly what makes leaks
dangerous in real code. This exercise's test can only see the leak because
Watch was designed to make the goroutine's exit observable: it
returns done, a channel the goroutine itself closes as its very last act.
<-done after cancel() isn't polling or sleeping — it's a plain blocking
receive, and it only unblocks if the goroutine actually returned. If it
didn't, that receive blocks forever, and the test times out. That timeout
is the failure signal: not "sometimes slow," but every single run,
deterministically, because a goroutine that's actually stuck stays stuck
every time you run it, no -race or flaky timing required.
The fix is a select, not a bigger loop
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
report(v)
}
}
select waits on both ctx.Done() and in at once. Whichever is ready
first wins, so cancellation gets a real chance to interrupt the loop instead
of being ignored while in is the only thing being watched. This is the
general shape of "stop a goroutine on command": it needs an explicit case
for the stop signal in whatever it's blocked on, every single place it can
block — a channel range with no select has no way to be interrupted from
outside at all.
Try it
Fix Watch so cancelling ctx actually stops its goroutine — done must
close once ctx is cancelled, not only once in is closed.
Practice
1. Fix the Goroutine Leak
⚑ Find the bugIntermediateWatchstarts a background goroutine that callsreportfor every value received onin. It's supposed to stop that goroutine as soon asctxis cancelled, signalling the stop by closing the returneddonechannel.It doesn't: the loop only exits when
inis closed, so cancellingctxleaves the goroutine running (anddonenever closes) forever.Fix
Watchso:- Values received on
inare still passed toreport, in order. - As soon as
ctxis cancelled, the goroutine returns anddonecloses — even ifinis never closed and never sends again.
package main import "context" // Watch starts a background goroutine that calls report for every value // received on in, until ctx is cancelled — at which point the goroutine // must exit and close done. func Watch(ctx context.Context, in <-chan int, report func(int)) (done chan struct{}) { done = make(chan struct{}) go func() { defer close(done) for v := range in { report(v) } }() return done } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Values received on