Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jamboree/a2b3fe32eeb8c21e820c to your computer and use it in GitHub Desktop.
Save jamboree/a2b3fe32eeb8c21e820c to your computer and use it in GitHub Desktop.

Introduction

So far, the most prominent proposal for stackless coroutine is Resumable Functions(N4402). Still, there's another novel proposal Resumable Expressions(N4453) using a totally different approach.

Resumable Functions provides a powerful and extensible mechanism for coroutine through the concepts of Coroutine Promise and Awaitable:

CoroutineResult<T> do_something()
{
    await getSomeAwaitable();
    ...
    return someT;
}

This approach uses await as the building-block, so I'd call it await-first approach.

The obvious downside of Resumable Functions is that the user has to spread the await keyword across the codes to make the functions resumable, that is, your async/non-async versions of functions cannot share the same code, despite that they may have the same structure and logic. Manual await marking causes a big concern in code reusability.

Resumable Expressions, on the other hand, aims for the unification of async/non-async codes via automatic resumability propagation, so the async version can share the same source code as the non-async version, and any existent generic code can be reused.

resumable void do_something()
{
    ...
    if (needs_to_wait)
        break resumable;
    ...
}

Here the break resumable is merely a suspend-point (aka. yield), so I'd call it yield-first approach. Compared to await-first approach, which uses higher-level await for async invocation, the yield-first approach is lower-level and only accounts for suspension. It's true that higher-level constructs can be built on lower-level constructs, as the author of Resumable Expressions did show a way to implement await in a library manner, but the solution is quite convoluted and wouldn't be as efficient as the await-first approach.

Design

This draft tries to draw the best from both of the proposals, it won't demolish the previous proposals, so many of the concepts from previous proposals will be kept or revamped, such as:

  • Coroutine Promise and Awaitable concepts from N4402
  • await/yield from N4402
  • resumable from N4453, now with a new name async

On that basis, the following ideas will be introduced:

  • Async function and variable
  • Evaluation context
  • Context-based overloading

Async function and variable

The keyword async is used to specify that the value of a variable or function can appear in async expressions. A function or variable that is marked as async is said to be async-capable, meaning that it can be used in both async and non-async context. Lambda expression is automatically considered async-capable.

If an async-capable function or variable is evaluated in non-async context, then async has no effect.

An async function has the following restrictions:

  • it must not be virtual
  • its address cannot be taken when evaluated in async context
  • the definition must be visible to the translation unit

An async variable has the following restrictions:

  • it must be immediately constructed or assigned a value.

An async variable is automatically awaited on used, i.e. when its address or value is taken or when it goes out of scope.

Example

async T get_something(...)
{
    ...
}

T compute(T); // non-async function

async T do_something(...); // forward-declaration

async T do_something(...)
{
    async T a = compute(get_something(...)); // async task for 'a' is created behind the scene
    T b = compute(get_something(...)); // can perform before 'a' completes
    return a + b;
}

The same example written in N4402 would be:

std::future<T> get_something(...)
{
    ...
}

T compute(T); // non-async function

std::future<T> do_something(...); // forward-declaration

std::future<T> do_something(...)
{
    std::future<T> a = [&]{ return compute(await get_something(...)); }();
    T b = compute(await get_something(...));
    return (await a) + b;
}

Note

async as function specifier is part of the function prototype.

Evaluation context

  1. normal context
  2. constant context
  3. async context

C++ already has the first two evaluation contexts, the third one is newly introduced in this draft.

A function that is marked as constexpr can be evaluated in both normal and constant context, and a function that is marked as async can be evaluated in both async and normal context. A function that is marked as async constexpr can be evaluated in async, normal and constant context.

Async context provider

The async context provider async is used in non-async function to provide the async context, it shares the same keyword as async specifier, but they appear in different positions.

An async function should not have an async context provider.

Example

async T get_something(...)
{
    ...
}

T compute(T); // non-async function

std::future<T> do_something(...); // forward-declaration

std::future<T> do_something(...) async
{
    async T a = compute(get_something(...));
    T b = compute(get_something(...));
    return a + b;
}

Note

async as context provider is not part of the function prototype.

Context-based overloading

In the past, overload resolution only depends on the arguments, this draft introduces a new rule for the process to also depend on the evaluation context.

async function specifier has a second form async(expression), which accepts a constant expression that contextually convertible to bool.

A function without async specifier or with async(expression) where expression evaluates to false is recognized as non-async, a function with async(expression) where expression evaluates to true is recognized as truly-async.

Context-based overloading can select between the non-async and truly-async overloads of the function. The process is done after the normal argument-based overload resolution. If after context-based overload resolution there are more than one overloads left the result is ambiguous. That is, an async-capable function cannot be overloaded with truly-async or non-async ones.

Example

template<class T>
T get(std::future<T>& f)
{
    return f.get();
}

template<class T>
async(true) T get(std::future<T>& f)
{
    return await f;
}

async T do_something(...)
{
    std::future<T> f1 = ...;
    std::future<T> f2 = ...;
    ...
    return compute(get(f1), get(f2), ...);
}

std::future<T> do_async(...) async
{
    return do_something(...);
}

T do_sync(...)
{
    return do_something(...);
}

The same code do_something can be used to generate both async and sync versions. Actually, we can have a general spawn function that introduces async context for us:

template<class F>
inline std::future<std::result_of_t<F()>> spawn(F f) async
{
    return f();
}

Now writing both async & sync call is simple:

auto fut = spawn([=]{ return do_something(...); }); // async
auto val = do_something(...); // sync

Other Issues

Recursion

Unlike stackfull coroutine, which has its own stack, stackless coroutine can only have a fixed-sized frame where the size is calculated in compile-time, that means it can't handle recursion naturally. But we can still support recursion, by resorting to abstraction and dynamic allocation. For optimization, the compiler can pre-allocate the frame up to some recursion depth or some size, then resort to dynamic allocation when the space is exhausted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment