Go Package context

From NovaOrdis Knowledge Base
Jump to navigation Jump to search

External

Internal

Overview

The context package provides two, somewhat unrelated, features.

One is the capability to preempt, or cancel, blocking code executed by goroutines running downstream in the call graph. The context package provides an API for cancelling branches of the call graph. Cancellation can be programmatic, on timeout or on deadline. This is an idiomatic preemption pattern, equivalent, but preferred to the Done channel pattern. It is preferred because its implementation is available in the standard library, making it a standard Go idiom to consider when working with concurrent code. The implementation is also more expressive.

The second feature is the capability to store request-scoped data and propagate this state through the call graph.

Explicit context propagation, as an argument of functions implementing the pattern, is a key difference between Go and other languages.

Concepts

Context

https://pkg.go.dev/context#Context

context.Context is an interface exposed by the context package. The Context instances flow across API boundaries and through the system in the same way Done channels do. The role of the Context instances is to carry cancellation signals, deadlines and request-scoped values. To implement the pattern, each function downstream from the top level concurrent call must take a Context as parameter. Conventionally, is the first parameter of the function. For more rules of using Contexts, see Programming Model. The implementations must ensure that they are concurrent-safe, so their methods may be called by multiple goroutines simultaneously.

type Context interface {

	// Deadline returns the time when work done on behalf of this context should be canceled. 
	Deadline() (deadline time.Time, ok bool)

	// Done returns a channel that's closed when work done on behalf of this context should be canceled. 
	Done() <-chan struct{}

	// If Done is not yet closed, Err returns nil. If Done is closed, Err returns a non-nil error 
	// explaining why: Canceled if the context was canceled or DeadlineExceeded if the context's deadline passed.
	Err() error

	// Value returns the value associated with this context for key, or nil if no value is associated with key. 
	Value(key any) any
}

The Context interface does not expose any method that allows cancelling the Context, and the Done channel is read only, it cannot be closed. This protects functions up the call stack from children cancelling the context: only the parent can cancel the children functions, as shown in the Programmatic Preemption (Cancellation) section. The function receiving the cancellation signal is not the one that sends the signal.

The Context interface does not expose any method that can mutate the state of the underlying structure, either. The context is immutable, so when new values are stored, the context is actually cloned, with extra metadata.

Err()

ctx.Err() returns an error indicating why the Context was canceled, as follows:

  • nil is the Done channel is not closed yet.
  • the context.Canceled error, if the context was programmatically canceled by invoking the cancel function.
  • the context.DeadlineExceeded error, if the context timed out or the context's deadline has passed.

The Background Context

https://pkg.go.dev/context#Background

The background Context is a non-null, empty, no-deadline and no-values Context returned by the context.Background() function. This context is never canceled, and it is typically used by the main function, initialization and tests, and as the top-level Context for incoming requests. The background Context is the root of the context tree.

The TODO Context

TODO is intended to serve as a placeholder for when you don't know which Context to utilize, or if you expect your code to be provided with a Context, but the upstream code hasn't yet furnished one. TODO is not meant for use in production.

Derived Context and the Context Hierarchy

Cancellation or data storage can only be performed on a derived context. The context package provide functions that derive new Context instances from an existing context: WithCancel(), WithTimeout(), WithDeadline() and WithValue(). The Context instances created this way form a tree. The Background context is the root of this tree. When a Context instance is canceled, all Context instances derived from it are also canceled.

Preemption (Cancellation)

Preemption, or cancellation of a function executing a blocking operation on a channel, or any other blocking operation (disk, network IO, user), consists in the immediate return from the blocking operation, and usually return from the function with a cancellation error. Programmatic cancelation is useful in stopping processing or handling application shutdown. Cancellation is initiated by a parent goroutine in the call graph.

Cancellation Signal - the Done Channel

The "cancellation signal" carried by the context is a Done channel that is closed when the functions associated with the context need to be preempted. The channel is returned by the context's Done() method. The functions typically read the channel in a select statement, and when the read returns, because the channel was closed, the functions should abandon their work and return. For an example of how to do that, see Programmatic Preemption below.

Also see:

Go Channels | The Done Channel

Cancel Function

The cancel function is a function returned by the invocation of the WithCancel function, which then can be used to cancel the context returned by the same invocation of the WithCancel function. For an example, see Programmatic Preemption below.

Deadline

