Golang Context

Checking about how to cancel requests I stepped onto the context package in golang.

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

type Context interface {

    Deadline() (deadline time.Time, ok bool)
    Done() <- chan struct{}
    Err() error
    Value(key interface{}) interface{}


}

Context allows to cancel any work that is being done using timeouts or by external signals. This cancellation can be propagated to any function or goroutine that is using the context.

Context manages a tree like structure having a root context and creating derived contexts based on the root context when you execute functions: WithCancel, WithDeadline and WithTimeout. Each function also returns a CancelFunc which returns a new Done channel that is closed when the parent context’s Done channel is closed. In other words, when a Context is canceled, all Contexts derived from it are also canceled. It’s important that any cancel function returned from a derived context is executed before that function returns. Not doing this will cause memory leaks in your program.

Context per se doesn’t have a cancel method:

A Context does not have a Cancel method for the same reason the Done channel is receive-only: the function receiving a cancelation signal is usually not the one that sends the signal. In particular, when a parent operation starts goroutines for sub-operations, those sub-operations should not be able to cancel the parent. Instead, the WithCancel function provides a way to cancel a new Context value.

The main difference between WithCancel and WithDeadline/WithTimeout is that any cancellation made by the client will throw a context cancelled, but if the time expires then it will be context deadline exceeded. Context cancelled is always cause by an upstream client, while deadline could be defined in one of the child functions.

The Context package also provides WithValue. This function allows to return a copy of the parent Context with an associated value. This value MUST be a comparable key of their own type. DO NOT USE concrete values. It’s only recommended to store request-scoped values. DO NOT store parameters that the function require to do its work.

The context.Background returns a non-nil, empty Context. It is never canceled, has no values, and has no deadline. It’s used as initialization and considered the root Context.

Best practices

  • Incoming requests to a server should create a context.
  • Outgoing calls to servers should accept a context.
  • Do not store contexts inside a struct type.
  • The chain of cuntion calls between them must propagate the context.
  • Replace a context using WithCancel, WithDeadline, WithTimeout, WithValue
  • When a context is cancelled, all contexts derived from it are also canceled.
  • Do not pass a nil context. Pass a context.TODO if you are unsure about which context to use.
  • Use context values only for request-scoped data that transits processes and api’s, not for passing optional parameters to functions.
  • Debugging or tracing data is safe to pass in a context.

Some code

$cat main.go
package main

import (
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func main() {
    router := httprouter.New()
    router.GET("/context/cancel/:delay", hedgedFunc)
    router.GET("/context/cancel", hedgedFunc)
    router.GET("/context/timeout/:delay", timeoutFunc)
    router.GET("/context/timeout", timeoutFunc)
    http.ListenAndServe(":8080", uuidMiddleware(router))
}

The above code shows all the routes for the application. After running this code, you can call it:

$curl localhost:8080/context/cancel         #default delay of 50ms
$curl localhost:8080/context/cance/100      #delay of 100ms
$curl localhost:8080/context/timeout        #default delay of 50ms
$curl localhost:8080/context/timeout/500    #delay of 500ms

hedgedFunc() triggers hedged requests cancelling all the other goroutines after one of the requests finishes. This function presents the context.WithCancel() logic.

timeoutFunc makes requests to a set of urls in sequential order. If the function does not finish in the desire timeout, it cancels the context exiting finishing the execution. This function presents the context.WithTimeoutlogic.

uuidMiddleware() provides a middleware to add a request-scoped UUID to identify the request using context values. This function presents the context.WithValue logic.

Finally, all these contexts examples are derivative of http.Request.Context which is the root context for the request.

$cat hedged.go
package main

import (
    "context"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    neturl "net/url"
    "strconv"
    "time"

    uuid "github.com/google/uuid"
    "github.com/julienschmidt/httprouter"
)

var urls = []string{
    "http://www.example.com",
    "http://blog.bitclvx.com",
}
var timeout time.Duration

func getDelay(p httprouter.Params) (time.Duration, error) {
    if p.ByName("delay") != "" {
        delay, err := strconv.Atoi(p.ByName("delay"))
        if err != nil {
            return 0, err
        }
        return time.Duration(delay), nil
    }
    return time.Duration(50), nil
}

func hedgedFunc(w http.ResponseWriter, r *http.Request, param httprouter.Params) {
    delay, err := getDelay(param)
    if err != nil {
        w.WriteHeader(http.StatusNotFound)
        return
    }
    ch := make(chan string, len(urls))
    ctx, cancel := context.WithCancel(r.Context())
    defer cancel()
    for _, url := range urls {
        go func(u string, c chan string) {
            c <- executeQueryWithContext(u, ctx)
        }(url, ch)

        select {
        case result := <-ch: //if channel returns, cancel context, and return results
            fmt.Fprint(w, result)
            cancel()
        case <-time.After(delay * time.Millisecond): //wait delay before making another request
        }
    }
}

func executeQueryWithContext(url string, ctx context.Context) string {
    start := time.Now()
    parsedUrl, _ := neturl.Parse(url)
    req := &http.Request{URL: parsedUrl}
    req = req.WithContext(ctx) //execute query with context.

    response, err := http.DefaultClient.Do(req)

    if err != nil {
        fmt.Println(err.Error())
        return err.Error()
    }

    defer response.Body.Close()
    body, _ := ioutil.ReadAll(response.Body)
    log.Printf("[%v] - Request time: %d ms from url%s\n", ctx.Value("uuid"), time.Since(start).Nanoseconds()/time.Millisecond.Nanoseconds(), url)
    return fmt.Sprintf("%s from %s", body, url)
}

func timeoutFunc(w http.ResponseWriter, r *http.Request, param httprouter.Params) {
    timeout, err := getDelay(param)
    if err != nil {
        w.WriteHeader(http.StatusNotFound)
        return
    }
    ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Millisecond)
    defer cancel()
    for _, url := range urls {
        executeQueryWithContext(url, ctx)
    }
    return
}

func uuidMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            uuid := uuid.New()
            r = r.WithContext(context.WithValue(r.Context(), "uuid", uuid))
            next.ServeHTTP(w, r)
        })
}

Bibliography

Tags golang context goroutine cancel
Luis Michael Ibarra
I build, break, fix, and run stuff professionaly