Hacker News new | ask | show | jobs
by BreakfastB0b 1243 days ago
> Go decouples all that coloring away.

Go does have “coloured functions”, it’s called `context.Context`.

Any function in go performing IO usually has to take `context.Context` as its first argument so it’s cancelable. Which then propagates that requirement to the caller just like `async`.

That constraint can be discharged with `context.Background()` just like the async constraint can be discharged by not `await`ing the future in Rust.

5 comments

> That constraint can be discharged with `context.Background()` just like the async constraint can be discharged by not `await`ing the future in Rust.

Context isn't a control flow property. Goroutines are still concurrent regardless of whether Context is passed, a global, or whatever, and regardless of whether intermediate functions know anything about Context--they could be calling closures that have captured Context on their own.

Another way of looking at it is that Context is just a way to specify certain properties of messaging objects, not the functions you call. A Context timeout could just as well be set on a socket itself, for example. Await/async is an independent property of the fundamental behavior of each and every function; and a property all functions in a call chain must obey to achieve the desired result.

The "colored functions" debate is just a twist on older debates, such as debates over first-class functions. The definition of first-class functions is instructive. In Go all functions are first-class because, just as in any other language with first-class functions, a reference to a function has the same primitive type regardless of whether it's a closure, is suspendable, etc. The type of its application-defined arguments are irrelevant in this regard. Rust, by contrast, does not have first-class functions in the strict sense, because it has several distinct primitive types for functions; not just for async/await, but notably for closures.

Notably, any language with both first-class functions and closures must be garbage collected. I'm sure there were multiple motivations for Go being garbage collected, but supporting closures as first-class functions is surely one of them, precisely so you don't have a "colored function" problem, where closures are distinguishable from non-closing functions with the same argument and return types.

You can dispute my description and definition of first-class functions (indeed, I skirted the question of whether the distinction between Go functions and methods matters), but at the very least it should elucidate the fundamental issues. Importantly, changes in the type signature of a function because of application-defined argument or return types is irrelevant. What's relevant is whether--or at least the extent to which--internal properties of a function object (e.g. references closed-over values) are visible in the type system, effecting how and when they can substitute for an otherwise identical function lacking the internal property.

You’re correct that they’re fundamentally different things, I didn’t mean to draw a false equivalence between them.

The comparison I was trying to draw between them was the way they infect the call tree and usually demarcate impure functions.

When I said that context.Background() discharges the constraint I meant in the sense that the caller doesn’t need to propagate that argument to its caller as it can just pull a context out of thin air.

However I’ve seen a lot of new go devs confuse context.Background() for forking things into the background.

For what it’s worth, I think coloured functions are actually a good thing. If you’re following clean architecture or something similar coloured functions help you easily distinguish what layer in your application something belongs to.

> Notably, any language with both first-class functions and closures must be garbage collected

This is not true, and Rust is a counter-example.

This! I wish Go made context implicit and inherited from the caller, and propagated down to IO by default, to remove the last function coloring problem.

This would do the right thing in the 99% of sequential code, and could be overridable the same way as today for power use cases.

There are TONS of deadlocks floating around 3p library code due to the complexity and confusion around managing deadlines and context-triggered IO interrupts.

What would you do after launching a goroutine? Im very fond of contexts being explicit because it makes this situation obvious, unlike thread local variables where you have to pause and think about it.
> What would you do after launching a goroutine?

Not sure what you mean? The context would be inherited from its parent.

I’m making two points: 1. Context should make its way down to IO, and 2. they should be implicit by default. All for the same reasons Go has automatic memory management - reducing complexity and surface area of bugs for the 99%.

Function coloring may seem innocent in a self-contained example, but become problematic at an ecosystem-level: if one player is not cooperating, the bets are off for everyone else. All dual context impls I’ve ever seen have the context free version simply invoke the other with the background context, indicating a lack of meaningful semantic distinction requiring an API level differentiation. In simpler terms, the act of choosing between either context or no context version is purely a mechanical “do I currently have a context” yes/no determination. This is prime criteria for warranting implicitness, imo.

> Not sure what you mean? The context would be inherited from its parent.

I hate dynamic scoping for the very same reason I would hate having context be inherited from the parent caller.

Not only would it would result in a function having different behaviours depending on what the call stack looked like at runtime (which is bad enough), but it would be invisible to the reader of the code.

What I like about Go is that it is very easy to visually inspect the code in code reviews.

Imagine looking at a diff in a PR that changes function `foo()` to add a call to function `bar()`, with `bar` using a context and `foo` not using a context.

Do you really want to have to read all possible call-sites to ensure that the context is what is expected when `foo()` calls `bar()`? It's easier to reason about when the `context.Context` is created at the point of calling `bar()`.

For context (hah), I’m talking purely in terms of cancelation and not context.Value() which is a whole other story, and imo should never have been created.

> I would hate having context be inherited from the parent caller.

By default. All I’m arguing is that canceling is in 99% what should be done. You could override that when it makes sense. If your parent wants you to terminate, then in what instances would you keep going? There are some use cases of graceful teardown, but I haven’t seen a single 3p developer give a shit about that, and I’d rather have them respect my desire to yield control back to me, than to go on forever because they forgot to carefully litter their codebase with SetDeadline in their IO calls.

> Do you really want to have to read all possible call-sites to ensure that the context is what is expected when `foo()` calls `bar()`?

