Finally the Context interface clicked for me. I was reading the Network Programming with Go book and the example of the UDP server uses the context package to handle a server life-cycle across goroutines.

Consider the example echoServer function. It is used to create a UDP echo server replying to any incoming packet with the same packet.

func echoServer(addr string) (net.Addr, error) {
    s, err := net.ListenPacket("udp", addr)
    if err != nil {
        return nil, fmt.Errorf("binding to udp: %s: %w", addr, err)
    }
    go func() {
        ...// do some work here
    }
    return s.LocalAddr(), nil
}

The author does not intend to keep the server s around but he still has to manage its life-cycle, e.g. eventually calling the Close() method. The function returns the local address of the server because that is all the author needed but there is no way to close the server later on without returning the variable s to the caller.

This problem warrant the use of messaging through channels. Channels are a data type that is used to sync two goroutines. We can pass a channel ch we control to the echoServer function and have a goroutine wait for a message on it to close the underlying server s.

func echoServer(ch <-struct{}, addr string) (net.Addr, error) {
    s, err := net.ListenPacket("udp", addr)
    if err != nil {
        return nil, fmt.Errorf("binding to udp: %s: %w", addr, err)
    }
    go func() {
        go func() {
            // this read from channel ch will block until we write to it
            <-ch
            s.Close()
        }
        ...
    }
    return s.LocalAddr(), nil
}

Now the caller is in charge of closing the underlying server and the resource will not leak.

The context package provides a way to reuse this pattern using an interface instead of a channel. A context interface can be used to propagate a cancellation message to the server because it wraps a channel. To access the channel of the context we user the Done() method.

func echoServer(ctx context.Context, addr string) (net.Addr, error) {
    s, err := net.ListenPacket("udp", addr)
    if err != nil {
        return nil, fmt.Errorf("binding to udp: %s: %w", addr, err)
    }
    go func() {
        go func() {
            <-ctx.Done()
            s.Close()
        }
        ...
    }
    return s.LocalAddr(), nil
}

The selling point of the Context type is that it is an interface. Many types can be used in place of a Context argument. This means that we can have different behavior as long as we satisfy the interface.

The most common context types are the one provided by the context package.

  1. Background()
  2. TODO()

And the rest are derived using the WithCancel, WithDeadline and WithTimeout functions.

Finally we can see a full example using the Context interface to propagate cancellation from a goroutine to another.

package main

import (
	"context"
	"fmt"
	"time"
)

type S struct{}

func (*S) Close() {
	fmt.Println("Closing")
}

func spawn(ctx context.Context) {
	s := new(S)
	go func() {
		<-ctx.Done()
		s.Close()
		return
	}()
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	spawn(ctx)
	fmt.Println("spawned")
	time.Sleep(1 * time.Second)
	fmt.Println("cancelling")
	cancel()
        // exiting immediately closes Stdout so we will never see "Closing"
	time.Sleep(1 * time.Second)
}