Hacker News new | ask | show | jobs
by alphazard 1075 days ago
It looks like a lot of people are missing the point here. Yes a coroutine library would be a worse/more cumbersome way to do concurrency than the go keyword.

The use case motivating all the complexity is function iterators, where `range` can be used on functions of type `func() (T, bool)`. That has been discussed in the Go community for a long time, and the semantics would be intuitive/obvious to most Go programmers.

This post addresses the next thing: Assuming function iterators are added to the language, how do I write one of these iterators that I can use in a for loop?

It starts by noticing that it is often very easy to write push iterators, and builds up to a push-to-pull adapter. It also includes a general purpose mechanism for coroutines, which the adapter is built on.

If all of this goes in, I think it will be bad practice to use coroutines for things other than iteration, just like it's bad practice to use channels/goroutines in places where a mutex would do.

5 comments

I think it's also worth mentioning that for certain specific use cases coroutines are much more efficient than a full goroutine, because switching to/from a coroutine doesn't require context switching or rescheduling anything. If you have two cooperating tasks that are logically synchronous anyway (e.g. an iterator) it's much more efficient to just run everything on the same CPU because the kernel doesn't have to reschedule anything or power down/wake up CPU cores, and the data is in the right CPU caches, so you'll get better cache latency and hit rates. With goroutines this may happen anyway, but it's not guaranteed and at the minimum you have the overhead of going through the Go runtime goroutine scheduler which is fast, but not as fast as just executing a different code context in the same goroutine. Coroutines offer more predictable scheduling behavior because you know that task A is switching directly to task B, whereas otherwise the goroutine scheduler could switch to another available task that wants to run. The last section of the blog post goes into this, where Russ shows that an optimized runtime implementation of coroutines is 10x faster than emulating them with goroutines.