Why would I? Unless foo or bar is a unicorn that requires the caller to prepare a custom context, it would work the same as mindlessly passing the context from parent to child, which people do today.

Take the converse example: if today I add a call to bar (no context param), I need to make sure it doesn’t block forever, and if it does, it makes my entire call tree non-cancelable, even if I have been diligent about it in every other place. It only takes one non-cooperative player to destroy that property, and the default is to not be cooperative.

If you don’t await, how do you get the result? Go returns the result even if you pass in context.Background(). Another subtle difference is that most functions take a context in Go. AFAIK, most functions in Rust are synchronous functions.

Also, changing an immutable reference to a mutable one in can land you in a world of hurt in Rust.

> Another subtle difference is that most functions take a context in Go. AFAIK, most functions in Rust are synchronous functions.

I'm fairly certain that most functions in Go don't take a context. There are tons of helper functions like those in the fmt or strings package that don't, for instance.

Oh and let’s not forget about… basic IO. In order to apply cancelation to IO you have to create a new goroutine, wait for the cancelation, and then interrupt the io. Oh, and you need to remember to not leak that goroutine.
There are tons of helper functions in the standard library, but the standard library is quite small compared to all Go code in existence. You also can't really change the standard library, so it's unlikely you'd suddenly start needing a context in fmt.Sprintf
context.Background() is typically only used when one doesn’t care about the result. If you did care about the result, you should be passing the parent context to preserve the circuit breaker timeout in case the operation takes too long.

I think the level of pain you experience from mutable references in Rust depends on if you’re coming from an OOP or FP background. I have a FP background and so the patterns I use to build code already greatly restrict mutation. You can usually change code that updates data immutably (creating a new copy of it) with mutable code in rust because the control flow of your program already involves passing that new version back to the caller which also satisfies the borrow checker in most situations.

It’s like the ST monad in Haskell; if you modify an immutable value but no one is around to see it, did you really mutate it?

> context.Background() is typically only used when one doesn’t care about the result. If you did care about the result, you should be passing the parent context to preserve the circuit breaker timeout in case the operation takes too long.

Not true in my experience. You would use context.Background in a test situation. It's also commonly used for short-lived applications like a CLI. You can see kubectl uses context.Background quite a lot: https://github.com/kubernetes/kubectl/search?q=context.backg...

> I think the level of pain you experience from mutable references in Rust depends on if you’re coming from an OOP or FP background. I have a FP background and so the patterns I use to build code already greatly restrict mutation. You can usually change code that updates data immutably (creating a new copy of it) with mutable code in rust because the control flow of your program already involves passing that new version back to the caller which also satisfies the borrow checker in most situations.

There has to be a better solution other than needlessly copying data.

> There has to be a better solution other than needlessly copying data.

Sorry I don’t think I explained myself very clearly.

What I’m saying is Rust allows you to optimise FP style patterns by replacing copying data with in place mutation because the control flow required for handling the flow of immutable data is also well suited to satisfying the borrow checker i.e. explicitly returning the data back to the caller. Because FP patterns can’t automatically communicate through shared mutable references they also lend themselves well to Rust but without the overhead of copying data because an in place mutation can be used instead.

> Not true in my experience. You would use context.Background in a test situation. It's also commonly used for short-lived applications like a CLI. You can see kubectl uses context.Background quite a lot:

My experience with Go is entirely within the context of API and microservice design where circuit breakers are very important which is why my experience with context propagation may be different than your own.

It’s also not surprising to see context.Background() used within tests because it’s being used precisely as I described above to “discharge the constraint” because you can’t propagate the constraint to the caller because test functions in go can only take one parameter `*testing.T`.

IME it's not uncommon for even a small a Java Spring application to take like a minute to build and like many seconds (30?) to properly start up and start taking requests.
Heh? Spring boot starts up in like 2-3 seconds. Of course you can register like 50 different messaging services or what not but then you are not comparing apples to oranges.

Also, that seems like a very excessive build time, either there is some badly configured build script or you are again comparing a full fledged spring app with another vanilla framework.

We don't have a whole lot going on. I'm talking about building an uber jar with shadowJar gradle plugin. Maybe 20 endpoints on a single controller connecting, and connecting one Kafka publisher and Cassandra db.
Context is a dependency like any other parameter in a function call. I don't think we should start conflating that with coloring which is distinct from normal parameters: function colors are the result of using language-specific keywords (async, await, yield, etc). These keywords are implemented with compile-time and/or runtime-time support. Context on the other hand is just Go code like any other Go code, and used as a convention, and quite a loose one at that. You can opt out of it with "Background()" at any point.

Also I probably see contexts used as much for supervisor/component management as IO. Most of the stdlib IO APIs lack Context support:

- the io and os packages have 0 references to context

- the "net" package has contexts for dns and making connections, but reads and writes don't use contexts

Async/await in Rust is mostly just sugar on top of futures, which you can implement manually. There's plenty of old code in Rust that predates async/await that does just that.
Sure and Python's yield is just sugar around the generator protocol, but both bits of plumbing are quite a bit more sophisticated than "just another function parameter." I know I'm being a bit pedantic, but I think "function colors" ought to mean something a bit more than "a commonly used function parameter."
The phenomenon of “coloured functions” is usually derided because of the way it infects the call tree. In this sense context proliferation is the same phenomenon. Otherwise no one would really care about coloured functions.
Fair enough