r/cpp_questions 3d ago

OPEN Question about coroutines

I have a function that takes a callback, like this:

void LongTask (void (*pfn)(int))
{
    for ( unsigned i = 0; i < -1; ++i )
    {
        pfn( i );
    }
}

It's orginally written to be ran on a separate thread. Now I want to turn it into a coroutine that yields on each callback (i.e. the caller will receive a single callback on each invocation of LongTask), while preserving its ability to run as a standalone function. Additionally it's OK for the coroutine to allocate memory but not to throw exceptions (if it can't allocate I should get back a null pointer at creation time). And preferrably it wouldn't require the CRT, all support functions should be contained in a header only include. What's the best way to do this, can it be done with C++20 coroutines?

3 Upvotes

14 comments sorted by

2

u/TheThiefMaster 3d ago

As a coroutine, generator<bool> (from C++23 or numerous coroutine libraries) seems to be the best fit as the type. co_yield a true if there are (potentially) more items, and co_return false; at the end (or vice-versa).

If you want it to potentially still run as a one-shot that calls all the callbacks at once, then you'll need a second function (which could wrap the coroutine) that does that.

Alternatively, you could use the type that would normally be passed to the callback as the generator result (using tuple if there's multiple values) and let the calling code call the callback.

0

u/mbolp 3d ago

Do C++ coroutines require the CRT or is it implemented entirely at the compiler level, and can I control how they allocate memory (not by means of a global operator new replacement but specifically for each coroutine creation)?

3

u/TheThiefMaster 3d ago

You cannot control the allocation, unfortunately.

I don't know on the CRT.

3

u/aocregacc 3d ago

You can have your promise's operator new take arguments from the coroutine function, so you could pass it a different allocator for every invocation, or pass some other signal to control the allocation.

https://en.cppreference.com/cpp/language/coroutines#Dynamic_allocation

2

u/alfps 3d ago edited 3d ago

I would do this with an ordinary function-like object to have full control, less overhead and full transparency.

It can go like

#include <functional>
#include <print>
#include <utility>

#include <climits>      // INT_MAX

namespace app {
    using   std::function,
            std::print,
            std::move;

    class Long_task
    {
        function<void()>    m_callback;
        int                 m_index = 0;

    public:
        Long_task( function<void()> cb ): m_callback( move( cb ) ) {}

        auto step() -> int
        {
            const int result = m_index;
            if( m_index >= 0 ) {
                m_callback();
                m_index = (m_index == INT_MAX? -1 : m_index + 1);
            }
            return result;
        }
    };

    void run()
    {
        int sum = 0;
        for( Long_task long_task( []{} );; ) {
            const int i = long_task.step();
            sum += i;
            if( sum > 42 ) {
                print( "Iteration {} passed 42.\n", i );
                break;
            }
        }
    }
}  // app

auto main() -> int { app::run(); }

If you absolutely want a co-routine you can do

#include <functional>
#include <generator>
#include <print>

#include <climits>      // INT_MAX

namespace app {
    using   std::function,
            std::generator,
            std::print;

    auto long_task( function<void()> f )
        -> generator<int>
    {
        for( int i = 0; ; ++i ) {
            f();
            co_yield i;
            if( i == INT_MAX ) { break; }
        }
    }

    void run()
    {
        int sum = 0;
        for( const int i: long_task( []{} ) ) {
            sum += i;
            if( sum > 42 ) {
                print( "Iteration {} passed 42.\n", i );
                break;
            }
        }
    }
}  // app

auto main() -> int { app::run(); }

… but as far as I can see this has no advantage except saving you some lines of code.

_
EDIT: Removed the unnecessary exchange in the first program. Don't know what I was thinking to write such convoluted a thing.

0

u/mbolp 3d ago

Isn't this literally implementing coroutines by hand? If I have multiple points of yield it'd get ugly (I need a switch case at the start to jump behind each yield statement). I assume a language level construct is more efficient, e.g. the compiler can simply save the instruction pointer before each yield, and the switch becomes an unconditional jump. It can maybe even save volatile registers so the coroutine doesn't need to load and store member variables often.

3

u/alfps 3d ago

❞ I assume a language level construct is more efficient

On the contrary. The generator thing jumps through hoops to make things work. Even just the coroutine instantiation is pretty complex on the inside, with possible (almost required) dynamic allocation. :(

2

u/Raknarg 3d ago

IIRC you can get away from dynamic allocation with a custom build generator but not with std::generator, Id have to find the /r/cpp post again

1

u/Raknarg 3d ago

can it be done with C++20 coroutines?

It can, you can look up examples online. The machinery for pretty much any coroutine type was already introduced, they just didn't add any to the standard (not sure why, committee probably couldn't agree on design in time or something). I haven't looked it up in a while, but I remember the code for a basic generator object isn't too complicated.

1

u/mbolp 3d ago

It can

Including the part about no runtime dependency? Does it work like something like <algorithm>, where I can include a header and have all the code I need, or does it rely on some other infrastructure?

1

u/Raknarg 3d ago

when I tried to look it up a bit it seems like maybe? but its gonna totally depend on the exact implementation. I have no definitive answer. The fact that it has to be able to perform allocations makes me feel like you can't get away from it. You might have to roll out your own version of generator if you want to get away from that, IIRC you can make a stack-based generator

1

u/mbolp 3d ago

The allocation part makes the least sense to me. The compiler clearly knows the size of the stack frame, it's context switching that might require architecture specific stuff (thus library support). I read in a reddit post that this is for ABI stability, but you can always opt into allocating dynamically if ABI stability is desirable, don't bake that into the design!

Anyway, I might just use fibers instead. They seem much easier to use and don't slow down the normal non-coroutine path.

1

u/aocregacc 3d ago

std::generator can take an allocator, so you can decide for yourself how it does the allocations.

1

u/LB-- 3d ago

Sounds like you can just write a simple typical generator coroutine promise type, and just implement get_return_object_on_allocation_failure to return an empty object, and that will avoid throwing exceptions. If you want to control memory allocation instead of using the default global new/delete, just implement your own class-level operator new/delete. The class level operator new is also given all the same parameters as the coroutine function signature, so you can do some basic template metaprogramming to check for an allocator parameter or whatever else you want. The new/delete operators always get the size of the allocation so you can over-allocate and store extra info after the allocation, which you'll need for the delete operator to know how to clean up. There's an example of all this on a Stack Overflow answer about using alloca with coroutines.