A deadline is communicated to the user by the result of the Context's Deadline() method. The method returns the time when work done on behalf of this Context should be cancelled, or a false value if no deadline is set. Checking the deadline programmatically allows the function to fail fast, which, in programs that may have a high cost for the next bit of functionality, this may save a significant amount of time. This approach implies that we need to have some idea of how long the subordinate call graph will take, which may not be trivial.

func someFunc(ctx context.Context) error {
	if deadline, ok := ctx.Deadline(); ok {
		// the context has a deadline
		if deadline.Sub(time.Now().Add(1 * time.Minute)) <= 0 {
			return context.DeadlineExceeded
		}
	}
	// the context does not have a deadline
	...
}

Also see Programmatic Deadline section, below.

Timeout

See Programmatic Timeout section, below.

Call Graph

Requests and Request-Scoped Data

One of the primary use of goroutines is to service requests. Each incoming request is handled in its own goroutine. Request handlers often start additional goroutines to access backends such as databases and RPC services. The set of goroutines working on a request typically need access to request-specific information, such as user identity, authorization tokens, and the request's deadline, in addition to the capability to cancel the goroutine tree.

The Context exposes the Value() method, which returns a request-scoped value for a given key. For an example of how to do this programmatically, see Storing Request-Scoped Data below.

The package documentation is quite vague on what kind of data should be stored in the Context: "Use context values only for request-scoped data that transits processes and API boundaries, not for passing optional parameters to functions." The following heuristics may help:

  1. The data should transit process or API boundaries. If you generate the data in the process' memory, it's probably not a good candidate, unless you also pass it across an API boundary.
  2. The data should be immutable. If it is not, then by definition what you're storing did not come from a request.
  3. The data should trend toward simple types. This way, it is much easier for the other side to pull the data if it does not have to import a complex graph of packages.
  4. The data should be data, not types with methods.
  5. The data should help decorate operations, not drive them. If your algorithm behaves differently based on what is or isn't included in the Context, you have likely crossed over into the territory of optional parameters.

Using the value propagation feature of the Context is dubious. Don't use Context values for stuff that should be regular dependencies between components. Use Context values for data that can't be passed to your program in any other way: only data that is request-scoped, stuff that is created at the beginning of a request lifecycle, like request ID, etc. If the information is available when the program starts or at any point prior to when the request starts, do not use Context values. This is the case for database handles, loggers, etc.

Context Keys, Values and Type Safety

Since Context's keys and values are defined as interface{}, we lose Go's type safety when attempting to retrieve values. A specific technique can be employed to restore type safety, as described in the Restoring Key/Value Type Safety for Request-Scoped Data section.

Passing Loggers in Context

See:

Zap Concepts | Loggers and context.Context

Programming Model

  • Each function downstream from the top level concurrent call must take a Context as the first parameter.
  • Context instances cannot be mutated or cancelled directly, as explained above.
  • To allow a parent goroutine to cancel its children, create a new Context instance with one of the WithCancel(), WithTimeout() and WithDeadline() methods and pass that instance to the function executing on the child goroutine. The derived Context instance can be canceled sooner than the parent context. See:
  • If the parent goroutine does not need to modify the cancellation behavior of the functions running on children goroutines, it will pass the unmodified Context. In this way, successive layers of the call graph can create Context instances that adhere to their needs, without affecting the parents. This composability enables writing large system without mixing concerns through the call graph.
  • The Context associated with an incoming request is typically canceled when the request handler returns.
  • Only pass Context instances as parameters to functions. Do not store references to Context instances. These instances may look equivalent from the outside, but internally may change at every stack frame.

Programmatic Preemption (Cancellation)

https://pkg.go.dev/context#WithCancel
https://pkg.go.dev/context#WithCancelCause

This section documents the idiomatic pattern to preempt, or cancel, a blocking function.

It consists in a someFunc() function that gets a Context instance as its first argument. The function will select reading the Done channel returned by the Context's Done() method. When the context is externally cancelled, the Done channel is closed, reading operation on the Done channel unblocks and allows the function to return. The recommended return value in this case is the error returned by ctx.Err().

// someFunc will return a context.Canceled error if the context was externally canceled
func someFunc(ctx context.Context, c <-chan interface{}) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err() // context.Canceled if the context was externally canceled 
		case <-c: // this will ensure the goroutine will block, as we will never write on that channel
		}
	}
}

