Hacker News new | ask | show | jobs
by oivey 1945 days ago
Part of this is that I’m tired, but it blows my mind how difficult C++ coroutines are as someone who considers themselves decent at C++ (although maybe I’m not) and uses coroutines in other languages. The amount of code needed to do almost nothing is extraordinary, and putting it all together doesn’t seem like you would often get on your first try. I get that new keywords basically can’t be added, but man, that’s painful.
7 comments

C++ coroutines as they are in the standard are not really intended to the everyday programmer (1), but rather for library implementers who will use and abstract them into higher level features.

1: like most of C++ one would way

Ultimately, I think the simultaneous complexity and lack of built in functionality will limit how often people end up using this new part of the standard. I can use coroutines to write high performance, parallel code in other languages without being a mythical ~library implementor~. I usually even write libraries in those languages.
Agreed. I think the killer use is for event-driven programs that already do a lot of "stack ripping". If you are already manually putting all your "local" variables into an object and slicing your functions into many methods, then the grossness of C++20 coroutines will be easy to digest given how much more readable they can make your code.
Doing things right takes time. This is one step in making co-routines useful. Library authors now have something to work with to figure out what the next part of the C++ standard is. Expect standard library support in C++ in either c++ 23 or 26.
I agree with GP, I feel a similar disdain when I hear that features that are merged into the standard are for the "library implementors". Boost.Coroutine and Boost.Asio have been around for how long? At least a decade? I think there has been more than enough experience with coroutines and async programming to get coroutines in standard C++ done so that at least the "average" C++ programmer can grok them.
The problem is coroutines need compiler support before you can play with them. Boost coroutines were rejected because doing coroutines only as a library just isn't pretty (it works, but nobody liked it), so looking at other languages the current direction was decided. Only now that you can do something can libraries spend the decades to figure out how it all fits into C++.
The problem is that it took forever to standardize the internal language-level machinery that the committee run out of time to standardize the user level library bits. Instead of delaying coroutines again (they were originally scheduled to be part of C++17, but then they were taken out of the standard), they decided to merge what they had into the standard.
Currently WinRT has the best support for C++ co-routines, Microsoft was after all the main driver of the proposal, and they map to the Windows concurrency runtime.

So on WinRT it is hardly any different from what C++/CX allowed for in concepts.

Not really, coroutines are supposed to be user level constructs. I.e most users should be able to write functions that yield or await. Unfortunately the machinery surrounding coroutines is very complex, but that can be hidden behind library functions.
This is a similar approach to what Kotlin did with its co-routine implementation. Aside from the suspend keyword, there are no language features that you work with directly. Instead everything is done via the kotlinx-coroutines library. Kotlinx means it is a multiplatform library with implementations for native, js, and jvm that do the appropriate things on each platform.

Basically how the library realizes this is opaque to the user and indeed it does things like reuse low level primitives available in each of the platforms. Basically, at it's lowest level it is simply syntactic sugar for callback based implementations common in platforms that have things like Futures, Promises, etc.

In fact it's easy to integrate any third party library that provides similar constructs and pretend that they are suspend functions. E.g. the suspendCancellableCoRoutine builder can take anything that has a success and fail callback and turn it into suspending code blocks. For example Spring supports coroutines and flows out of the box now and provides deep integration with its own reactive webflux framework. So, you can write a controller that is a suspend function that returns a flow and it just works as a nice reactive endpoint.

I actually would expect that these new C++ capabilities will also find their way into Kotlin's native coroutine implementation eventually. Kotlin native is still in Beta but seems popular for cross platform IOS and Android library development currently.

Kotlin's coroutine library took quite a while to get right for the Kotlin developers and evolved a lot between Kotlin 1.2 and 1.4. Parts of it are still labelled as experimental. Particularly, reactive flows are a relatively late addition and have already had a big impact on e.g. Android and server-side programming.

They are very complex, and they kept being patched up till the last minute as more corner cases were discovered.

I believe that the design is both unneededly complex and too unflexible, but this is the only design that managed to get enough traction to get into the standard and it took forever. There are competing designs and improvements, we will have to see if they will make it into the standard.

