Now, let's dive into context cancellation. Context package in go allows you to do this easily, both manually and within a configured timeframe. Let's see how to cancel a context in Golang.
Why do you need context cancellation?
Context cancellation: sometimes, you must stop the running actions because you no longer require their results. There can be multiple reasons, such as an error that makes all the subsequent computations meaningless, or it can be that a user requested to cancel the job we are doing.
Imagine that you have a web service that uses an external API to process images. The usual flow is the following:
The user uploads their image to your service.
You make a request for an external service.
You wait for the response from that external service.
You show the successful result or an error to the user in your beautiful UI.
But consider the following scenario:
The user uploads their image to our service.
You make a request for an external service.
The user clicks "abort" in your UI for whatever reason.
What should you do in this case with your request to the external service? The response is now useless because you don't need to show anything to the user. Waiting for the response in this simplistic scenario is just wasting resources.
Listening to cancellation events
Prior to canceling contexts, let's see what differentiates a canceled context from a non-canceled one.
The Context type provides a method called Done which returns a channel of type <-chan struct{}. This channel is a read-only channel that can only emit empty structs. It is not used to send any form of data, but rather it is used to send a cancellation signal.
But how can an empty struct constitute a signal if it has no data? Well, an empty struct is a signal!
No message is sent to the Done channel until the channel is closed, meaning that if we try to read from it, we'll be blocked. It also means that we can safely use it in select a statement reading from other channels until the context is closed.
Here is the easiest way to block some execution until the context is closed:
func waitForCancellation(ctx context.Context) error {
// as soon as no data is sent to the channel prior to cancellation
// the code will be blocked until the channel is closed
<-ctx.Done()
// this part of the code will be blocked until the context is canceled
// here we can do something we would like to do after the cancellation
// like cleaning up resources or deleting temporary files
fmt.Println("the context is canceled")
}In practice, cancellation listening usually happens in select statement:
func waitForCancellationWithSelect(ctx context.Context) error {
for {
select {
case <-ctx.Done(): // a closed Done() channel starts emitting empty structs
return // so as soon as it's closed, we can indefinitely read from it
default:
// we can be doing some useful work here
}
}
}How do we cancel the context?
The context package provides the WithCancel function with the following signature:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)This function accepts a parent context and, unlike WithValue returns two values: derived context and cancellation function of type CancelFunc which is a function type defined as
type CancelFunc func()As you can see, this function accepts no arguments and returns no values. The only thing it does is close the channel of that derived context. Let's see what it looks like in action:
At first, let's create a function that will inevitably fail:
// this function will just fail in 5 seconds and return a non-nil error
func doSomethingAndFail() error {
time.Sleep(time.Second * 5)
// we can be doing other useful things here
return errors.New("something went wrong")
}And here is what it looks like in our main function:
// we derive our context from background context
// and get a convinient cancelProcessing function as a result
// which will cancel our context when called
processingCtx, cancelProcessing := context.WithCancel(context.Background())
// here we run a goroutine which will cancel the context if our function fails.
go func() {
if err := doSomethingAndFail(); err != nil {
cancelProcessing()
}
}()
// select statement will block until either the provided timeout is reached
// or until the context is canceled
select {
case <-time.After(time.Second * 5):
fmt.Println("timed out")
case <-processingCtx.Done():
fmt.Println("the context was canceled")
}If you run the code above, you should see the context was canceled in your console
Cancellation with defer
Another thing possible to do with cancellation functions is canceling them with defer statement. Most of the time, this is the way we cancel the context.
Let's add one more detail to our code:
processingCtx, cancelProcessing := context.WithCancel(context.Background())
// here we defer the context cancellation
defer cancelProcessing()We typically invoke the cancel function within the same scope where we created the context with cancel. This practice allows us to terminate all initiated or 'spawned' operations as soon as we have concluded our process. Why is this significant, you ask?
Well, consider those scenarios where we set in motion multiple goroutines originating from the very same function, and we're sitting there, eagerly awaiting the results. Let's imagine for a second that we are doing computations not in main, but in some other function. In these situations, having a cancellation mechanism becomes vital.
By adhering to this standard, we ensure that resources are not wasted on unnecessary computations and that our software behaves in a predictable and manageable manner. It's like having a hidden superpower that maintains efficiency and ensures your Go code is always running at its best!
How do we cancel the context with a cause?
The context package also provides another way of canceling context:
func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc)It looks similar to WithCancel, but returns not CancelFunc, but CancelCauseFunc instead. It accepts a single parameter cause error, which allows us to provide some information as to why our context was canceled.
type CancelCauseFunc func(cause error)The context package documentation explains this difference clearly: a CancelCauseFunc behaves like a CancelFunc but additionally sets the cancellation cause. This cause can be retrieved by calling Cause on the canceled Context or on any of its derived Contexts.
Let's modify our example to provide a cause to our CancelCauseFunc:
processingCtx, cancelProcessingWithCause := context.WithCancelCause(context.Background())
go func() {
if err := doSomethingAndFail(); err != nil {
// this is the main difference!
// we provide a cause when calling a cancel function
cancelProcessingWithCause(errors.New("the function returned a non-nil error"))
}
}()
select {
case <-time.After(time.Second * 5):
fmt.Println("timed out")
case <-processingCtx.Done():
fmt.Println("the context was canceled")
// note that err and cause are two different things!
fmt.Println("Err:", processingCtx.Err())
fmt.Println("Cause:", context.Cause(processingCtx))
}The output should be the following:
the context was canceled
Err: context canceled
Cause: the function returned a non-nil errorNote the difference between the Err and the context.Cause:
Err can return just one of two errors defined in the context package:
context.Canceledif the context was canceled.context.DeadlineExceededif the context's deadline passed.
context.Cause returns the cause if it is provided, otherwise, it returns the same error as Err
Note that Cause method and function CancelCauseFunc are new features, and they were introduced in go1.20
How do we cancel the context with timeout?
The context package also provides functions for the time-based cancellation of contexts.
The first one's signature looks similar to WithCancel's signature:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)The context derived from this function will be canceled at the time provided. If a parent has a deadline and it is earlier than the one you provide, both the parent and derived context will be canceled at the parent's deadline. And, of course, it does not work the other way around.
Note that WithDeadline also returns CancelFunc, just like WithCancel, which means that if needed, we can cancel this context before the deadline.
Here is how you set a deadline for context:
ctx, cancel := context.WithDeadline(ctx, time.Date(2038, time.January, 3, 14, 8, 0, 0, time.UTC))WithTimeout function is pretty similar to WithDeadline, but even judging from its name, it's clear that instead of a Deadline, you specify a timeout:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)In fact, it just returns WithDeadline(parent, time.Now().Add(timeout))
Let's rewrite our example one more time:
// we don't need to cancel the function manually
// hence the underscore
processingCtx, _ := context.WithTimeout(context.Background(), time.Second*2)
go func() {
doSomethingAndFail()
// this line will never be printed because the context will have been canceled earlier
fmt.Println("finished doing and failing!")
}()
select {
case <-time.After(time.Second * 5):
// this line will never be printed because the context will have been canceled earlier
fmt.Println("timed out")
case <-processingCtx.Done():
fmt.Println("the context was canceled")
fmt.Println("Err:", processingCtx.Err())
fmt.Println("Cause:", context.Cause(processingCtx))
}The output should be the following:
the context was canceled
Err: context deadline exceeded
Cause: context deadline exceededCancellation propagation
The simple rule is this: when a parent context is canceled for whatever reason, all its derived contexts get automatically canceled too.
And it does not work the other way around: when a child context is canceled, the parent will be okay.
Almost the same example as before, but this time we'll see how propagation works:
processingCtx, cancelProcessing := context.WithCancel(context.Background())
deadline := time.Date(2038, time.January, 3, 14, 8, 0, 0, time.UTC)
processingCtxWithDeadline, _ := context.WithDeadline(processingCtx, deadline)
go func() {
if err := doSomethingAndFail(); err != nil {
cancelProcessing()
}
}()
select {
case <-time.After(time.Second * 5):
fmt.Println("timed out")
// even though the deadline is not met yet, this context will be canceled by cancelProcessing func
// it happens because cancelProcessing cancels processingCtxWithDedaline's parent
// and that makes it automatically canceled too
case <-processingCtxWithDeadline.Done():
fmt.Println("the context was canceled")
fmt.Println("Err:", processingCtx.Err())
fmt.Println("Cause:", context.Cause(processingCtx))
}Conclusion
So, what have we learned?
We need cancellation because some events may disrupt others, and we don't want to do meaningless computations.
The
Donechannel is used for listening to cancellation events. We can determine that the context is canceled when this channel is closed.The
WithCancelfunction allows developers to manually cancel the context withCancelFuncThe
WithCancelCausefunction provides an extra level of detail during cancellation events by allowing a cause for the cancellation to be provided toCancelCauseFunc, crucial for efficient debugging and event handling.The package also offers time-based cancellation through
WithDeadlineandWithTimeoutfunctions.The difference between
WithDeadlineandWithTimeoutis that we provide concrete point in time toWithDeadlineand a timeout toWithTimeout.