I am trying to write a simple round-robin scheduler for coroutines. My simplified code is the following:
Generator<uint64_t> Counter(uint64_t i) {
for (int j = 0; j < 2; j++) {
co_yield i;
}
}
...
void RunSimple(uint64_t num_coroutines) {
std::vector<std::pair<uint64_t, Generator<uint64_t>>> gens;
for (uint64_t i = 0; i < num_coroutines; i++) {
// Storing coroutines into a vector is safe because we implemented the move
// constructor and move assigment operator for Generator.
gens.push_back({i, Counter(i)});
}
// This is a simple round-robin scheduler for coroutines.
while (true) {
for (auto it = gens.begin(); it != gens.end();) {
uint64_t i = it->first;
Generator<uint64_t>& gen = it->second;
if (gen) {
printf("Coroutine %lu: %lu.\n", i, gen());
it++;
} else {
// `gen` has finished, so remove its pair from the vector.
it = gens.erase(it);
}
}
// Once all of the coroutines have finished, break.
if (gens.empty()) {
break;
}
}
}
When I run RunSimple(/*num_coroutines=*/4)
, I get the following output:
Coroutine 0: 0.
Coroutine 1: 1.
Coroutine 2: 2.
Coroutine 3: 3.
Coroutine 0: 0.
Coroutine 1: 1.
Coroutine 2: 2.
Coroutine 3: 3.
Coroutine 1: 1.
Coroutine 3: 3.
Coroutine 3: 3.
The last three lines of the output are unexpected... coroutines 1 and 3 do not seem to be exiting when I expect them to. Upon further investigation, this is happening because std::coroutine_handle<Promise>::done()
is returning false for both of these coroutines. Do you know why this method is returning false... am I doing something wrong?
Edit: Here is my Generator
implementation:
template <typename T>
struct Generator {
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
struct promise_type {
T value_;
std::exception_ptr exception_;
Generator get_return_object() {
return Generator(handle_type::from_promise(*this));
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { exception_ = std::current_exception(); }
template <std::convertible_to<T> From> // C++20 concept
std::suspend_always yield_value(From&& from) {
value_ = std::forward<From>(from);
return {};
}
void return_void() {}
};
handle_type h_;
Generator(handle_type h) : h_(h) {
}
Generator(Generator&& g) : h_(std::move(g.h_)) { g.h_ = nullptr; }
~Generator() {
if (h_) {
h_.destroy();
}
}
Generator& operator=(Generator&& g) {
h_ = std::move(g.h_);
g.h_ = nullptr;
return *this;
}
explicit operator bool() {
fill();
return !h_.done();
}
T operator()() {
fill();
full_ = false;
return std::move(h_.promise().value_);
}
private:
bool full_ = false;
void fill() {
if (!full_) {
h_();
if (h_.promise().exception_)
std::rethrow_exception(h_.promise().exception_);
full_ = true;
}
}
};
Your program contains undefined behavior.
The issue is your
fill
resumes the coroutine when!full_
, regardless of whether the coroutine is atfinal suspend
or not, which you can learn by callingh_.done()
.If you resume the coroutine at the final suspend point, it will destory itself and you can no longer do anything with the coroutine handle you have.
And you call
fill
fromoperator bool
, meaning that it, when called while the coroutine is suspended at final suspend, first destroys the coroutine, and then tries to access it, which is UB:You could fix this by making
fill
aware ofdone
ness:Also, your move assignment operator leaks the current coroutine:
You probably want to have something like
if (h_) h_.destroy();
in the beginning.Also, as mentioned in the comments, the
full_
member has to be carried over in the move constructor and assignment operators: