Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Prevent Goroutine Leaks with Cancellation-Aware Exits

Hello. In the previous lesson, you gave each data channel a clear closer. That solves one lifecycle problem, but not another: a goroutine can own its channel correctly and still remain blocked forever if its receiver leaves early. You have already used contexts to signal that work should stop; now you’ll put that signal inside the channel operations that might block.

Allow about 40 minutes to read the two resources, run the example, and trace where each goroutine can wait.


Why correct channel ownership is not enough

Recall the merge example: main read every result, the forwarding goroutines finished, and a coordinator closed the output. Suppose instead that main reads one result and returns. A forwarder trying to send the next result on the unbuffered output has no receiver. It cannot reach its deferred wg.Done, so the coordinator cannot finish waiting. An upstream producer may also become stuck trying to send to that forwarder.

Read the “Stopping short” section of Go Concurrency Patterns: Pipelines and cancellation on the Go blog.

Go Concurrency Patterns: Pipelines and cancellation

Read this section to see how an early-exiting receiver can leave otherwise well-structured pipeline goroutines blocked.

Under “Stopping short,” begin with the early-exit discussion. Continue through the comparison with buffering. Focus on why a buffer sized for one particular workload does not give senders a reliable way to learn that the receiver has stopped.

A blocked goroutine does not disappear because the function that started it returns. Nor will garbage collection terminate it. Cancellation must be observable at the place where it could otherwise wait indefinitely. For a channel send, that means offering the goroutine a choice between completing the send and receiving the cancellation signal.


Make a blocked send cancellable

The Go blog’s “Explicit cancellation” section first uses a done channel, then closes it to broadcast cancellation to any number of goroutines. Read it for the channel mechanism; our code will use ctx.Done(), which supplies the same kind of receive event while fitting the context-based cancellation you used earlier.

Go Concurrency Patterns: Pipelines and cancellation

Study how a sender can abandon a channel operation when downstream stops receiving.

In “Explicit cancellation,” start at the cancellable send and read through the pipeline guidelines, stopping before “Digesting a tree.” Pay particular attention to the select around an outbound send and to why closing the shared done channel works without counting senders.

Here is the essential change to a producer. A bare out <- n can wait forever; this select can complete when cancellation becomes available:

select {
case out <- n:
	// A receiver accepted the value.
case <-ctx.Done():
	return
}

A context does not forcibly interrupt goroutines. Its Done channel becomes readable; the goroutine must check it and return. The goroutine also must not close ctx.Done()—the owner of the cancel function triggers cancellation.

The context package documents a generator using precisely this pattern. Its caller stops reading after a few values and cancels the context so the generator can exit.

context package

Read the package documentation’s WithCancel example to connect early consumer exit with a producer’s select on ctx.Done().

Find “func WithCancel,” then its “Example.” Read the generator example through the loop’s break and the printed output. Notice both obligations: the producer watches ctx.Done(), and the caller invokes cancel when it no longer needs values.


Run a producer that the consumer can leave

The following program starts a finite producer, consumes only its first value, and cancels before consuming another. Save it as main.go and run go run main.go.

package main

import (
	"context"
	"fmt"
)

func produce(ctx context.Context, nums ...int) (<-chan int, <-chan struct{}) {
	out := make(chan int)
	stopped := make(chan struct{})

	go func() {
		defer close(stopped)
		defer close(out)

		for _, n := range nums {
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()

	return out, stopped
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	out, stopped := produce(ctx, 1, 2, 3)

	fmt.Println("first:", <-out)
	cancel() // We will not receive another value.
	<-stopped
	fmt.Println("producer exited")

	_, ok := <-out
	fmt.Println("more values available:", ok)
}

The first receive completes a send of 1. After that, the producer may attempt to send 2, but main will not receive it. Cancellation makes the other branch of the producer’s select ready, allowing it to return rather than remain blocked. You should see:

first: 1
producer exited
more values available: false

There are two distinct completion mechanisms here. The producer closes out because it is that channel’s sole sender; ok == false tells a receiver there are no more values. It also closes stopped to let this small demonstration wait for the goroutine itself to exit. The deferred calls run in reverse order, so out closes before stopped. In a program already tracking multiple workers with a WaitGroup, you can use that WaitGroup to confirm completion instead of creating a stopped channel for each worker.

Notice the division of responsibility: main calls cancel, but never closes out. The producer observes cancellation, stops sending, and closes its own output. Keeping defer cancel() is useful even though this path calls cancel() explicitly: it preserves cleanup if the function later gains another return path.


Check every place the goroutine can block

Protecting a send is not sufficient if the same goroutine might wait for input. For example, this forwarding goroutine could remain stuck in for n := range in when an upstream channel stays open but stops producing. Make both the receive and the send cancellable:

// forward must be the sole sender to out.
func forward(ctx context.Context, in <-chan int, out chan<- int) {
	defer close(out)

	for {
		var n int

		select {
		case <-ctx.Done():
			return
		case value, ok := <-in:
			if !ok {
				return
			}
			n = value
		}

		select {
		case <-ctx.Done():
			return
		case out <- n:
		}
	}
}

The outer select lets forward leave while waiting for input. The inner one lets it leave while waiting for an output receiver. If in closes normally, the ok check ends the loop. forward closes out only because its contract says it is the sole sender; a shared-output stage still needs the producing-side coordinator from the previous lesson.

Cancellation must extend through the whole chain. If forward stops receiving from in but an upstream producer continues an unconditional send to in, the upstream producer can leak instead. Give each stage that may be abandoned a way to stop at its own blocking operations.

One subtlety matters when you rely on results: if both a send and ctx.Done() are ready, select may choose either. Cancellation is a request to stop, not a promise that no value can ever be sent after cancellation is triggered. In the runnable example, after the first receive there is no further receiver, so only the cancellation branch can unblock a subsequent send.


Confirm exit, not just the cancellation call

When checking a cancellation fix, separate signalling from observing completion. Calling cancel() proves that the signal was issued; waiting for stopped or a WaitGroup proves that the worker returned. A test should bound that wait so a regression fails instead of hanging indefinitely:

select {
case <-stopped:
	// The worker exited.
case <-time.After(5 * time.Second):
	t.Fatal("producer did not exit after cancellation")
}

This fragment belongs inside a Go test with testing and time imported. The timeout is a failure bound, not a sleep intended to “give the goroutine time.” Also, a successful race-detector run would not establish that a blocked goroutine exits: a leak can occur without a data race.

Before applying the pattern elsewhere, inspect each wait point: channel send, channel receive, or an operation delegated to another API. A select helps with channel operations; for another blocking API, use its context-aware form if it provides one. Do not assume that a cancellation check before a blocking call can interrupt the call once it has begun.


Takeaways

A goroutine can leak even when its channel has the correct closer. When a consumer may stop early, give the producing work a cancellation signal and make potentially blocking sends—and receives—responsive to it. Keep data-channel ownership separate from cancellation, and wait for goroutine completion when you need to verify that the fix worked.

This completes the module’s lifecycle groundwork. Next, you’ll put these cancellation and coordination rules to use in a bounded worker pool, limiting how much concurrent work can be in flight.

Can't find a good explanation? Sign up and we'll make it for you

Sign up