Hacker News new | ask | show | jobs
by bheadmaster 522 days ago
Contexts in Go are generally used for convenience in request cancellation, but they're not required, and they're not the only way to do it. Under the hood, a context is just a channel that's closed on cancellation. The way it was done before contexts was pretty much the same:

    func CancellableOp(done chan error /* , args... */) {
        for {
            // ...

            // cancellable code:
            select {
                case <-something:
                    // ...
                case err := <-done:
                    // log error or whatever
            }
        }
    }
Some compare context "virus" to async virus in languages that bolt-on async runtime on top of sync syntax - but the main difference is you can compose context-aware code with context-oblivious code (by passing context.Background()), and vice versa with no problems. E.g. here's a context-aware wrapper for the standard `io.Reader` that is completely compatible with `io.Reader`:

    type ioContextReader struct {
        io.Reader
        ctx context.Context
    }

    func (rc ioContextReader) Read(p []byte) (n int, err error) {
        done := make(chan struct{})
        go func() {
            n, err = rc.Reader.Read(p)
            close(done)
        }()

        select {
        case <-rc.ctx.Done():
            return 0, rc.ctx.Err()
        case <-done:
            return n, err
        }
    }

    func main() {
        ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
        defer cancel()

        rc := ioContextReader{Reader: os.Stdin, ctx: ctx}

        // we can use rc in io.Copy as it is an io.Reader
        _, err := io.Copy(os.Stdout, rc)
        if err != nil {
            log.Println(err)
        }
    }
For io.ReadCloser, we could call `Close()` method when context exits, or even better, with `context.AfterFunc(ctx, rc.Close)`.

Contexts definitely have flaws - verbosity being the one I hate the most - but having them behave as ordinary values, just like errors, makes context-aware code more understandable and flexible.

And just like errors, having cancellation done automatically makes code more prone to errors. When you don't put "on-cancel" code, your code gets cancelled but doesn't clean up after itself. When you don't select on `ctx.Done()` your code doesn't get cancelled at all, making the bug more obvious.

4 comments

You are half right. A context also carries a deadline. This is important for those APIs which don't allow asynchronous cancellation but which do support timeouts as long as they are set up in advance. Indeed, your ContextReader is not safe to use in general, as io.ReadCloser does not specify the effect of concurrent calls to Close during Read. Not all implementations allow it, and even when they do tolerate it, they don't always guarantee that it interrupts Read.
This works, but goes against convention in that (from the context package docs) you shouldn’t “store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.”
It does seem an unnecessarily limiting convention.

What will go wrong if one stores a Context in a struct?

I've done so for a specific use case, and did not notice any issues.

This guidance is actually super important, as contexts are expected to be modified in a code flow and apply to all functions that are downstream of your current call stack.

If you store contexts on your structs it’s very likely you won’t thread them correctly, leading to errors like database code not properly handling transactions.

Actually super fragile and you should avoid doing this as much as is possible. It’s never a good idea!

What do you mean by "won't thread them correctly"?

The specific use case I had was where the context represented the lifetime of a (shared) TCP connection. I then wanted to use its cancellation to drive the destruction of various dynamic graph elements hanging off that shared connection.

Think a graph of muxes/demuxes in a dynamic message graph while the whole program is a CSP style thing.

I needed something to drive destruction, the context provided what I needed.

> What will go wrong if one stores a Context in a struct?

Contexts are about the dynamic contour, i.e. the dynamic call stack. Storing the current context in a struct and then referring to it in some other dynamic … context … is going to lead to all sorts of pain: timeouts or deadlines which have already expired and/or values which are no longer pertinent.

While there are some limited circumstances in which it may be appropriate, in general it is a very strong code smell. Any code which passes a context should receive a context. And any code which may pass a context in the future should receive one now, to preserve API compatibility. So any exported function really should have a context as its first argument for forwards-compatibility.

Except Contexts provide a cancellation mechanism which can be used outwith of a specific dynamic call graph.

The documented semantics of the facility (and the implementation) seemed perfect for destroying a dynamic graph of CSP elements, which were exchanging messages. Where the cancellation causes the goroutines within the (think Actor-like) CSP element to clean up, tear down, and exit.

The warning about not storing it in a struct seems to assume it can only be used for one specific type of purpose, say web server processes and/or related database requests.