A part of the tis that the standards committee decided to do a multi phase rollout. They created the core language parts and will hopefully in a future version add the library support making it easier to approach. (And then hopefully find that the design works with the library design ...)
Well put. I wish more features were rolled out as core language features before putting them in std. making things language features makes them possible. Making them library features makes them vocabulary. I’m more eager for things to be possible than I am for them to be ergonomic.
Making something a core language feature means it has to be completely right. Doing something in library leaves more of an option to fix it later or creating another version of the thing inna different header.

I also would like if std::variant were a language feature, not library :)

This is how I view iterators, but with an extra notch in difficulty. You really need to understand the coroutine model to get anywhere -- and the same is true of iterators. Are these leaky abstractions? I generally think of leaks as "pushing", but this is "pulling" -- I'd call 'em sucky abstractions. But I digress.

I just sat down with the tutorial and banged out what I consider to be a holy grail for quality of life. Alas, it's about 50 lines of ugly boilerplate. I'm omitting it, in part because it's untested, but mostly because I'm embarrassed to share that much C++ in a public forum. If anybody close to the C++ standards is listening... please make this boilerplate not suck so hard.

    template<typename T>
    struct CoroutineIterator {
      left for the reader!
      hint: start with the ReturnObject5 struct in the article,
            and add standard iterator boilerplate
    }

    //! yields the integers 0 through `stop`, except for `skip`
    CoroutineIterator<int> skip_iter(int skip, int stop) {
      for(int i=0;i<stop;i++)
        if(i != skip)
          co_yield i;
    }

    int main() {
      for(auto &i : skip_iter(15, 20)) {
        std::cout << i << std::endl;
      }
    }
So, given sibling comments on stack and function calls, this:

    skip_iter(int skip, int stop)
      for(int i=0;i<stop;i++)
Wouldn't work with complex types, boxed integers and so on? Because calling postfix ++ would be a function/method call?
Stackless coroutines can call other functions just fine (they would completely unusable otherwise). What they can't do is delegate suspension to any called function, i.e. any called function must return before the coroutine can yield as exactly one activation frame (the top level) can be suspended.
That's just a simple example. I think the following should work, possibly with modification for forwarding:

    class Stooge {...};

    CoroutineIterator<Stooge> three_stooges() {
       co_yield Stooge("Larry");
       co_yield Stooge("Curly");
       co_yield Stooge("Moe");
    }
This is all down to not having a "managed runtime", so it has to be shoehorned into the environment available where you have a stack, a heap, and that's basically it.
For me coming from Rust, this just seems way overly complicated to me. Rust's async doesn't even need a heap to work: https://lights0123.com/blog/2020/07/25/async-await-for-avr-w...
Requiring heap allocation for coroutines was extremely contentious to say the least. But because C++ does not have a borrow checker, all the other allocation-less designs proved to be very error prone.

C++ coroutines do support allocators [1], so it is not a huge issue, but it does complicate the design further.

[1] the compiler is also allowed to elide the allocation, but a) it is not clear how this is different from the normal allocation elision allowance that compilers already had, and b) it is a fragile optimization.

Does that mean that every nested coroutine (async call) needs another heap allocation, or just the top level one?
What do you mean nested call? As a first approximation, you need an heap allocation for each coroutine function instance for its activation frame. Every time a coroutine instance is suspended, the previously allocated frame is reused. If you instantiate a coroutine from another coroutine, then yes you need to heap allocate again, unless the compiler can somehow merge the activation frames or you have a dedicated allocator.
Is that a given, though? Rust's generators are decent prior art - they generate a state machine that would only require heap allocation if the size of the state machine becomes unbounded (for example, a recursive generator). Otherwise the generator is perfectly capable of being stack allocated in its entirety. This turns out to be sufficient for a large amount of programs, with a sufficient workaround for the ones where you can't (box the generator, making the allocation explicit).
Great article, thanks for sharing!
You are meant to use a library like cppcoro https://github.com/lewissbaker/cppcoro rather than building all this on your own.

But for folks working on gamedev libs, high-performance async, etc would probably prefer making their own task/promise-type for hand-crafted customization.

What exactly do you find complex? I see that it is just a blob of C++ that is difficult to read at a first glance, but if you get past that, it is actually super simple.
I hope C++ at some point gets another frontend in terms of syntax. Languages should probably be specified in terms of abstract syntax instead of being stuck with a bad frontend like C++.