7 ms·
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 co
by bheadmaster 2y 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.
- bryancoxwell 2y agoThis 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.”
- bheadmaster 2y agoTrue. 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.
- dfawcus 2y agoIt 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.
- lawrjone 2y agoThis 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!
- dfawcus 2y agoWhat 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.
- eadmund 2y ago> 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.
- kiitos 2y agoYou're spawning a goroutine per Read call? This is pretty bonkers inefficient, to start, and a super weird approach in any case...
- bheadmaster 2y agoYes, 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.
- orf 2y agoA mutex in a hot Read (or any IO) path isn’t efficient.
- bheadmaster 2y agoWhat would you suggest as an alternative?
- the_gipsy 2y agoConsider 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)
- bheadmaster 2y agoThere 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)
- the_gipsy 2y agoChanging 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".
- rad_gruchalski 2y agoOf 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?
- kbolino 2y agoYou 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.