Context should go away for Go 2
As usual, when a new blog post comes out on blog.golang.org, I’m all eager to read it as soon as possible. The most recent one, Contributors Summit, is a nice write-up on the issues that the Go contributors have been talking about. While reading it, I stumbled upon a sentence that made me write this post. Here is is:
For instance, it would be nice if io.Reader accepted a context so that blocking read operations could be canceled.
This gave me chills. This is what io.Reader would look like with a context.
type Reader interface {
Read(ctx context.Context, p []byte) (n int, err error)
}
I did some research and found that some people already proposed this change for Go 2. Thankfully it received a decent amount of thumbs down, so it’s likely not making it.
This post is about all of the things that are wrong with the "context" package, why it is useful
despite that, and that Go 2 should do something about it. So, grab some popcorn and let’s get
started!
Go is a general purpose language
First things first, let’s establish some ground. Go is a good language for writing servers, but Go is not a language for writing servers. Go is a general purpose programming language, just like C, C++, Java or Python. For example, I’ve been using Go for about 2 years and I’ve never written a single server in it.
For this reason, when designing the Go language and it’s standard library, we need to approach it from a general purpose language perspective. Now, I’m not trying to say that context is only useful for server people. But mostly, it is.
Context is like a virus
This is the first and most important problem with context: it spreads! As mentioned in this blog post about the context package:
At Google, we require that Go programmers pass a Context parameter as the first argument to every function on the call path between incoming and outgoing requests.
Every such function also needs to propagate the context down it’s call path, or else it wouldn’t be fully cancelable. This means that all the potentially slow functions from other libraries that are being called from a function accepting a context should also accept a context.
In short, if you’re writing a library that has function which can take some significant amount of time and your library is potentially going to be used by a server application, you have to accept a context in those functions.
That’s how context spreads like a virus. What’s bad about that? Let’s recap:
- Go is a general purpose language.
- If a library is potentially going to be used by a server, it should accept a context.
- Now, everyone has to deal with the context, even the ones who don’t need it.
Of course, I can just pass context.TODO() everywhere, but that’s just gross, it hurts readability,
makes my code look ugly and simply removes a part of fun I have with Go.
If the Go language ever comes to the point where I’d have to write this
n, err := r.Read(context.TODO(), p)
put a bullet in my head, please.
You might argue: A library can provide two version of each function, one with a context and one
without a context. Sure, just take a look at the
"database/sql" package. Although it does solve the problem
partially, it smells quite bad.
Also, imagine teaching Go to a student. You start explaining the context-equipped io.Reader
interface (or anything else which occasionally requires a context) to them and they ask: What is
that ctx context.Context thingy there? And the answer would probably just be: Don’t worry about
that, just pass context.TODO() there for now. Sounds a lot like public static void to me.
The message is: Context spreads like a virus and I (alongside almost everyone who doesn’t write servers in Go) don’t want to deal with it when I don’t have to.
The "context" package itself is not that good
The first thing is a personal opinion, but for me, the context.Context interface has too many
methods. Now, the more serious problems.
If you use ctx.Value in my (non-existent) company, you’re fired
I’m not sure who came up with this idea that context should carry a map of meaningless objects to meaningless objects. There are just so many things that are wrong with it. Let’s list a few:
- An obvious one, it’s not statically typed at all.
- It requires documenting which values (keys and their types) a certain function supports and uses. As we all know, documentation is mostly a code that never runs.
- It’s very similar to thread-local storage. We know how bad of an idea thread-local storage is. Non-flexible, complicates usage, composition, testing.
- This probably doesn’t happen often, but it’s prone to name collisions.
- It’s just magic. An error-prone magic.
I know that ctx.Value makes some things easier. But, I believe that designing your APIs without
ctx.Value in mind at all makes it always possible to come up with alternatives.
Context is mostly an inefficient linked list
The way WithCancel, WithDeadline, etc. constructors from the "context" package work is they
create a linked list. Among other things, this sometimes requires creating a
goroutine for WithCancel, which propagates
cancelation signals from the previous context to the new one. Of course, if the context is never
canceled, this goroutine is leaked.
The WithValue constructor takes a context and returns a context which propagates the previous
context but also contains a value under the specified key. This is, obviously, achieved by creating
another node in the linked list, the purpose of which is to return the correct value for that key
and propagate the previous context otherwise. So, ctx.Value is not only a map of meaningless
objects to meaningless objects, it’s also a terribly slow map of meaningless objects to meaningless
objects.
And lastly
ctx context.Context
is a lot like
Foo foo = new Foo();
One of the things Go was created to avoid.
What does the "context" package actually solve?
Despite all of the bad things described above, the "context" package is genuinely useful, because
it solves one thing that is kinda hard to do in Go: cancelation. That’s the only problem the
"context" package really solves (or attempts to solve).
Let’s face it, cancelation in Go is hard. There is a whole talk called ‘Advanced Go Concurrency
Patterns’, which discusses this problem in depth. This
talk happened before the "context" package was introduced into the Go standard library and thus it
discusses solutions to the cancelation problem which only involve simple channels.
There are number of reasons why solutions proposed in the talk don’t scale very well. Here are a few of them:
- The cancelation channels are usually not accepted by other libraries and functions and thus cancelation is only possible ‘in-between’ the slow operations.
- Considering a ‘tree of goroutines’ (where children goroutines are the ones spawned by the parent goroutines), it’s easy to cancel the whole tree (just close the cancelation channel), but it’s harder to cancel a sub-tree (you need to introduce another channel for that, or some other solution).
The "context" package does solve these problems. Inefficiently and with numerous problems, but
solves them better than anything else out there. In Go, we need to be able to solve the
cancelation problem. Solving it is usually necessary anytime a decent usage of goroutines is
involved.
Go 2 should explicitly address the cancelation problem
I think it’s a weakness of the Go programming language that we needed to introduce a package like
"context". Go makes it very easy to create goroutines and communicate between them. However, the
"context" package is a proof that Go makes it hard enough to arrange goroutines to finish. I
believe this problem should be solved directly in the language. The language should provide a
solution, which is:
- Simple and elegant.
- Optional, non-intrusive and non-infectious.
- Robust and efficient.
- Only solves the cancelation problem. Values can be omitted. Timeouts can also be implemented on top of a very simple cancelation.
You might argue: I like context, it’s an elegant solution to the problem without changing or complicating the language. I disagree. For all the reasons described above it’s not an elegant solution and although it’s not an integral part of the language, it is and is becoming more and more an integral part of the libraries. In the end, it makes the language harder to use.
I have a few solutions in mind, but I’ll leave them for another post or a proposal, or I’ll leave them for myself if someone comes up with a better solution. The purpose of this post is just to point out the problem.
Conclusion
This post was trying to point out a problem in the Go language. In short, cancelation is a problem
in Go and the "context" package does not solve this problem very well. I can’t think of any other
solution that would solve this problem good except for a language change. That is up for Go 2.
Thanks for reading and I’m looking forward to your feedback and hate comments objections ;).
Michal Štrba
23 archived comments
Posted on the previous version of this blog. They're read-only now; new comments go below.
Do you know Goroutine local storage https://github.com/jtolds/gls
Argh, all that mess for the advantage of... using a global log variable! https://dave.cheney.net/201...
Please consider adding this to https://github.com/golang/g..., as this kind of post is exactly what we’re looking for.
I wasn't sure if this qualifies for an experience report, but if you say so, I'll put it there :)
EDIT: Oh, it's already there!
Things like Erlang process linking (goroutines linking?) and OTP supervisor trees could be a potential solution to the cancelation problem.
> This probably doesn’t happen often, but it’s prone to name collisions.
From godoc: The provided key must be comparable and should not be of type string or any other built-in type to avoid collisions between packages using context. Users of WithValue should define their own types for keys. To avoid allocating when assigning to an interface{}, context keys often have concrete type struct{}. Alternatively, exported context key variables' static type should be a pointer or interface.
Thank you for the great post. I would love to see some of the potential ways to handle cancellation at a language level (with the optional timeout). So looking forward for your next blog post on this.
For an idea of how "context" (as used for storing ctx.Value) might appear if given language support, look at Racket and its continuation marks. It's essentially an implicit, globally-available version of "context".
The fact that nobody has been in a hurry to reimplement that in the 15 years since the technique was developed may suggest it's not ergonomically worthwhile unless you have a genuine technical reason (i.e. supporting call/cc)
I have a quick question:
According to this: " Go is a good language for writing servers, but Go is not a language for writing servers. Go is a general purpose programming language, just like C, C++, Java or Python. For example, I’ve been using Go for about 2 years and I’ve never written a single server in it."
If all (Go, C, C++, Java, Python) are just general purpose languages not for writing servers, then; which is "the language" to write servers?
Thanks.
Well, since all of them are general purpose languages, all of them are also suitable for writing servers ;) Go just has better libraries for that.
Okay, I thought you had a kind of "philosopher's stone" language just for writing servers. :/
But now you clarify that everything quoted above should have been just understood like:
"Go is a good language for writing servers, just like any other general purpose programming language like C, C++, Java or Python"
Thanks for clarifying.
Erlang / Elixir
Good point, thank you. I'm also embarrassed by virus nature of context. In uncertain moment It becomes look as garbage that hides a logic of application. It makes code unclear and hard-to-readable.
If it is actually required everywhere, then why it shouldn't be built-in in language. So, I also believe that overusing context is a wrong way.
Do you have data showing that context really is slow, or are you assuming that it must be slow? My working performance theory for it is that it is cacheline friendly (generally stack allocated) and worst case adds a few chained cache misses.
It was actually an ungrounded claim, I admit. I ran some benchmarks.
Source: https://gist.github.com/fai...
Results: https://gist.github.com/fai...
The results show that context is fairly fast when used with not too many values, but slower when there are many values.
I believe Google also uses context for their distributed tracing framework Dapper:
https://research.google.com...
Some evidence to that effect:
https://github.com/golang/g...
The pervasive nature of context would then be seen as a feature, not a bug.
I disagree with you that the only problem context.Context solves is cancellation. Yes, this is probably the main problem that it solves in most of the cases, but not the only one. As its name suggests it provides a context for some execution/calculation. A logical run-time context for an execution.
We use very context's WithValue() very intensively in order to provide some default app service registry. Then later by adding couple decorators/middleware calls we can in run-time reconfigure this app service per API call. For example, in order to diagnose some specific case I can pass a flag and a standard logger will be replaced with a more verbose one that will redirect all the output to a certain location so I can get an immediate feedback. Another example is to use it as backbone for feature flags or A/B testing.
It is a very simple concept, and very explicit so there is no magic. IMHO the 2 main problems with context.Context that it is too low-level and it is a run-time feature.
EDIT: the nice part of having context.Value is that the same "context-aware" API, your API, can be easily used with a lot of user interfaces: HTTP REST endpoints, CLI utilities and GUI. Just make sure to configure the context and pass it to your API. The main problem though is that it is a run-time feature, and there is no way to statically validate that the API contract is obeyed and context.Context is configured as expected by the latter.
PS I am not arguing that this is most elegant and essential feature of Go. There are other ways to solve the problems I described above. But what I am trying to say is that there is a valid use case for context.WithValue
Not only is Context a poorly designed and implemented type but the documentation is deceptive and misleading (for example context.TODO and the mandate not to support nil Context is basically marketing the context package, there's no design or engineering goal) and the way it was hoisted into the standard library with debate about it squelched basically "because app engine" and then propagated (including into database/sql along with a weaselly attempt to break the go 1 compatibility guidelines) is very disturbing.
Do you think this could be resolved with a .NET Task aproach? Instead passing context, the routine could return every time a "routine" struct or even "context" struct containing the state of the routine or channels to receive that info
wow, i couldn't understand context, even after reading some posts about it. Now, after your article, I do understand contexts and agree: they're useless. It tries to solve three problems with one concept, where solving the cancellation thing is the only real issue here.
I strongly disagree with that statement. Go can be used for other tasks but was designed to write network software in mind (and does this very good).
IMHO thats one of the reasons why Go lacks out-of-box some features that presents in general purpose languages like Java (e.g. Generics)
Go can be used in a lots of fields, but it is primary is a DSL for writing asynchronous code.
And it is not that generic of course. Spawning OS threads is an example of what it cannot really do.
IIRC at Google, the C++ version of goroutines, called Fibers, had the notion of "current fiber context" so that you didn't have to pass it everywhere. Perhaps having the ability to say "context.GetCurrent()", where necessary, would be reasonable?