Context-aware io.Reader for Go.

Mat Ryer · David Hernandez · 3 Feb 2020

Context-aware io.Reader for Go.

Mat Ryer · David Hernandez · 3 Feb 2020

Context

Following a recent conversation with Jon Calhoun, we became interested in cancelling potentially long-running copy operaitons if the context was cancelled, like if a browser cancels an HTTP request.

Context (literally the context.Context type) has taken hold in Go; visible in the client libraries you use, in the database connections we need, and probably through many layers of your own code in between.

My favourite use of context.Context is to allow for cancellation. Sometimes it’s fairly easy to see the benefit of aborting something. If you’re processing a list of items ready for a response to an HTTP request, and the user clicks away (the request should in theory get cancelled) then why not stop iterating through that list and let the CPU do something else? In some cases it won’t make much difference but it’s worth familiarising yourself with the pattern.

The net/http package became context-aware in Go 1.5 with the ctx := r.Context() and r = r.WithContext(context.Context) methods to respectively get and use the context with http.Requests.

But in the real world we live alongside older (more traditional? simpler?) designs such as those powerful old types in the io package, namely io.Reader and io.Writer.

How cancellation with context works

context.Context is an object that you pass through your functions, through your layers of code, which can act as a signal to the whole chain that we want to cancel the work.

If you get the context from the http.Request via the r.Context() method, and the user clicks away while the request is processing, the context will be cancelled.

You can wire up your main programs to cancel a context when the user hits Ctrl+C. (If they hit Ctrl+C a second time, you should just exit because otherwise your program will be annoying if it gets stuck somewhere).

When a context is cancelled, calling ctx.Err() will return a special sentinal error context.Canceled (notice the US English spelling), which indicates that the context has been cancelled. Similarily, if a context times out, context.DeadlineExceeded is returned.

Time outs

As well as explicit cancellation, it’s possible to get a context that cancels itself after a specific duration.

ctx, cancel := context.WithTimeout(ctx, 1 * time.Second)
defer cancel()
err := doSomethingQuickly(ctx)

In this example we use context.WithTimeout to get a new context that will cancel itself after one second. If doSomethingQuiclky doesn’t finish within a second, the context will be cancelled and the operation aborted.

Why did it stop?

You can compare the err to these values to find out what happened.

if err := ctx.Err(); err != nil {
	// time to stop... but why...?
	switch err {
	case context.Canceled:
		// context was cancelled
	case context.DeadlineExceeded:
		// context timed out
	}
}

If the context was cancelled, the error will be context.Canceled, if it timed out, it will be context.DeadlineExceeded.

ctx.Done for concurrent situations

For code with concurrency designs, the ctx.Done() channel gets closed when the context is cancelled, allowing you to use it in a select block:

select {
case <-ctx.Done():
	// context was cancelled
	switch ctx.Err() {
	case context.Canceled:
		// context was cancelled
	case context.DeadlineExceeded:
		// context timed out
	}
	return // stop processing
default:
	// do the next piece of work
}

Cancelling an io.Reader

During a conversation with Jon Calhoun where we were talking about how io.Reader doesn’t use context.Context in its Read method:

Read(p []byte) (n int, err error)

You might be forgiven for thinking that cancelling readers (for example, cancelling io.Copy) is a lost cause, and we should just wait until they add context to all the fundamental interfaces of the standard library (spoiler alert: they aren’t going to do that).

But notice that the Read method returns an error, and since we can wrap the Read method with our own, we can write one that is context-aware.

Writing a context-aware io.Reader

We can write a method called Reader which wraps another io.Reader taking in a context.Context.

package ctxio

import (
	"context"
	"io"
)

type readerCtx struct {
	ctx	context.Context
	r   io.Reader
}

func (r *readerCtx) Read(p []byte) (n int, err error) {
	if err := r.ctx.Err(); err != nil {
		return 0, err
	}
	return r.r.Read(p)
}

// NewReader gets a context-aware io.Reader.
func NewReader(ctx context.Context, r io.Reader) io.Reader {
	return &readerCtx{
		ctx: ctx, 
		r: r,
	}
}

When Read gets called it first checks to see if the context is finished or not, and only falling through to reading if ctx.Err returns nil.

Note this code is inside a package called ctxio so the API would be ctxio.NewReader, which is optional - you could just create the helper function near to where you need it.

Using the cancellable reader to cancel io.Copy via context

We might use this reader when we’re proxying a file for download through a web request:

func handleDownload(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	f, err := os.Open("/path/to/absolutely/massive/file.yml")
	if err != nil {
		http.Error(w, err.Error(), http.InternalServerError)
		return
	}
	defer f.Close()
	_, err = io.Copy(w, ctxio.NewReader(ctx, f))
	if err != nil {
		switch err {
		case context.Canceled:
			// canceled by user
		case context.DeadlineExceeded:
			// timed out
		default:
			// some other error
		}
	}
}

Since we wrap the io.Reader (in this case an *os.File) with ctxio.Reader, we know that the context error (ctx.Err()) will be checked on each call to Read (which happens multiple times inside io.Copy).

If the *http.Request is cancelled, we can abort the copy and give some resources back.

contextio package

Olivier Mengué (@omengue) has already put these ideas into a package which is available at github.com/dolmen-go/contextio if you’re into that sort of thing.


Learn more about what we're doing at Pace.

A lot of our blog posts come out of the technical work behind a project we're working on called Pace.

We were frustrated by communication and project management tools that interrupt your flow and overly complicated workflows turn simple tasks, hard. So we decided to build Pace.

Pace is a new minimalist project management tool for tech teams. We promote asynchronous communication by default, while allowing for those times when you really need to chat.

We shift the way work is assigned by allowing only self-assignment, creating a more empowered team and protecting the attention and focus of devs.

We're currently live and would love you to try it and share your opinions on what project management tools should and shouldn't do.

What next? Start your 14 day free trial to see if Pace is right for your team


First published on 3 Feb 2020 by Mat Ryer David Hernandez
#Tech #Golang

or you can share the URL directly:

https://pace.dev/blog/2020/02/03/context-aware-ioreader-for-golang-by-mat-ryer.html

Thank you, we don't do ads so we rely on you to spread the word.

https://pace.dev/blog/2020/02/03/context-aware-ioreader-for-golang-by-mat-ryer.html


You might also like:

Why you shouldn't use func main in Go #Golang #Patterns

Pace July update: 5 new features #launch #features #update #projectmanagement

Batching operations in Go #Golang #Patterns

Subscribe:
Atom RSS JSON