Paul Di Gian
Paul Di Gian's Blog

Paul Di Gian's Blog

Advanced go - Make synchronous code asynchronous with context.Context and channels

Paul Di Gian's photo
Paul Di Gian
·Nov 7, 2021·

3 min read

Subscribe to my newsletter and never miss my upcoming articles

Go standard library comes with a nice little structure that allows to stop long-running goroutines, when theirs result is not needed anymore: context.Context.

If any of the call you are making, has the potential to block for a long amount of time, it is good practice to accept an extra parameter, the context.

However, sometimes, we don't have this luxury.

Sometimes we require using libraries that we cannot change, and that are not designed to use context.Context.

A very simple example is io.Read from the standard library.

If we are reading from a file, or from a network socket, the read call may take a long amount of time, and during the read the goroutine is blocked and, beside shutting down the software, there is nothing else you can do.

Suppose we are handling an HTTP request, and in order to create a response we need to read some data from either a file or the network.

The code may look a bit like this:

_ := request.Context() // this is goinf unused

r := io.SomeReader() // not important which reader
b := make([]byte, 1024)

n, err := r.Read(b)
if err != nil {
    return err
}

// use the buffer `b`

However, if the request is cancelled just before the Read call, or during it, we will still be using valuable resources executing the Read, while the system could be doing something more useful.

Fortunately in Go there is a simple pattern that allow to get around this limitation.

It involves a channel for the result, and the context we are monitoring.

ctx := request.Context()

r := io.SomeReader() // not important which reader
b := make([]byte, 1024)
type readResult struct {
    readBytes int
    err             error
}
resultCh := make(chan readResult)

go func() {
    if err := ctx.Err(); err != nil {
        return
    }
    n, err := r.Read(b)
    select {
        case <-ctx.Done():
            // do nothing
        case resultCh <- readResult{readBytes: n, err: err}:
            // do nothing
    }
}()

var result readResult
select {
    case <-ctx.Done():
        // do something in here
        // likely return from the function with an error
        return ctx.Err()
    case result <- resultCh:
        // great we go our result in time and we can move on
        // from now on, `b` is populated, with the read bytes
}

// here we can use `result` as `readResult` and the buffer `b`

The main point of this pattern is to wait, at the same time, for either the result of the blocking function, or for a context cancellation. If the context is cancelled, the result from the blocking function is not needed anymore, and we can gracefully and quickly shut down the code path and free valuable resources. If the result comes in time, we can move on with the default execution.

The pattern can be adapted in a thousand of different ways. In the specific example, you may want to return the buffer itself and the error.

But the main concept will stay the same.

Spawn another goroutine, that:

  1. Check if the context already expired
  2. If it is not expired, invoke the blocking function
  3. Push the result into a channel, if the context is not expired in the meantime

Unfortunately, there is no way, to communicate to the Read function that it is not necessary anymore, so the function will eventually be executed and its result thrown away. But we did not wait for it, freeing up run time resources and improving the overall latency.

If the problem becomes really important, you must find other ways to avoid the Read call or to interrupt it.

For instance, if you are communicating with a TCP socket, you might use a SetReadDeadline or a SetWriteDeadline

If you are reading from files, then it gets more complicated, but, if you really need it, there are ways around it.

 
Share this