-
Notifications
You must be signed in to change notification settings - Fork 187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
PoC: async_generator as a stream #596
base: main
Are you sure you want to change the base?
PoC: async_generator as a stream #596
Conversation
* limitations under the License. | ||
*/ | ||
|
||
// A lot of stuff from cppcoro |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A lot of this stuff is copied from cppcoro
. Not too sure how to reflect this here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lewissbaker, do you have any insight into how we should handle your copyrights here?
Sorry for the delay on this review; I spent some time looking at it with @janondrusek yesterday and we came up with a few high-level thoughts that I think you'll need to try to address before we can give useful detailed feedback. First, we agree with your "general feeling" re: implementing the generator in terms of an iterator-based interface. If we had two missing pieces:
then you could implement
With the above implementation, you can invoke In the absence of It's not obvious to me whether Second, I think the current
Suppose you have this:
If you suspend the process just before
When If you change
and then stop the debugger on the
so, when With your current implementation, the generator's continuation is whoever is waiting for the result of Instead, I think the generator should have an initially-empty Third, I regret the way we implemented the magic Finally, we should be able to avoid the use of an Regarding the questions in your checklist:
Agreed; looks good.
Mostly agreed. A I think unwind-on-cancellation might need to be implemented in the first draft. Without unwind-on-cancellation, I presume that awaiting a Sender that completes with
|
Putting it as draft as it will take some time :) |
@ispeters started looking into this again. I am having a little trouble instantiating an return create<T&>([this](auto& rec) {
any_receiver_ref<T&> r1{inplace_stop_token{}, &rec};
any_receiver_ref<T&> r2{get_stop_token(rec), &rec};
// below obviously doesn't work because the constructor expects 2 args
any_receiver_ref<T&> r2{&rec};
} |
What kind of errors are you getting? I managed to wrap the This would be better if you plan to actually use the caller's stop token, though. |
…or states to S/R operations
a8b7835
to
39a0dc2
Compare
Just pushed this commit, addressing some of the comments. Gonna write up a quick progress update below:
Things that still need to be done:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like where this is going! Thanks for persisting and sorry the review turn-around is so long.
// to resume the generator's coroutine_handle<> after | ||
// saving the create-receiver in the promise so we can | ||
// complete create-sender from within the generator | ||
return create<T>([this](auto& rec) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should use create<T&>
here. The values generated by this generator will all be passed to the next-receiver using code like this:
co_yield <expr>;
so the lifetime of <expr>
will span the suspend point created by the co_yield
. You can therefore efficiently and safely pass <expr>
as an lvalue reference through to the next-receiver.
return create<T>([this](auto& rec) { | |
return create<T&>([this](auto& rec) { |
// Potential problem: not sure if get_scheduler(gen.next()) would return | ||
// the right thing here. Perhaps we need a wrapper sender that also records | ||
// each next sender's scheduler and customizes get_scheduler(...) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this is a problem. P2300 has the concept of a "completion scheduler" that senders may report, but Unifex doesn't have that concept. You can't pass a sender to unifex::get_scheduler()
, only a receiver.
async_generator& operator=(async_generator&& other) noexcept { | ||
async_generator temp(std::move(other)); | ||
swap(temp); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eric Niebler taught me that you can do this in less code by receiving the argument by value.
async_generator& operator=(async_generator&& other) noexcept { | |
async_generator temp(std::move(other)); | |
swap(temp); | |
async_generator& operator=(async_generator other) noexcept { | |
swap(other); |
template <typename T> | ||
class async_generator_yield_operation { | ||
public: | ||
using value_type = std::remove_reference_t<T>; | ||
|
||
async_generator_yield_operation(std::optional<value_type> value = {}) noexcept | ||
: value_{std::move(value)} {} | ||
|
||
bool await_ready() const noexcept { return false; } | ||
|
||
template <typename Promise> | ||
void await_suspend([[maybe_unused]] unifex::coro::coroutine_handle<Promise> | ||
genCoro) noexcept { | ||
const auto& consumerSched = genCoro.promise().consumerSched_; | ||
if (unifex::get_scheduler(genCoro.promise()) != consumerSched) { | ||
genCoro.promise().rescheduleOpSt_ = unifex::connect( | ||
unifex::schedule(consumerSched), | ||
reschedule_receiver<Promise>{std::move(value_), genCoro}); | ||
unifex::start(*genCoro.promise().rescheduleOpSt_); | ||
return; | ||
} | ||
|
||
if (value_) { | ||
unifex::set_value(std::move(*genCoro.promise().receiverOpt_), *value_); | ||
} else { | ||
unifex::set_done(std::move(*genCoro.promise().receiverOpt_)); | ||
} | ||
} | ||
|
||
void await_resume() noexcept {} | ||
|
||
private: | ||
std::optional<value_type> value_; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Below, I argue that you can rely on the generator generating values with code like
co_yield <expr>;
which means that <expr>
survives across a suspend point. I think this means you can just store the address of that value here.
template <typename T> | |
class async_generator_yield_operation { | |
public: | |
using value_type = std::remove_reference_t<T>; | |
async_generator_yield_operation(std::optional<value_type> value = {}) noexcept | |
: value_{std::move(value)} {} | |
bool await_ready() const noexcept { return false; } | |
template <typename Promise> | |
void await_suspend([[maybe_unused]] unifex::coro::coroutine_handle<Promise> | |
genCoro) noexcept { | |
const auto& consumerSched = genCoro.promise().consumerSched_; | |
if (unifex::get_scheduler(genCoro.promise()) != consumerSched) { | |
genCoro.promise().rescheduleOpSt_ = unifex::connect( | |
unifex::schedule(consumerSched), | |
reschedule_receiver<Promise>{std::move(value_), genCoro}); | |
unifex::start(*genCoro.promise().rescheduleOpSt_); | |
return; | |
} | |
if (value_) { | |
unifex::set_value(std::move(*genCoro.promise().receiverOpt_), *value_); | |
} else { | |
unifex::set_done(std::move(*genCoro.promise().receiverOpt_)); | |
} | |
} | |
void await_resume() noexcept {} | |
private: | |
std::optional<value_type> value_; | |
}; | |
template <typename T> | |
class async_generator_yield_operation { | |
public: | |
using value_type = std::remove_reference_t<T>; | |
async_generator_yield_operation() noexcept | |
: value_{nullptr} {} | |
explicit async_generator_yield_operation(value_type& value) noexcept | |
: value_{&value} {} | |
bool await_ready() const noexcept { return false; } | |
template <typename Promise> | |
void await_suspend([[maybe_unused]] unifex::coro::coroutine_handle<Promise> | |
genCoro) noexcept { | |
const auto& consumerSched = genCoro.promise().consumerSched_; | |
if (unifex::get_scheduler(genCoro.promise()) != consumerSched) { | |
genCoro.promise().rescheduleOpSt_ = unifex::connect( | |
unifex::schedule(consumerSched), | |
reschedule_receiver<Promise>{value_, genCoro}); | |
unifex::start(*genCoro.promise().rescheduleOpSt_); | |
return; | |
} | |
if (value_) { | |
unifex::set_value(std::move(*genCoro.promise().receiverOpt_), *value_); | |
} else { | |
unifex::set_done(std::move(*genCoro.promise().receiverOpt_)); | |
} | |
} | |
void await_resume() noexcept {} | |
private: | |
value_type* value_; | |
}; |
|
||
template <typename Promise> | ||
struct reschedule_receiver { | ||
std::optional<typename Promise::value_type> value_; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See below for why I think this is safe.
std::optional<typename Promise::value_type> value_; | |
typename Promise::value_type* value_; |
// This is needed for at_coroutine_exit to do the async clean up | ||
friend unifex::continuation_handle<> tag_invoke( | ||
const unifex::tag_t<unifex::exchange_continuation>&, | ||
async_generator_promise& p, | ||
unifex::continuation_handle<> action) noexcept { | ||
return std::exchange(p.continuation_, std::move(action)); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is the wrong implementation for at_coroutine_exit
. Instead, the generator object should have an initially-null coroutine_handle<>
that points to the top of a stack of continuations to be awaited in cleanup()
. Invoking at_coroutine_exit
inside a generator should push the new coroutine onto the stack. Then, cleanup()
should run the stack of continuations (if it's non-empty) before completing with done.
Alternatively, perhaps the handle shouldn't be initially-null, but the result of invoking this coroutine:
nothrow_task<void> cleanup() {
co_await just_done();
}
then you always have a non-empty stack.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you mean and also your very original comment explains the same thing (some stuff starts making sense after a while with this code base... 😄).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Invoking at_coroutine_exit inside a generator should push the new coroutine onto the stack.
I am unclear how this bit we can do. at_coroutine_exit
doesn't seem to be a customization point right now. I might be missing something, but even if it is a CP, I don't see a way we can customize it on it being invoked inside a generator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not 100% certain this is going to work, but here's how I think it'll work.
at_coroutine_exit
returns a _cleanup_task<>
. _cleanup_task<>
's implementation of await_suspend
invokes exchange_continuation
on the calling coroutine's promise. So, I think you can implement exchange_continuation
in such a way that each call pushes another clean-up task onto the stack of things to resume from cleanup()
.
Hi, I reached out before Christmas to check if there are any async streams in
unifex
(#586). In the discussion within the issue, we established it'd be good to have a coroutine-based async stream, similar tocppcoro::async_generator
.In this PR, I have added a small PoC that achieves part of the requirements, listed by @ispeters (list with checbkoxes below). I would appreciate some feedback & see if you guys are interested in seeing something like this in
unifex
.A summary of the requirements for an
async_generator
+ whether or not they are addressed in this PR:Support
co_await
andco_yield
inside the generator coroutine.❗ This achieved already by
cppcoro::async_generator
Feel like a
unifex::task<>
❗ In short:
(a) resuming the generator resumes it in the scheduler it was running before suspension;
(b) resuming the consumer coroutine resumes it on the scheduler it was running before
co_await
-ing the generator.❗ Achieved via
await_transform
customization in the generator promise, similar tounifex::task<>
❗ I did not look into that. Looks like it might be too much for 1 PR.
noexcept
version of it❗ Shouldn't be too much to add to this PR. Can do, if there is interest
Ability to let callers opt-out of scheduler affinity. This is achieved in
unifex::task<>
by having thisstruct _sa_task
.❗ Have not looked into that
At a high-level, the generator supports
next()
andcleanup()
(so it is a stream in itself). In addition, I did not exposebegin()
,end()
. The reason for this: it forces clients to useiterator
-based for loops, at least without something likeco_await for_each(...)
construct in the standard. To me, iterator-based for loops are quite archaic & error-prone, so I would prefer not to expose something like that.High-level skeleton might be summarized as: