Go Concurrency and Cancellation
Go concurrency and cancellation work best when every goroutine has an owner, a reason to stop, and an observable completion path. Before starting a worker, decide who waits for it and what happens if its consumer stops early. Cancellation is a request to stop; it is not evidence that the worker has already returned.
Use channels and contexts to express the lifecycle rather than hiding it in a larger buffer or a timed sleep. A buffer can absorb some work, but it does not establish that an abandoned producer can finish for every workload.
TL;DR
- Give each owned goroutine a stop condition and a join mechanism.
- Make blocking sends, receives, and external operations cancellation-aware.
- Let the component that owns sending define channel closure.
- Protect shared state independently of cancellation signaling.
- Run race detection and realistic lifecycle tests as different checks.
Quick Example
When run, it prints zero and waits for its generator to finish after cancellation.
Additional tests repeated the consume/cancel/join sequence 200 times, checked output closure, and exercised a context canceled before generation. Those tests passed. They do not prove that an arbitrary application is leak-free or race-free; no race-detector run, network request, database transaction, or external rollback was performed.
Core Concepts
Go's pipeline guidance describes how a downstream consumer that stops early can leave upstream senders blocked. Explicit cancellation lets those senders abandon work instead of relying on a fragile buffer-size assumption. Go pipeline cancellation.
The context package supplies cancellation and deadline propagation through a Done channel. Code must observe that signal and return from relevant work. Derived contexts do not extend an earlier parent deadline. Go context documentation.
Separate request lifetime, worker lifetime, and business-operation outcome. A canceled request can stop waiting while an external write has already committed. A returned worker may have completed cleanup while the logical operation still needs reconciliation.
Choose the synchronization for the question
This is an editorial lifecycle matrix. Use the mechanism that answers the actual question rather than treating every channel as interchangeable.
Make blocking points accountable
For a producer, a send can block when the consumer leaves. For a consumer, a receive can block when the producer never sends or closes. Include cancellation in the appropriate select and define channel ownership so shutdown does not create a send-on-closed-channel panic.
In the example, the producer closes its output and completion channels. The caller cancels and waits; it does not close an output channel another goroutine may still use. This ownership is small enough to inspect and test.
When several select cases are ready, do not assume cancellation always wins immediately. Design the acceptable behavior around that fact. If a consequential action must not begin after a particular boundary, enforce the operation's actual state and authorization contract rather than relying on select ordering.
Pass context into APIs that support it and inspect their documented cancellation behavior. Wrapping an uncancelable blocking operation in a goroutine can let the caller stop waiting while the worker remains blocked. That is a changed waiting policy, not a completed cleanup strategy.
Bound concurrency and separate shared state
Choose an admission and concurrency policy for the workload. Starting one goroutine per input does not make the downstream service capable of serving all of them. Decide what happens when capacity is exhausted: wait within a deadline, reject, or queue with explicit bounds.
Keep mutable shared data under an appropriate ownership or synchronization rule. A context becoming canceled does not protect a map, slice, or counter from concurrent access. Review reads and writes along every relevant path, including error and shutdown handling.
Go's race detector observes executed behavior and reports conflicting accesses. Its documentation explicitly notes that unexecuted paths remain outside its evidence. A clean run is useful but does not establish a complete correctness proof. Go race detector.
The local environment ran ordinary compilation and lifecycle tests only. A suitable race-enabled toolchain and representative test workload remain separate verification work; this article does not imply they passed.
Best Practices
Write lifecycle tests around the actual failure points: consumer stops early, input never arrives, context is already canceled, deadline expires, worker fails, and shutdown begins during work. Wait for owned completion instead of sleeping and hoping it happened.
Keep errors and outcomes explicit. Distinguish context cancellation, timeout, internal failure, and successful completion in the API contract. Do not transform every failure into an empty successful result that hides unfinished work.
Document ownership near the boundary: who starts workers, who cancels, who closes channels, and who joins. If those answers require reading unrelated files, consider a smaller component or clearer interface.
Common Mistakes
Bad: Increase a channel buffer until the shutdown test stops hanging.
Correct: Explain why the producer can stop for every supported consumer behavior, then test cancellation and completion directly.
Bad: Call cancel and immediately declare every worker finished.
Correct: Wait through the owned completion mechanism and preserve a bounded failure path for a worker that does not return.
Bad: Treat a clean race run as proof of leak freedom.
Correct: Test synchronization and lifecycle separately. A blocked goroutine need not produce a data race.
FAQ
Should every function accept a context?
Use it where the operation belongs to a cancellable request or task and the API can meaningfully observe it. Avoid adding a context as decoration to pure calculations that never use it.
Does cancellation undo a remote write?
Not necessarily. The remote system may already have acted. Preserve the provider or transaction contract and reconcile uncertain outcomes instead of inferring rollback from a canceled wait.
What does the local test establish?
The exact generator stops and closes its output in the tested sequences. It does not validate another application's goroutines, scheduler behavior under all conditions, or external integrations.
Related Topics
- Go — Understand the language's concurrency primitives.
- Third-Party Integration Resilience — Track uncertain external outcomes.
- C# Async and Cancellation — Compare lifetime questions across runtimes.