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.
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 N4402resumable
from N4453, now with a new nameasync
On that basis, the following ideas will be introduced:
- Async function and variable
- Evaluation context
- Context-based overloading
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.
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;
}
async
as function specifier is part of the function prototype.
- normal context
- constant context
- 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.
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.
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;
}
async
as context provider is not part of the function prototype.
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.
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
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.