If I hadn't used it, I would have had to create something almost identical - but without the ability to store a data element.

True. But this code is only proof-of-concept of how non-context-aware functions can be wrapped in a context. Such usage of context is not standard.
Consider this:

    ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
    reader := ioContextReader(ctx, r)
    ...
    ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
    ctx = context.WithValue(ctx, "hello", "world")
    ...
    func(ctx context.Context) {
        reader.Read() // does not time out after one second, does not contain hello/world.
        ...
    }(ctx)
There are two solutions, depending on your real use case:

1) You're calling Read() directly and don't need to use functions that strictly accept io.Reader - then just implement ReadContext:

    func (rc ioContextReader) ReadContext(ctx context.Context, p []byte) (n int, err error) {
        done := make(chan struct{})
        go func() {
            n, err = rc.Reader.Read(p)
            close(done)
        }()

        select {
        case <-ctx.Done():
            return 0, ctx.Err()
        case <-done:
            return n, err
        }
    }
Otherwise, just wrap the ioContextReader with another ioContextReader:

    reader = ioContextReader(ctx, r)
Changing the interface 1) is obviously not relevant.

Re-wrapping works only for the toy example. In the real world, the reader isn't some local variable, but there could be many, across different structs, behind private fields.

To cirle back, and not focus too much on the io.Reader example: the virality of ctx is real, and making wrapper structs is not a good solution. Updating stale references may not be possible, and would quickly become overwhelming. Not to forget the performance overhead.

Personally I think it's okay, go is fine as a "webservices" language. The go gospel is, You can have your cake and eat it too, but it's almost never true unless you twist the meaning of "cake" and "eat".

Of course not - you're not handling the context at all in the called function. What's there to consider, reader.Read() has no idea about your timeout and value store intent. How would it, telepathy?
You're spawning a goroutine per Read call? This is pretty bonkers inefficient, to start, and a super weird approach in any case...
Yes, but this is just proof of concept. For any given case, you can optimize your approach to your needs. E.g. single goroutine ReadCloser:

    type ioContextReadCloser struct {
        io.ReadCloser
        ctx context.Context

        ch chan *readReq
    }

    type readReq struct {
        p   []byte
        n   *int
        err *error
        m   sync.Mutex
    }

    func NewIoContextReadCloser(ctx context.Context, rc io.ReadCloser) *ioContextReadCloser {
        rcc := &ioContextReadCloser{
            ReadCloser: rc,
            ctx:        ctx,

            ch: make(chan *readReq),
        }
        go rcc.readLoop()
        return rcc
    }

    func (rcc *ioContextReadCloser) readLoop() {
        for {
            select {
            case <-rcc.ctx.Done():
                return
            case req := <-rcc.ch:
                *req.n, *req.err = rcc.ReadCloser.Read(req.p)
                if *req.err != nil {
                    req.m.Unlock()
                    return
                }
                req.m.Unlock()
            }
        }
    }

    func (rcc *ioContextReadCloser) Read(p []byte) (n int, err error) {
        req := &readReq{p: p, n: &n, err: &err}
        req.m.Lock() // use plain mutex as signalling for efficiency
        select {
        case <-rcc.ctx.Done():
            return 0, rcc.ctx.Err()
        case rcc.ch <- req:
        }
        req.m.Lock() // wait for readLoop to unlock
        return n, err
    }
Again, this is not to say this is the right way, only that it is possible and does not require any shenanigans that e.g. Python needs when dealing with when mixing sync & async, or even different async libraries.
I think you're missing the forest for the trees, here.

The io.Reader/Writer interfaces, and their implementations, are meant to provide a streaming model for reading and writing bytes, which is as efficient as reasonably possible, within the constraints of the core language.

If your goal is to make an io.Reader that respects a context.Context cancelation, then you can just do

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

    func NewContextReader(ctx context.Context, r io.Reader) *ContextReader {
        return &ContextReader{
            ctx: ctx,
            r:   r,
        }
    }
    
    func (cr *ContextReader) Read(p []byte) (int, error) {
        if err := cr.ctx.Err(); err != nil {
            return 0, err
        }
        return cr.r.Read(p)
    }
No goroutines or mutexes or whatever else required.

Extending to a ReadCloser is a simple exercise left to the, er, reader.

A mutex in a hot Read (or any IO) path isn’t efficient.
What would you suggest as an alternative?