Skip to content

One cancel reaches every goroutine

Level: 201 · anyone who has started goroutines and now has to stop them

One line: Calling a context's cancel closes ctx.Done() for every goroutine holding that context or one derived from it, and each goroutine waiting on it returns, with ctx.Err() now context.Canceled; cancelling a child never reaches its parent.

A goroutine cannot be stopped from outside (A goroutine has no handle). It can be asked. A context's Done method returns a channel that is closed when the work done for that context should stop, and a receive on a closed channel always proceeds at once ↗, so a single close wakes every goroutine waiting on it, however many there are.

The program below builds a tree of contexts for one order: payment and shipping are derived from order, and label is derived from shipping. It starts one goroutine per context, each waiting on its own Done, and then cancels twice: the shipping branch first, then the whole order.

Verified output of cancel_reaches_every_goroutine_go.go — regenerated by tools/run_examples.py, never hand-typed.

before any cancel:
  order     ctx.Err() = <nil>             goroutine waiting
  payment   ctx.Err() = <nil>             goroutine waiting
  shipping  ctx.Err() = <nil>             goroutine waiting
  label     ctx.Err() = <nil>             goroutine waiting
after cancelShipping():
  order     ctx.Err() = <nil>             goroutine waiting
  payment   ctx.Err() = <nil>             goroutine waiting
  shipping  ctx.Err() = context canceled  goroutine returned
  label     ctx.Err() = context canceled  goroutine returned
after cancelOrder():
  order     ctx.Err() = context canceled  goroutine returned
  payment   ctx.Err() = context canceled  goroutine returned
  shipping  ctx.Err() = context canceled  goroutine returned
  label     ctx.Err() = context canceled  goroutine returned
errors.Is(payment.Err(), context.Canceled): true

Reading the output

Before any cancel, every Err is nil and every goroutine is waiting. The Context interface promises that Err returns nil until Done is closed, and a non-nil error that never changes after it.

cancelShipping() closed two channels, shipping's and label's, because label was derived from shipping. order and payment did not notice. A cancel travels down the tree, never up to a parent and never across to a sibling. The word waiting in those rows is not a bet on timing: each of those goroutines returns only once its Done is closed, and theirs are not.

cancelOrder() closed the rest. Nothing had called cancelPayment by then, and payment's goroutine returned anyway: the package overview ↗ states that when a context is canceled, all contexts derived from it are canceled too. The last line shows the test to use on the error, errors.Is(err, context.Canceled); the next lesson has the other error a context can end with.

Two things the output does not show, both from the documentation for CancelFunc. A cancel does not wait for the work to stop, which is why the program waits for its goroutines itself, with a channel per step and a sync.WaitGroup (A WaitGroup counts goroutines). And after the first call, calling a cancel again does nothing, which is why every defer cancel...() in the program is safe even for a context that was already canceled.

The goroutines here only wait. Real work waits on its own channels as well, in a select with one more case for <-ctx.Done() (select waits on many channels), and returns ctx.Err() from that case. A goroutine that never looks at Done is not stopped by any cancel.

cancel_reaches_every_goroutine_go.go

cancel_reaches_every_goroutine_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// One cancel() reaches every goroutine that holds the context, or a context
// derived from it, and each of them returns. Cancelling a child never reaches
// its parent.
//
//  go build cancel_reaches_every_goroutine_go.go && ./cancel_reaches_every_goroutine_go
package main

import (
    "context"
    "errors"
    "fmt"
    "sync"
)

// One goroutine per step of an order, each holding its own context:
//
//  order
//  ├── payment
//  └── shipping
//      └── label
type step struct {
    name     string
    ctx      context.Context
    returned chan struct{} // closed when the step's goroutine returns
}

func main() {
    order, cancelOrder := context.WithCancel(context.Background())
    defer cancelOrder()
    payment, cancelPayment := context.WithCancel(order)
    defer cancelPayment()
    shipping, cancelShipping := context.WithCancel(order)
    defer cancelShipping()
    label, cancelLabel := context.WithCancel(shipping)
    defer cancelLabel()

    steps := []*step{
        {name: "order", ctx: order},
        {name: "payment", ctx: payment},
        {name: "shipping", ctx: shipping},
        {name: "label", ctx: label},
    }

    var wg sync.WaitGroup
    for _, s := range steps {
        s.returned = make(chan struct{})
        wg.Go(func() {
            defer close(s.returned)
            <-s.ctx.Done() // real work would select on this alongside its own channels
        })
    }

    report("before any cancel", steps)

    cancelShipping()
    <-steps[2].returned
    <-steps[3].returned
    report("after cancelShipping()", steps)

    cancelOrder()
    wg.Wait()
    report("after cancelOrder()", steps)

    fmt.Println("errors.Is(payment.Err(), context.Canceled):", errors.Is(payment.Err(), context.Canceled))
}

// report prints each step's ctx.Err() and whether its goroutine has returned.
// A goroutine whose context is not canceled cannot have returned, so
// "waiting" is as certain as "returned".
func report(title string, steps []*step) {
    fmt.Println(title + ":")
    for _, s := range steps {
        state := "waiting"
        select {
        case <-s.returned:
            state = "returned"
        default:
        }
        fmt.Printf("  %-9s ctx.Err() = %-17v goroutine %s\n", s.name, s.ctx.Err(), state)
    }
}

Always call cancel

The package overview gives the reason to call a cancel even when the work finished on its own: calling it removes the child from its parent and stops any timer the context holds, and not calling it leaks the child, and its children, until the parent is canceled. For a child of context.Background(), which is never canceled, that is for the life of the program. go vet checks for it, with an analyzer named lostcancel that runs by default:

Verified output of cancel_never_called_vet_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet .
./quote.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak
./quote.go:15:2: the cancel function is not used on all paths (possible context leak)
./quote.go:17:3: this return statement may be reached without using the cancel var defined on line 15
exit status 1
$ go vet .    # after moving defer cancel() to the line after each context.With...
exit status 0

The first finding is a cancel thrown away with _. The second and third are one mistake: defer cancel() comes after an early return, so that path leaves without it. The second quote.go puts defer cancel() on the line after each context is made, and vet exits 0.

cancel_never_called_vet_sh.sh

cancel_never_called_vet_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# go vet's lostcancel check. A CancelFunc that is thrown away, or called on only
# some of the paths out of a function, is reported, and vet exits 1. The same
# two functions with `defer cancel()` straight after each context is made pass.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
export GOTOOLCHAIN=local

say() { printf '$ %s\n' "$*"; }

# vet names the package on lines that start with #; its findings are the rest.
vet() {
    go vet . 2>"$dir/vet.txt"
    local status=$?
    grep -v '^#' "$dir/vet.txt"
    echo "exit status $status"
}

cd "$dir" || exit 1
go mod init example 2>/dev/null

cat >quote.go <<'GO'
package main

import (
    "context"
    "errors"
    "time"
)

func quoteDiscarded(parent context.Context) error {
    ctx, _ := context.WithTimeout(parent, time.Minute)
    return ctx.Err()
}

func quoteOnePath(parent context.Context, supplier string) error {
    ctx, cancel := context.WithCancel(parent)
    if supplier == "" {
        return errors.New("no supplier")
    }
    defer cancel()
    return ctx.Err()
}

func main() {
    _ = quoteDiscarded(context.Background())
    _ = quoteOnePath(context.Background(), "acme")
}
GO

say 'go vet .'
vet

cat >quote.go <<'GO'
package main

import (
    "context"
    "errors"
    "time"
)

func quoteDiscarded(parent context.Context) error {
    ctx, cancel := context.WithTimeout(parent, time.Minute)
    defer cancel()
    return ctx.Err()
}

func quoteOnePath(parent context.Context, supplier string) error {
    ctx, cancel := context.WithCancel(parent)
    defer cancel()
    if supplier == "" {
        return errors.New("no supplier")
    }
    return ctx.Err()
}

func main() {
    _ = quoteDiscarded(context.Background())
    _ = quoteOnePath(context.Background(), "acme")
}
GO

say 'go vet .    # after moving defer cancel() to the line after each context.With...'
vet

What to do

  • Give every goroutine you may need to stop a context, and make each of its blocking waits a select with a <-ctx.Done() case that returns ctx.Err().
  • Derive a child with context.WithCancel(parent) for each part you may want to stop on its own. Cancelling the child stops that part; cancelling the parent still stops everything.
  • Write defer cancel() on the line after the context is made, and run go vet.
  • A cancel asks; it does not wait. When the caller needs the goroutines to have stopped, it waits for them. First error cancels the rest puts the two together.

In other languages

  • The Concurrency library's Who waits when main returns? ↗ notes that Python's threading documentation recommends an ordinary thread and a signal such as an Event for work that has to stop cleanly. An Event is the closest thing to Done: set once, seen by every thread that waits on it. It has no children, though, and clear() can unset it, while a canceled context stays canceled.
  • The same page's C++ section uses std::jthread, whose destructor asks its thread to stop. The thread sees the request through a std::stop_token and has to check it and return, just as a goroutine has to watch Done.
  • Java's Thread.interrupt is also a request: it sets a flag, and it wakes a thread blocked in sleep, join or wait with an InterruptedException. That wake-up is what a select on ctx.Done() gives a goroutine, but only a goroutine that asks for it.
  • The Rust library's Spawning a thread ↗: a JoinHandle offers join, thread and is_finished, and nothing that stops the thread. Stopping is a flag or a channel the thread checks, which is a context without the tree.
  • Cancellation in async code, where a task can be cancelled at an await, belongs to the Concurrency library's planned chapter 06, Async.

Sources