GorNishanov / coroutines-ts

20 stars 2 forks source link

Lamda Lifetimes And Async Coroutines #32

Open jumonatr opened 6 years ago

jumonatr commented 6 years ago

In the Coroutine TS/N4736, if an async lambda coroutine captures state, the programmer needs to ensure that the lifetime of the closure is as long as the lifetime of the execution of the coroutine which can lead to easily made mistakes if the lifetime of the lambda closure object ends before the coroutine has completed.

From a library point of view if you accept a functor that returns a task, once you call that functor you can then dispose of it since it's given you its result. If that functor happens to be a coroutine either the library accepting the functor or the caller need to keep it alive until the coroutine has completed.

If the library happens to copy the functor before executing it (like putting it in a queue), the caller can no longer keep it alive, but the library still has no idea that it needs to do so until the returned task has completed. It would be a very restrictive burden to require the library implementer to do so.

The main issue here is that when creating a coroutine from a lambda, you treat them as one and the same. The programmer doesn't differentiate between the callable object and the coroutine, yet the coroutine will outlive the callable in most cases since it merely starts it.

This makes the ability to create an async coroutine from a lambda more of a trap than a feature. It would require the caller to know of these pitfalls (and the internals of the function they’re calling into) and either create a wrapper callable to move the user functor onto the stack of the coroutine to get around this issue (and pay the cost of wrapping a coroutine in another coroutine), or go back to a much more verbose free function and std::bind with all the pitfalls that those introduce (which lambdas fixed).

template<typename T>
auto launch(T handler)
{
    …
    handler();
    …
}

int x;
launch([x]() -> task<void>
{
    … // X is safe to access here since closure object is still alive
    co_await …; // on suspend will return back to the caller that will destroy the closure object
    … // X is no longer safe to access
});

// it becomes a lot harder to define private async coroutines
// (something I've seen used quite often in other languages)
launch([this]() -> task<void>
{
    …
    co_await …;
    … // _this_ is now corrupt
});
GorNishanov commented 6 years ago

Thank you for filing the issue. I think there are three possible behaviors with regard to interactions of the captures and coroutines.

1) capture is accessible to a coroutine by reference (current model) 2) capture is moved to coroutine, when operator() is invoked. The lambda becomes one shot wonder. 3) capture is copied into the coroutine

If we would like to allow all three possibilities, one needs to be the default. Two others need to have a syntactic markers to chose the desired behavior.

Currently, the default is most efficient, but, most error prone (which is frequently the default in C++). We can flip it around and choose safety first. So questions:

1) which ones should be the default? 2) what kind of syntax to use to override the default?

GorNishanov commented 6 years ago

One incarnation of a resolution to this issue could be: Theme: Default favors safety over efficiency

Other aspects to consider. Does the mode of capture itself (namely capturing by & or by =) affects whether a coroutine copies, moves or refers to the capture by reference.