DEV Community

Cover image for Beginners' guide to Go Contexts: The Magic Controller of Goroutines
Debdut Chakraborty for Rocket.Chat

Posted on

Beginners' guide to Go Contexts: The Magic Controller of Goroutines

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   
    }  
}
Enter fullscreen mode Exit fullscreen mode

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())  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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  
    }()  
}
Enter fullscreen mode Exit fullscreen mode

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  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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)  
}
Enter fullscreen mode Exit fullscreen mode

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 the ctx.Done() channel. It tells every function using that context: "The party is over, stop whatever you are doing."

Top comments (0)