Google has internal kernel patches that implement cooperating multithreading this way (internally they're called fibers), and the patches exist for precisely this reason: better latency and more predictable scheduling behavior. Paul Turner gave a presentation about this at LPC ~10 years ago that explains more about the motivation for the patches and why they improve efficiency: https://www.youtube.com/watch?v=KXuZi9aeGTw

It's not just about performance, but also safety and ergonomics. Since true coroutines[1] offer predictable scheduling, their behavior with regards to data races and deadlocks is also more predictable.

If programmers try to manually implement iterators, generators and interleaved state machines with their own goroutines and channels, it's not just performance that suffers - there is too much room for error.

[1] I'm using the qualifier "true" here, since many modern languages (such as Python, Kotlin) use the term "coroutines" for something that is more like Go's Goroutines than Lua's coroutines. Unlike Go, they are not preemptible, but they are (at least by default) implicitly resumed when necessary by some scheduler, and they may execute on different kernel threads and switch contexts.

> I'm using the qualifier "true" here, since many modern languages (such as Python, Kotlin) use the term "coroutines" for something that is more like Go's Goroutines than Lua's coroutines.

Python has both true (ish) coroutines (or at least coroutines which are entirely user controllable), which it mostly uses for iteration, and the concurrency specialised “async”.

Initially the goal was to reuse “yield” for concurrency (a big reason why “yield from” was added), but the ergonomics of mixing multiple coroutines uses was found to be awful, at least for the langage python is, and trying to provide relevant sugar difficult.

It's almost true. Python calls its "almost true" coroutines "generators", while the implicitly scheduled, async I/O-oriented coroutines are just called "coroutines".

I understand that the historical reasons for that ("generators" originally only supported "yield" and a separate async/await design with implicit scheduling seemed more ergonomic). But that doesn't remove the confusion.

But Python's generators are still fully "true" coroutines. Russ makes the distinction between coroutines and generators based on whether as "Generators provide less power than coroutines, because only the top-most frame in the coroutine is allowed to yield. ". I think this is a bad definition, as generators can clearly "pass" the yielding power to other generators with "yield from", and Russ talks about this feature in the same post. Generators require more ceremony, but I don't think they are "less powerful" in any meaningful way .

Roberto Ierusalimschy, the creator of Lua, and Ana Lucia de Moura proposed the distinction between stackful and stackless coroutines[1]. Their paper revived coroutines as a research subject and had a lasting impact on coroutine-related terminology. Ierusalimschy and Moura made the distinction between "Stackful" coroutines and "non-stackful coroutines" (the term "stackless coroutines" stuck later on, but they did not use it), where Stackful coroutines like Lua's coroutines can "suspend their execution from within nested functions". They also made the same allusion to power that Russ does: "powerful enough to constitute a general control abstraction; in particular, they can- not be used as a concurrent construct".

I think both Ierusalimschy and de Moura are onto something when they call generators "limited", but they are just incorrect when they say that "but are not powerful enough to constitute a general control abstraction; in particular, they cannot be used as a concurrent construct". Early Node.js libraries and frameworks such as "co" and "koa"[2] should count as a proof that you can very much use generators as a concurrency construct. Generators have all the power that is necessary for dealing with normal concurrency cases, although they don't have the best ergonomics.

But there is another distinction where "generators" are not the same as Lua coroutines, and clearly lack some expressive power (even though this power is NOT a necessary condition for implementing full-fledged concurrency). Lua allows coroutines to communicate bidrectionally: yield() can pass a value back the coroutine caller, which is returned from resume() - but when the coroutine caller calls resume() again they can pass an argument which will be returned from yield(). Generators are unidirectional: they only allow you to pass a value to yield(), but not to resume().

[1] http://www.inf.puc-rio.br/~roberto/docs/MCC15-04.pdf [2] https://news.ycombinator.com/item?id=6933358

> Lua allows coroutines to communicate bidrectionally: yield() can pass a value back the coroutine caller, which is returned from resume() - but when the coroutine caller calls resume() again they can pass an argument which will be returned from yield(). Generators are unidirectional: they only allow you to pass a value to yield(), but not to resume().

I might be missing something as I’m not familiar with Lua’s coroutines but that sounds like send (https://docs.python.org/3/reference/expressions.html#generat...). The parameter to `send` (from outside the coroutine) is the return value of `yield` (inside the coroutine).

What is wrong with:

    for {
        next := getNext()
        ...
    }
What is the advantage of writing this as:

    for next := range getNext {
        ...
    }
In practice the difference would be closer to:

    getNext := iterableThing.Iterator()
    for {
        next, ok := getNext()
        if !ok {
            break
        }
        ...
    }
vs.

    for next := range iterableThing.Iterator() {
       ...
    }
One advantage is that it's slightly shorter, which matters for very common patterns--people complain about `err != nil` after all. Another advantage is there isn't another variable for everyone to name differently. Another advantage is that everyone can't do the control flow slightly differently either.
Only newbies tend to complain about `err != nil` in my experience. After a certain point it just clicks and they get used to it. There's a cadence to Go code (do the thing, check the error, do the thing, check the error) that is easy to read once you're used to it, but looks horribly verbose when you're coming from an exceptions-based language.

Go has simplicity as a design goal. Part of that simplicity is not adding all the features we can think of to the language. Just because it would provide slightly leaner syntax for a relatively small group of use cases isn't a good reason to add new language features (imho, from my 10+ years using Go and watching it evolve).

If we can implement this using current language features, but it's complex and messy to implement, then it's a great case for a new library, possibly even inclusion in the standard library. If we can't implement this at all using current language features, then maybe it's a case for a new language feature to enable this. If we can implement it relatively cleanly using current language features, then we're good and don't need to do anything. This seems like a "can implement, but not very cleanly" case, which would be a great justification for a library, but not a language feature. Again, imho.

I'm used to `err != nil`, but it doesn't mean I like it. It's a lot of what amounts to boilerplate in a language that is mercifully short of boilerplate elsewhere. This is doubly true when you want custom error types, and need to start unpacking values from your custom error struct.
>This seems like a "can implement, but not very cleanly" case, which would be a great justification for a library, but not a language feature.

First sentence of OP:

> This post is about why we need a coroutine package for Go

Then in the girst section after the intro:

> Later, I will argue for an optimized implementation provided directly by the runtime, but that implementation should be indistinguishable from the pure Go definition.

Yeah, so I'm agreeing that this would be a great library, but disagreeing that it should be a language feature. Sorry if I didn't make that clear.
Personally, I don't really see anything wrong with explicit error values, and checking them against nil.

The point I was trying to make is that some amount of people see the benefit in removing much smaller boilerplate. And so presumably at least as many people should see the benefit of removing a larger amount of boilerplate.

I'm just talking about the benefits here. There is also a cost to expanding the language. The costs might outweigh the benefits, but it doesn't mean that the benefits don't exist.

> There is also a cost to expanding the language. The costs might outweigh the benefits, but it doesn't mean that the benefits don't exist.

Agreed. I think this is where the discussion will focus. I'm glad that historically the Go team have weighted the costs heavily, and hope they continue to do so.

I guess I'm in the minority but boilerplate really doesn't bother me. Going the other way gets too much "magic" and we lose the ability to fine-tune behaviour.

> complain about `err != nil` after all

Not to nitpick this specifically but as a generic reminder not all complaints are worthy of shifting the trajectory of a massively popular programming language.

Balancing "worthy" and "unworthy" changes is really hard both in the community and discussions like this one. I don't envy the teams that have to do it.

Not forcing to check errors is akin to what you do in C, and even there compilers complain about this.
I think more idiomatic go for the first case would be:

    getNext := iterableThing.Iterator()
    for next, ok := getNext(); ok; next, ok = getNext() {
        ...
    }
Which, yeah, the range cleans it up a bit, but it's not doing quite as much work as you're implying.
Don't forget that the important bit in Go is readability of the code. To me the range form is much easier to reason about than this for loop.
If we're going for readability instead of trying to stick to the case presented above, I'd go with:

    for i.HasNext() {
        current := i.GetNext()
        ...
    }
And just mutate the internal state of the struct. Probably throw it behind some sort of interface like:

    type Iterator[T any] interface { // feel free to strip out the generic
        HasNext() bool               // if you really only have one type
        GetNext() T                  // you do this with
    }
I think this still loses to ranging over a function, but I still think it should be compared to what you can do with the current language as opposed to what was originally posted.
The err!=nil is exactly what you don’t want to mention and the perfect example why you shouldn’t listen to all the close-minded takes some people might have.
Your code is an example of a "pull iterator", and it's not as much of a concern.

The harder case to deal with is a "push iterator", which are often much simpler to implement, but less flexible to use. See https://github.com/golang/go/discussions/56413

The OP is about converting a push iterator into a pull iterator efficiently by using coroutines. This provides the best of both worlds, simple iterator implementation and flexible use by its caller.

Besides what everyone else said, the obvious advantage is the latter builds in the logic for exiting the loop once getNext has run out of elements in the slice/map. Your former example will need a final step that's like:

    ...
    if getNext() == nil {
      break
    }
    ...
This isn't a huge boon, and is mostly a matter of style. But I prefer the latter because it's logic that gets handled with the range builtin, so my code can be more about application logic, rather than muddling in breaking out of loop logic.
None of the proposals submit your idea of writing things differently. The article proposes an implementation that is fully doable and usable with current spec and no breaking changes.

The point of coroutines is that they are little contexts that serve to be called

- many times

- sometimes with different parameters

- change state

- might be interrupted by callers

- might interrupt callers themselves

All of this can be done by other means. Just like any sorting can be done by copy pasting the same code, but generics make it less tedious. That's the same idea here. Some problems can be implemented as interleaving coroutines, and their definition is simple enough that you want to write it all in the body of some CreateCoroutine() function instead of taking out another struct with 5 almost empty functions. It will not solve all problems, but can more clearly separate business logic and orchestration.

The first doesn't do control flow
Depends how getNext is defined.
The only way to do control flow in getNext, consistent with how you partially defined it, is to panic. That means there is some important code wrapping the whole loop body that you left out.
That doesn't make sense. `getNext()` has no ability to break out of the loop.
Well, what was wrong with:

    for {
        next := <-channel
        ...
    }
Horrible overhead. If the loop does something simple, like summing integers, 99% of time will be spent switching between goroutines.

From TFA:

"Because scheduling is explicit (without any preemption) and done entirely without the operating system, a coroutine switch takes at most around ten nanoseconds, usually even less. Startup and teardown is also much cheaper than threads."

"For this taxonomy, Go's goroutines are cheap threads: a goroutine switch is closer to a few hundred nanoseconds"

Also check out https://news.ycombinator.com/item?id=29510751

and https://ewencp.org/blog/golang-iterators/index.html:

"Finally, the most natural channel based implementation is… slow. By a factor of nearly 50x. Buffering does help though, reducing that to a factor of 25x."

And even if it's a toy program and you don't care about performance, it's not as simple as just:

    for {
        next := <-channel
        ...
    }

You have to set up the channel and the goroutine that feeds it, you need to safely close the channel when the iteration is done (but not before, unless you like panics), you need to deal with panics inside the goroutine and possibly support cancellation if the iteration breaks early (unless you love memory leaks).

If you try to implement this pattern by hand, you are all too likely to make fatal mistake, and this is doubly true in the hands of an inexperienced programmer.

I appreciate the fact that Russ wrote this long post, gradually implementing `coro.New()` and improving its functionality and safety — and only in the very end, we get a short paragraph about performance. Good performance is important to make this feature attractive to use, but if the feature is clunky and error-prone, it wouldn't be worth much, even with great performance.

I'm responding to a comment asking for a justification for sugar for function call based iteration. My comment is an attempt to draw a parallel to the need for sugar for channel based iteration. I'm not trying to suggest channel based iteration as an alternative to coroutines.
The article mentions race conditions
I think coroutines in Go will make it possible to use Go as a host for clean definition of discrete element simulations. Without it, yielding to an actor is clunky.
Why not just range (or use an switch) over a channel and have a go routine running that pushes values into the channel?

I still don’t see the need for coroutines.

This is too slow to really be useful on its own
Still a kitchen sink move, though, isn't it?

Like, no careful thinking and good 80/20 solution this time. Just "huh, we'd need coroutines to do this `right` so let's just do that"

When they added generics, they really, really thought long and hard, and came up with a compromise that was brilliant and innovative in the balance it struck.

I would have hoped to see something like that here, like "we're adding this one feature to goroutines to have control in specific situations" feels like something that would be better than "we're going full Rust on this problem and just adding it."

This has been under discussion for a long time.

https://github.com/golang/go/issues/43557

https://github.com/golang/go/discussions/56413

I'm not sure Russ's personal blog is any kind of official statement "this is what we're doing" yet?

> I'm not sure Russ's personal blog is any kind of official statement "this is what we're doing" yet?

I've found it's generally a very strong indicator.

But to your point, this isn't a new issue, there's been a good amount of discussion around it over the years.

In the old discussion, I specifically complained about how it would add back door coroutines to Go via iterators, and now Russ has a post about formalizing the concept of a coroutine, while also keeping the optimization of coroutines as a runtime implementation detail. I feel vindicated. :-)

https://github.com/golang/go/discussions/56413#discussioncom...