We've all used contexts, usually by passing them to functions that require them, like HTTP handlers or database queries. But what exactly are contexts, and how do they work under the hood?
In Go, a Context is essentially a signal. It travels through your functions to tell them when they should stop working because the data is no longer needed.
The Basic Check
The most fundamental way to use a context is to check its state manually. This is perfect for long-running loops or heavy calculations. If a function is a "one-off" and finishes instantly, a context doesn't add much value.
However, for a loop like this:
func process(ctx context.Context) {
for i := range 1000000 {
// check if the signal says we should stop
if err := ctx.Err(); err != nil {
fmt.Println("stopping early:", err)
return
}
// simulate some work
_ = i
}
}
If we didn't have that if err := ctx.Err() check, the goroutine would keep spinning even if the user who started it has already disconnected or timed out.
Powering up with Select
While checking ctx.Err() works for loops, the real magic happens with the select statement. This is how you make a goroutine "listen" for a cancellation signal while it is busy doing something else, like waiting for a channel.
Waiting for a result
Imagine you are fetching data from a slow API. You want the data, but you aren't willing to wait forever.
func fetch(ctx context.Context) {
resultCh := make(chan string)
go func() {
time.Sleep(5 * time.Second) // simulate a slow task
resultCh <- "got the data!"
}()
select {
case res := <-resultCh:
fmt.Println("received:", res)
case <-ctx.Done():
// ctx.Done() is a channel that closes when the context is cancelled
fmt.Println("gave up waiting:", ctx.Err())
}
}
By using select, your code becomes responsive. The moment the context expires, the <-ctx.Done() case triggers, and your function can exit immediately instead of hanging for the full 5 seconds.
Layered Control
Contexts are designed to be passed down. If you create a "child" context from a "parent," and the parent is cancelled, all the children are cancelled too. This lets you stop an entire tree of goroutines from one single place.
func run(ctx context.Context) {
// create a child context we can cancel manually
ctx, cancel := context.WithCancel(ctx)
go process(ctx) // this starts the loop from earlier
// simulate another part of the app failing
go func() {
time.Sleep(2 * time.Second)
fmt.Println("something else failed!")
cancel() // this kills the 'process' goroutine too
}()
}
Making Existing Code Context-Aware
You might have a library or an old function that doesn't support contexts yet. How do you "wrap" it so it respects a timeout?
The trick is to run the old code in a separate goroutine and use a select statement to wait for either the result or the context signal.
func ContextAwareWrapper(ctx context.Context, data string) (string, error) {
resultCh := make(chan string, 1)
go func() {
// call the old, non-context-aware function
resultCh <- OldLegacyFunction(data)
}()
select {
case <-ctx.Done():
// if the context expires first, we return an error
return "", ctx.Err()
case res := <-resultCh:
// if the work finishes first, we return the result
return res, nil
}
}
Note: Using a buffered channel (
make(chan string, 1)) is important here. It ensures that if the context times out and we exit the function, the goroutine still running the OldLegacyFunction can send its result to the channel and exit without getting stuck forever (a goroutine leak).
The Importance of Cancel and Defer
Whenever you use context.WithCancel, WithTimeout, or WithDeadline, the standard library gives you back a new context and a cancel function.
You must call that cancel function.
Even if your function finishes successfully, you should call it. The best way to do this is with defer.
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// this ensures that when main finishes, the context is cleaned up
defer cancel()
doWork(ctx)
}
Why is this important?
-
Resource Cleanup: Behind the scenes, the parent context keeps track of its children. If you don't call
cancel, the parent might keep a reference to the child in memory until the parent itself dies, leading to a memory leak. -
Stop Ongoing Work: Calling
cancel()sends the signal through thectx.Done()channel. It tells every function using that context: "The party is over, stop whatever you are doing."
Top comments (0)