Recommended resources
The freely available resources this lesson will direct you to.
Hello. In the previous lesson, sync.WaitGroup gave you a reliable way to wait for goroutines to finish. It did not give those goroutines a reason to stop. This lesson adds that missing signal: you’ll pass a context.Context through concurrent work, set a deadline for the whole operation, and cancel work that is no longer needed. Allow about 40 minutes, including the video and code run.
A signal to stop, not a command to stop
Imagine two goroutines working for one caller. If the caller no longer needs their results, waiting for both to finish normally wastes time. A context lets the caller signal that the work should be abandoned. Each function receiving the context must either observe that signal or pass the context to another function that does.
A context’s Done() method returns a channel that is closed on cancellation. After it closes, Err() tells you whether the reason was explicit cancellation (context.Canceled) or an expired deadline (context.DeadlineExceeded). Closing Done does not forcibly terminate a goroutine: your code must respond by returning, or use an operation that already supports contexts.
Watch these parts of justforfunc #9: The Context Package, by justforfunc: Programming in Go. The demonstration moves from creating a context to making a waiting function actually respect it.
justforfunc #9: The Context Package
Watch how the presenter derives cancelable and timed contexts, then replaces an uninterruptible sleep with a wait that can notice cancellation.
In creating contexts, focus on why WithCancel and WithTimeout return a new context and a cancel function. In responding to cancellation, notice that merely accepting a context parameter changes nothing: the function has to include ctx.Done() in its select.
The select is the key connection to the previous lesson. A WaitGroup tells the caller when tasks have finished. A context gives tasks a chance to finish early. In many programs you use both.
Pass one operation’s context to all its work
Contexts form a parent-and-child hierarchy. Canceling a parent cancels its descendants; canceling one child does not cancel its parent or siblings. The cancel function returned by context.WithCancel(parent) controls that new child context, not the parent it received.

For a new, top-level operation in a small program, context.Background() is a suitable root. If a function receives a context, derive from that context instead: replacing it with Background() would discard the caller’s cancellation and deadline.
The official context package documentation is worth keeping as a reference. Read its overview and usage rules now; you can return to the individual function entries while coding.
Read the Go standard library's context package documentation for the contract behind cancellation and for the conventions that let it propagate across function calls.
Under Documentation → Overview, begin at the paragraph starting “A Context may be canceled” and read the propagation contract, ending with the warning about unused cancel functions. Continue in Overview from “Programs that use Contexts should follow these rules” through the usage rules; focus on passing ctx explicitly as the first parameter and sharing it safely among goroutines. Finally, under Types → CancelFunc, read its contract. Notice especially that calling it does not wait for work to stop.
The convention you’ll use below is func run(ctx context.Context, ...). One caller can pass the same ctx to several goroutines. A worker should not try to cancel its caller’s context; the caller owns the decision to stop the operation.
Give concurrent tasks a shared deadline
The following program starts two tasks. One should finish well within the operation’s time budget; the other would take longer. The run function uses a timer as stand-in for interruptible work, so you can see exactly where cancellation is observed.
package main
import (
"context"
"fmt"
"sync"
"time"
)
type task struct {
name string
duration time.Duration
}
func run(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
tasks := []task{
{name: "short", duration: 30 * time.Millisecond},
{name: "long", duration: 350 * time.Millisecond},
}
var wg sync.WaitGroup
for _, job := range tasks {
wg.Add(1)
go func(job task) {
defer wg.Done()
if err := run(ctx, job.duration); err != nil {
fmt.Printf("%s: %v\n", job.name, err)
return
}
fmt.Printf("%s: complete\n", job.name)
}(job)
}
wg.Wait()
fmt.Printf("operation: %v\n", ctx.Err())
}
Save it as main.go and run go run main.go. You should see the short task complete, the long task report context deadline exceeded, and the operation report the same deadline error. The exact scheduling and elapsed time can vary; the important observation is that the long task returns without waiting for its full 350 milliseconds.
Follow the responsibilities in the code:
WithTimeoutgives both goroutines the same time budget. It derives a context rather than changingBackground().runwaits for either useful completion or cancellation. WhenDone()closes, returningctx.Err()preserves the reason.defer cancel()releases resources associated with the derived context even if the work finishes before its deadline. Callingcancelagain after the deadline is harmless.wg.Wait()confirms that the workers have actually returned. Callingcancel()alone would not provide that guarantee.
A timeout is a duration measured from context creation. When you have an absolute cutoff time, context.WithDeadline(parent, cutoff) expresses it directly. Either way, a child cannot extend its parent’s deadline: if the parent expires first, the child is canceled too.
Cancel early, and keep shorter budgets local
A deadline is not the only reason to stop. Suppose the caller has enough information to abandon an operation before its deadline. If it owns the cancel function, it can call cancel() immediately and then use wg.Wait() to await the workers’ return. Their ctx.Err() will normally be context.Canceled, rather than context.DeadlineExceeded.
You can also give one sub-operation a shorter budget without shortening the budget of its siblings:
func runQuickPart(ctx context.Context) error {
child, cancel := context.WithTimeout(ctx, 75*time.Millisecond)
defer cancel()
return run(child, 300*time.Millisecond)
}
Here, runQuickPart receives its caller’s context and derives a child. If 75 milliseconds pass first, only this child and work derived from it are canceled. If the caller cancels ctx first, the child is canceled immediately. The function defers its own cancel function because it owns this child’s lifetime; it does not own the caller’s context.
The distinction is important when diagnosing errors. A child reporting context.DeadlineExceeded does not by itself mean the entire parent operation has expired. Check the context whose outcome you need to report.
Where propagation can break
Propagation requires a continuous chain: a function accepts ctx, passes it to work it delegates, and makes its own interruptible waits observe ctx.Done(). Passing context.Background() to a helper instead of the received ctx breaks that chain. So does accepting ctx but then performing a long time.Sleep, a blocking operation, or a computation that never checks it.
In the example, select makes the timer wait interruptible. In other code, prefer passing the context into a library operation that supports it. For a long computation that has no such operation, arrange sensible points to check cancellation. A select does not promise which case wins if completion and cancellation become ready at the same instant; decide whether your application needs an additional check before accepting a result.
Keep contexts for operation-scoped cancellation and deadlines, not as substitutes for mutexes or WaitGroups. A context does not protect shared data, and its cancel function does not join goroutines. Those remain the separate responsibilities established in the previous lessons.
Takeaways
Pass a context explicitly through all work belonging to an operation. Derive child contexts for local cancellation or shorter deadlines, call their cancel functions when done, and have interruptible work observe Done() and return Err(). Use a WaitGroup when the caller must also know that the signaled goroutines have finished.
Next, you’ll apply similarly clear ownership rules to channels: deciding who is responsible for closing each one, and doing so exactly once.