The parent goroutine must use WithCancel() function to create a new derived context to be passed to functions executed on children goroutines. The function also returns a cancel function.

Alternatively, WithCancelCause() can be used. The function behaves like WithCancel() but returns a cancel function that allows specifying the error (the "cause") when cancelling the context, which can be retrieved using Cause().

To externally cancel the context, and implicitly all functions listening on its Done channel, invoke the cancel function:

ctx, cancel := context.WithCancel(context.Background())

// spin off someFunc() on its own thread and get it to block by reading on the channel we will never write on
go func() {
	err := someFunc(ctx, make(chan interface{}))
	fmt.Printf("someFunc() errored out because %v\n", err)
}()

// spin off the anonymous function that will cancel someFunc() after 5 seconds
go func() {
	time.Sleep(5 * time.Second)
	cancel() // Calling the cancel() function cancels the context
}()

Programmatic Timeout

The WithTimeout() function creates a new derived context that closes its Done channel after the given timeout. The Context error becomes context.DeadlineExceeded. The WithTimeout() invocation also returns a cancel function that can be used independently of the timeout.

someFunc() function gets a Context instance as its first argument. The function will select reading the Done channel returned by the Context's Done() method. When the context times out, the Done channel is closed, reading operation on the Done channel unblocks and allows the function to return. The recommended return value in this case is the error returned by ctx.Err(), which, in this case, is context.DeadlineExceeded. At the same time, the Context can be externally cancelled by inferring the cancel function, and in that case, the error will be context.Canceled.

// someFunc will return a context.DeadlineExceeded error if the context timed out.
func someFunc(ctx context.Context, c <-chan interface{}) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err() // context.DeadlineExceeded if the context timed out
		case <-c: // this will ensure the goroutine will block, as we will never write on that channel
		}
	}
}

The parent goroutine must use WithTimeout() function to create a new derived context to be passed to functions executed on children goroutines. The function also returns a cancel function that can be optionally used, independent of the timeout. Typically, the function is deferred.

Alternatively, WithTimeoutCause() function can be used. This function allows setting the error (the "cause") returned by the context with the timeout expires, instead of the default context.DeadlineExceeded.

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

// spin off someFunc() on its own thread and get it to block by reading on the channel we will never write on
go func() {
	err := someFunc(ctx, make(chan interface{}))
	fmt.Printf("someFunc() errored out because %v\n", err)
}()

Simple Timeout on a Channel

A simpler pattern to time out on a time channel is available here:

Timing-out select

Programmatic Deadline

The WithDeadline() function creates a new derived context that closes its Done channel when the machine's clock advances past the given deadline.

Storing Request-Scoped Data

To store the data in the context, create an immutable clone with context.WithValue().

The keys must satisfy Go's notion of comparability. The values must be safe to access concurrently.

// storing data in Context
ctx := context.WithValue(context.Background(), "auth-token", "a023k27l")
ctx = context.WithValue(ctx, "user-id", "a@example.com")
go someFunc(ctx)

To retrieve data from the context, use the Value() method:

func someFunc(ctx context.Context) {
	// retrieving data from Context
	fmt.Printf("auth-token: %v\n", ctx.Value("auth-token"))
	fmt.Printf("user-id:    %v\n", ctx.Value("user-id"))
}

Restoring Key/Value Type Safety for Request-Scoped Data

As described in Context Keys, Values and Type Safety section, the fact that the keys and values stored by the Context are declared as interface{} makes them lose the type safety. This technique restores the type safety.

Define an unexported custom key type in the package, usually an alias for int. All keys to be stored within the context must be values of that type. They can be declared as an enumeration, also package private (unexported). As long as other packages do the same, this prevents collision within the context, even if the underlying key instances are the same int values:

type contextKey int

const (
	userID contextKey = iota
	authToken
	traceID
)

func requestHandler(ctx context.Context) {
	fmt.Printf("userID:    %v\n", ctx.Value(userID))
	fmt.Printf("authToken: %v\n", ctx.Value(authToken))
	fmt.Printf("traceID:   %v\n", ctx.Value(traceID))
}

...

ctx := context.WithValue(context.Background(), userID, "a@example.com")
ctx = context.WithValue(ctx, authToken, "AB00BC")
ctx = context.WithValue(ctx, traceID, int64(100000))
go requestHandler(ctx)