This issue is intended to cover ideas about updating the context package for Go 2.
ctx context.Context
.Context.WithValue
function accepts values of any types, which is easy to misuse by passing, say, a string rather than a value of some package-local type.Context
is confusing to some people, since the main use of contexts is cancelation of goroutines.See also #24050 and #20280.
See also #27982.
I would add to that list:
context.WithValue
(intentionally) obscures information about the actual inputs required by a function; for example, because those inputs are required by some method of some other parameter. If we're getting real parametricity, it would be preferable to make those requirements explicit at compile time.The name Context is confusing to some people, since the main use of contexts is cancelation of goroutines.
Related #28017
I have struggled with performance issues in the past with package context, which were tied somewhat to the API. This led to one hack fix and contemplation of some ugly compiler changes (#18493). I’d like any re-think of context to see how lightweight we can make the package so that it can be used in very fine-grained contexts.
/cc @rogpeppe who I think had some ideas about the design of the API when it was first introduced.
See also #28279.
Perhaps have go
return a one-buffer channel. Closing the channel would cancel the goroutine, and we could have semantics similar to #27982 to check for cancellation. Reading from the channel would read the value that the function returns.
The problem with this, is that there is no way to read multiple return values, which is a pretty big issue. Not quite sure about an elegant way to solve this without having tuple types built-in to the language.
Writing to the channel would unfortunately write to the channel, but if you do stupid things, you should expect stupid behavior. It doesn't have any real use except for breaking your own code. (And we can't have it return a read-only channel if we want to be able to close
it)
Context of course would still exist, but it's primary purpose would be for goroutine-local values, rather than cancellation.
I feel that Context
only partly solved the problem of cancellation, since there is no facility to wait until the thing we're cancelling, has been cancelled (e.g. wait for the cancelled goroutine to stop). I built a "stopper" for this use case: https://github.com/function61/gokit/blob/dc75639388d554c7f79ca7ec6c967436b6c8301c/stopper/stopper_test.go (disclaimer: I should've made "stopper" a hybrid composed of context - my design is far from good and I plan to improve it)
I like this comment: https://github.com/golang/go/issues/21355#issuecomment-324816938
A somewhat less invasive way to reduce the syntactic overhead of
Context
would be to make it a built-in type (likeerror
).That would at least cut the stutter by a third:
-func Foo(ctx context.Context) error { +func Foo(ctx context) error {
I think my only issue with it is that functions that take contexts also typically have a Context
in the function name, ie FooContext(ctx context.Context) error
would become FooContext(ctx context) error
, and we don't save quite as much as we think we do. Either way cutting off the redundancy is nice.
@deanveloper any example of a FooContext function that actually have 'context' in the name? I haven't seen any of that myself. But context
does spread all the way deep into call hierarchy which is sort of horrifying.
Partially copying my reply in #20280
As someone who has been writing Go for two months. I never really understood context
, or had to understand it, even though I see it everywhere and use it everywhere, as required by the code standard to pass it down in call hierarchy as the first argument. Today I am refactoring some code that uses it, and wonder if I can get it removed, so I figured out what context
is for for the first time.
It is not immediate obvious for a beginner to understand the purpose context
is used for, or to understand that IO would like to use context for cancellation, instead of something else, or that context is a primary mechanism of cancellation at all...
It's simply not self-explanatory enough, and I don't think the terminology is consistent for a developer who also deals with concepts with the same name in a different language. e.g. writing C++ at the same time, I would think Context has server-lifetime instead of request-lifetime.
I think that makes it worthwhile for us to carefully explain what it is to help engineers / new learners understand, and make the package itself as clear as possible. e.g. If it is for request-scoped info, why is it package not named request/context
, or maybe it should be concurrency/context
? Or if it is really used in conjunction with goroutine, how come it is not part of the language feature?
[The part where I don't know what I am talking about]
I feel like some of the concepts were borrowed from the web, where the functionalities, e.g. canceling an asynchronous operation might be realized by a browser API, i.e. to implement the behavior on call stack alone might not be sufficient, and require invention like this which gets a bit confusing. If that's the reality, we should state the facts very clearly and be crystal clear about the limitation and potential confusions.
@deanveloper any example of a FooContext function that actually have 'context' in the name? I haven't seen any of that myself. But context does spread all the way deep into call hierarchy which is sort of horrifying.
Here are a few examples off the top of my head, you can probably make a grep
which could find all instances
Edit - Here's a third-party lib that I've used as well https://godoc.org/github.com/nlopes/slack
@deanveloper oh this has to do with providing two versions of everything that either use context or not, as Go doesn't support overloading? Yeh that might need to be solved at a different level :|
I think not supporting overloading is actually a good thing. Makes sure that code stays readable and encourages writing multiple functions for different actions (rather than using something like a boolean flag). Although it does lead to cases like this, unfortunately.
I don't think it has anything to do with overloading, rather I think it is so because the context package was introduced fairly recently, and due to Go's backwards compatibility there has to be separate mechanisms for incrementally improving code (DialContext() as in different function, or a new context field like in net/http). If the context package was there from the start, there would probably be just one net.Dial() that takes context as first arg.
Correct, but if Go did support function overloading, then a Dial
function could have been written with a context as the first argument and not broken compatability.
It was a combination of adding features in later as well as having no support for overloading.
@deanveloper or library writers can adopt a currying approach https://golang.org/pkg/net/http/#Request.WithContext
I also meant that having 'Foo' and 'FooContext' doesn't have to do with overloading, but it's just something library writers have to deal with, rather than something that would impact any decision regarding context itself here.
@dayfine
It's simply not self-explanatory enough, and I don't think the terminology is consistent for a developer who also deals with concepts with the same name in a different language. e.g. writing C++ at the same time, I would think Context has server-lifetime instead of request-lifetime.
I agree. I think this is due to context
being a combination of two completely separate concepts - WithValue
aka the real "context" and cancellation. There're several blog posts written about this problem and the solution I would like Go to pursue is to split these concepts into separate types. Ideally I would like to WithValue
to go away completely and leave only cancellation part of the context
. After that it can be renamed to clearly state what it's for. We can look at CancellationToken
from C# as an example.
Standard library have no use for WithValue
at all. It should only accept cancellation tokens. Passing arbitrary values between calls might be useful but it should be limited to outside code. I don't even remember any standard library function that reads values from context
. Is there any?
@creker
I don't even remember any standard library function that reads values from context. Is there any?
net/http
is one such package. It inspects client request contexts for net/http/httptrace
hooks, and calls them.
If we drop WithValue
from context, what could be the transport for layers of middlewares?
The point isn't to drop WithValue from context
, but rather to split context
into two separate ideas.
As far as I see it, context
is 2 things:
Now, as far as cancellation goes, I believe that really belongs somehow connected to the thing that just might get cancelled. You can always easily cancel from within a go-routine (that's just a normal error return), but, today, you have to use context
to cancel from outside a go-routine. The canonical place to put a cancel function, thusly, is something like:
cancel := go doSomething()
// if we for some reason want to not do that thing, then
cancel()
Note: I would relatedly love a timeout added to sync.WaitGroup
, but that's an issue for another issue.
As for a global store of variables, I think this only happened because context
must be passed in to everywhere. If you really want to have a set of values that go everywhere with you, that's a nice sort of thing to construct intentionally. net/http
uses the values, but it doesn't need to. It could have its own struct for this that's not awkwardly tied to other packages.
It's also slightly more: runtime/pprof.Do introduces labels (key, value string) which are context bound. Effectively it might be WithValue underneath (didn't check source), but it's use case is distinctly different than WithValue.
I think something along the lines of #21355 should be the way to go here:
Contexts pass values through intermediate layers of the call stack. Cancellation is one such value (in the form of the Done channel), but is not the only one and is not particularly different from any other value plumbed in this fashion.
The problem contexts aim to solve is not that of passing parameters to a function, but of passing parameters through a function that does not care about them.
The key point here being that the context could be retrieved from a stack frame (e.g. a stack frame should hold the context information). To expand a bit more concretely: runtime.Frame should have a Context field, and the runtime package api can be extended to work with the frame information in various ways:
The semantics around context this way stay the same, as the context is only passed through the stack depth, but implicitly instead of explicitly via function APIs. When the express purpose of the context is cancellation, this doesn't mean that all the function of the call stack need to be context aware, as was already talked about.
To add a few other opinions:
context.Context
not context
due to existing package practices (e.g. time.Time)As for a global store of variables, I think this only happened because context must be passed in to everywhere.
It sadly makes passing interface{}
through anything, to anything, incredibly easy.
I do wish restricting or eliminating that side effect was feasible, unfortunately its usage as a global parameter bag in the wild is rampant.
Typemap
in WithValue
like rust does with coming generic
, https://github.com/reem/rust-typemap
@ianlancetaylor wrote:
- Context values are passed everywhere explicitly, which troubles some people. Some explicitness is clearly good, but can we make it simpler?
Most of the function/method calls I do nowadays have as first parameter ctx
and so ctx
is part of most of the frames on the call stacks I typically see.
=> Thought experiment: Why not make ctx
an implicit part of the Go 2 call stack?
Implications:
main()
would implicitly contain ctx := context.Background()
, which is just context.WithValue
without any values (empty slate).context.WithValue
.ctx.Value()
. Ideally we wouldn't need a type check anymore like in Go 1 but could directly use the returned value.ctx
, like the original context has timed out but you need to do cleanups that should have a strict timeout. For this we could introduce some sort of disown
functionality to ignore previous annotations / start again with a clean slate. This could also be limited to spawning a new Go-routine. So on Go-routine launch there would be some way to decide if the existing annotations should be "inherited".Pros:
ctx
. That would be implicit as the annotations are part of the stack.context.WithTimeout
with a timeout
package that contains timeout.Set(time.Duration)
to set a timeout and timeout.Elapsed() bool
to check if the timeout has been reached.Cons:
context
is already and hence even harder to grasp for Go beginners.Thoughts?
@michael-schaller if it is part of the call stack then it is GLS/TLS - which has already been rejected (incorrectly so IMO).
@robaho My proposal isn't based on thread-local storage (TLS) / goroutine-routine local storage (GLS). Similarly to passing ctx
with every function call in Go 1 (and hence being part of every stack frame) my proposal would implicitly add ctx
to every stack frame in Go 2. TLS/GLS on the other side is independent of the stack state and is only bound to the respective thread/goroutine.
Also just to clarify, I'm just trying to give food for thought on this topic. If my proposal is rejected then that is IMHO a win as it also brings us closer to an answer what to do with context
in Go 2. ;-)
@michael-schaller the internals are different but the net effect is the same as GLS/TLS - that you have go routine scoped variables that are implicitly passed across function boundaries - that is - routine scoped. Not function call scoped. That is GLS/TLS.
@robaho I'm confused. context
in Go 1 isn't GLS and my proposal for Go 2 is very similar to Go 1's context
. The essentially difference is that ctx
isn't passed on explicitly anymore and instead implicitly. So I'm really confused how you think that the net effect is GLS.
GLS is like global variables, just that they are only accessible by a single goroutine. Also if you change a gouroutine-local variable then it changes for the whole call stack of this goroutine.
ctx
on the other side is something that you pass on, even across goroutines. Furthermore you can't change an existing context, you can just create new ones. Either blank ones (context.Background()
) or extended/annotated ones (context.With*
). That means particularly that a ctx
further down the call stack can't affect/alter previous contexts.
@michael-schaller if it is not explicitly passed as a parameter it has the same semantics as TLS/GLS since almost all uses of context - except for cancellation - requires creating a new one to hold the request state (or in the case of pprof labels) - which brings the same name collisions / global namespace issues. But I am willing to be that most framework uses of context.Context probably store a map in the Context, so they only have a single Context instance per routine, and then modify the map, rather than creating a new Context using WithValue - this is the only way to pass information back up to higher-level monitoring code.
For those interested, I suggest looking at ThreadLocal in Java. A difference being that because of generics it is type-safe, but the important thing is that 99+% of all usages are in frameworks or to avoid concurrency penalties when sharing expensive to create objects. Go's context.Context does not address the latter use case (nor really the first very well).
@robaho Thanks for the explanation. I think I understand now what you were hinting at. Let me try to rephrase it in my words. Please correct me if I got any of this wrong (again). ;-)
GLS (if it would exist in Go) would be available to the whole stack of a goroutine and hence it could affect all function calls of that goroutine, similarly to how a global variable could affect all function calls of a process. My proposal is similar as it could affect the whole stack after an annotation has been made. So annotations made in the very beginning of a goroutine would be effectively GLS. To make matters worse annotations that have been inherited by a new goroutine would be a GLS that isn't limited to a single goroutine anymore.
On the other side with context
in Go 1 the programmer always has full control over the provided ctx
and how it is passed on. However as ctx
in Go 1 is typically just passed on as is or is extended/annotated with one of the context.With*
functions means that context
in Go 1 has a very similar problem as a ctx
created for a new goroutine is effectively GLS for this goroutine. To make matters worse a ctx
passed on to any new goroutines is also GLS for that goroutine, which again means that this kind of GLS isn't limited to a single goroutine anymore.
@michael-schaller that is generally it. To be specific, the arguments against the current Context design, is that:
Even the “always add to the function signature” only serves to create a very poor GLS and lots of noise.
One other point to add, in the broad case things are more difficult in Go due to the easy concurrency (similar to using multiple worker pools in handling different stages of a single request in Java) so using GLS for “request state” is not trivial either. It may be that a first class “improved Context” designed for this specific case would be a great language feature.
No way to propagate up except storing a map in the context at a higher level
I don't think context package should make that easier. That's clearly a design flaw with the application, not context package.
so using GLS for “request state” is not trivial either.
That's exactly why context should be passed explicitly. GLS doesn't cover the same use cases that context covers. Context spans logical scopes (which consist of any number of goroutines), not goroutine or function scopes.
That is not true for frameworks. Often the tracking logging auditing is “external” to the request processing. Implementing this without GLS is very difficult. This is the exact reason that the pprof labels are an internal implementation that a user could not implement.
On May 31, 2020, at 9:00 AM, Antonenko Artem notifications@github.com wrote:
No way to propagate up except storing a map in the context at a higher levelI don't think context package should make that easier. That's clearly a design flaw with the application, not context package.
so using GLS for “request state” is not trivial either.
That's exactly why context should be passed explicitly. GLS doesn't cover the same use cases that context covers. Context spans logical scopes (which consist of any number of goroutines), not goroutine or function scopes.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub, or unsubscribe.
Problem:
In large production applications (a web service in my case), it is unnecessarily difficult to diagnose context.Canceled
errors.
Even with diligent error wrapping, the end result is often a string, like
handle request: perform business logic: talk to database: context canceled
There are many potential causes for this context cancelation. Some of them are normal operating procedure, and others require intervention. For example, it could be the request is complete, or a custom-built timeout, or perhaps a graceful application shutdown.
In general, if a context wrapped with multiple WithCancel
s (or WithTimeout
s), the cause for context canceled
(or deadline exceeded
) is ambiguous without additional logging.
Solution:
I would like to propose the addition of explicit error
s to supplement context.Canceled
and context.DeadlineExceeded
.
With errors
(nee xerrors
), I imagine it would be useful to implement Unwrap
, such that the error Is
both Canceled
and the custom error.
Here's a strawman to demonstrate the concept:
package example
import (
"context"
"errors"
"fmt"
)
var ErrSomethingSpecific = fmt.Errorf("something specific happened")
func Example() {
ctx, cancel := context.WithCancel(context.Background(), ErrSomethingSpecific)
cancel()
fmt.Println(errors.Is(ctx.Err(), context.Canceled))
fmt.Println(errors.Is(ctx.Err(), ErrSomethingSpecific))
// Output:
// true
// true
}
I got frustrated with that as well and have been using https://github.com/OneOfOne/bctx/blob/master/err.go in local and work projects.
For passing data, perhaps it would be better to use a builtin function to access the data. So instead of a global context
variable (which would have .WithValue
and .Value
), there would instead be addcontext(value)
and getcontext(Type) (value, bool)
builtins. This would reduce a lot of the namespace bloat with xxxContext
functions and having context
as the first parameter in every function. Contexts that were added in a function call are removed when the function exits. For instance:
type userContext struct {
currentUser string
}
func main() {
printUser()
withUser()
printUser()
}
func withUser() {
addcontext(userContext{ "dean" })
printUser()
}
func printUser() {
if user, ok := getcontext(userContext); ok {
fmt.Println("user:", user.currentUser);
} else {
fmt.Println("no user")
}
}
// output:
// no user
// user: dean
// no user
Note that this also makes contexts much more type safe, which is a huge benefit since current contexts have the type safety of a map[interface{}]interface{}
.
This still doesn't feel like the best solution. But hopefully this could inspire another idea that's even better. Perhaps requiring removing context explicitly with defer
. Or, context is added when calling a function (ie withcontext(context, functioncall())
).
===== EDIT =====
I like the withcontext
example which I had brought up in the last paragraph, so I thought I would rewrite my last example using withcontext
instead of addcontext
:
type userContext struct {
currentUser string
}
func main() {
printUser()
withcontext(userContext{ "dean" }, printUser())
printUser()
}
func printUser() {
if user, ok := getcontext(userContext); ok {
fmt.Println("user:", user.currentUser);
} else {
fmt.Println("no user")
}
}
// output:
// no user
// user: dean
// no user
Most helpful comment
I would add to that list:
context.WithValue
(intentionally) obscures information about the actual inputs required by a function; for example, because those inputs are required by some method of some other parameter. If we're getting real parametricity, it would be preferable to make those requirements explicit at compile time.