Помимо использования корутин для создания генераторов, их можно попробовать использовать для линеаризации уже существующего асинхронного кода. Давайте попробуем это сделать на небольшом примере. Возьмем код, написанный на акторном фреймворке и перепишем одну функцию этого кода на корутины. Для сборки проекта будем использовать gcc из ветки coroutines.
Наша цель — получить из лапши коллбэков:
abActor.getA(ABActor::GetACallback([this](int a) {
abActor.getB(ABActor::GetBCallback([a, this](int b) {
abActor.saveAB(a - b, a + b, ABActor::SaveABCallback([this](){
abActor.getA(ABActor::GetACallback([this](int a) {
abActor.getB(ABActor::GetBCallback([a, this](int b) {
std::cout << "Result " << a << " " << b << std::endl;
}));
}));
}));
}));
}));
Что-то вроде:
const int a = co_await actor.abActor.getAAsync();
const int b = co_await actor.abActor.getBAsync();
co_await actor.abActor.saveABAsync(a - b, a + b);
const int newA = co_await actor.abActor.getAAsync();
const int newB = co_await actor.abActor.getBAsync();
std::cout << "Result " << newA << " " << newB << std::endl;
Итак, приступим.
Акторы
Для начала нам нужно создать простенький акторный фреймворк. Создание полноценного акторного фреймворка — непростая и большая задача, поэтому мы реализуем лишь некое его подобие.
Для начала создадим базовый класс:
class Actor {
public:
using Task = std::function<void()>;
public:
virtual ~Actor();
public:
void addTask(const Task &task);
void tryRunTask();
private:
std::queue<Task> queue;
mutable std::mutex mutex;
};
Идея в принципе проста: мы помещаем задачи, являющиеся функциональными объектами, в очередь, и по вызову tryRunTask пытаемся выполнить эту задачу. Реализация класса подтверждает наши намерения:
Actor::~Actor() = default;
void Actor::addTask(const Task &task) {
std::lock_guard lock(mutex);
queue.push(task);
}
void Actor::tryRunTask() {
std::unique_lock lock(mutex);
if (queue.empty()) {
return;
}
const Task task = queue.front();
queue.pop();
lock.unlock();
std::invoke(task);
}
Следующий класс — это «тред», к которому будет принадлежать наши акторы:
class Actor;
class ActorThread {
public:
~ActorThread();
public:
void addActor(Actor &actor);
void run();
private:
std::vector<std::reference_wrapper<Actor>> actors;
};
Тут тоже все просто: в самом начале программы мы «привязываем» наши акторы к треду методом addActor, а потом запускаем тред методом run.
ActorThread::~ActorThread() = default;
void ActorThread::addActor(Actor &actor) {
actors.emplace_back(actor);
}
void ActorThread::run() {
while (true) {
for (Actor &actor: actors) {
actor.tryRunTask();
}
}
}
При запуске «треда», мы входим в бесконечный цикл и пытаемся выполнить по одной задаче с каждого актора. Не самое оптимальное решение, но для демонстрации пойдет.
Теперь давайте рассмотрим представителя класса акторов:
class ABActor: public Actor {
public:
using GetACallback = Callback<void(int result)>;
using GetBCallback = Callback<void(int result)>;
using SaveABCallback = Callback<void()>;
public:
void getA(const GetACallback &callback);
void getB(const GetBCallback &callback);
void saveAB(int a, int b, const SaveABCallback &callback);
private:
void getAProcess(const GetACallback &callback);
void getBProcess(const GetBCallback &callback);
void saveABProcess(int a, int b, const SaveABCallback &callback);
private:
int a = 10;
int b = 20;
};
Этот класс хранит в себе 2 числа — a и b, и по запросу выдает их значения или перезаписывает.
В качестве коллбэка он принимает функциональный объект с необходимыми параметрами. Но давайте обратим внимание на то, что разные акторы могут быть запущены в разных потоках. И поэтому, если по окончании работы мы просто вызовем переданный в метод коллбэк, этот коллбэк будет вызван в текущем выполняемом треде, а не в том треде, что вызвал наш метод и создал этот коллбэк. Поэтому нам нужно создать обертку над коллбэком, которая разрулит эту ситуацию:
template<typename C>
class Callback {
public:
template<typename Functor>
Callback(Actor &sender, const Functor &callback)
: sender(sender)
, callback(callback)
{}
public:
template<typename ...Args>
void operator() (Args&& ...args) const {
sender.addTask(std::bind(callback, std::forward<Args>(args)...));
}
private:
Actor &sender;
std::function<C> callback;
};
Эта обертка запоминает исходный актор и при попытке выполнить себя просто добавляет настоящий коллбэк в очередь задач исходного актора.
В результате, реализация класса ABActor выглядит так:
void ABActor::getA(const GetACallback &callback) {
addTask(std::bind(&ABActor::getAProcess, this, callback));
}
void ABActor::getAProcess(const ABActor::GetACallback &callback) {
std::invoke(callback, a);
}
void ABActor::getB(const GetBCallback &callback) {
addTask(std::bind(&ABActor::getBProcess, this, callback));
}
void ABActor::getBProcess(const ABActor::GetBCallback &callback) {
std::invoke(callback, b);
}
void ABActor::saveAB(int a, int b, const SaveABCallback &callback) {
addTask(std::bind(&ABActor::saveABProcess, this, a, b, callback));
}
void ABActor::saveABProcess(int a, int b, const ABActor::SaveABCallback &callback) {
this->a = a;
this->b = b;
std::invoke(callback);
}
В интерфейсном методе класса мы просто биндим переданные аргументы к соответствующему «слоту» класса, создавая таким образом задачу, и помещаем эту задачу в очередь задач этого класса. Когда тред задач начнет выполнять задачу, он таким образом вызовет правильный «слот», который выполнит все необходимые ему действия и вызовет коллбэк, который в свою очередь отдаст настоящий коллбэк в очередь вызвавшей задачи.
Давайте напишем актора, который будет использовать класс ABActor:
class ABActor;
class WokrerActor: public Actor {
public:
WokrerActor(ABActor &actor)
: abActor(actor)
{}
public:
void work();
private:
void workProcess();
private:
ABActor &abActor;
};
void WokrerActor::work() {
addTask(std::bind(&WokrerActor::workProcess, this));
}
void WokrerActor::workProcess() {
abActor.getA(ABActor::GetACallback(*this, [this](int a) {
std::cout << "Result " << a << std::endl;
}));
}
И соберем все это вместе:
int main() {
ABActor abActor;
WokrerActor workerActor(abActor);
ActorThread thread;
thread.addActor(abActor);
thread.addActor(workerActor);
workerActor.work();
thread.run();
}
Давайте проследим всю цепочку работы кода.
В начале, мы создаем необходимые объекты и устанавливаем связи между ними.
Потом мы добавляем задачу workProcess в очередь задач Worker актора.
Когда тред запустится, он обнаружит в очереди нашу задачу и начнет ее выполнять.
В процессе выполнения, мы вызовем метод getA класса ABActor, тем самым положив соответствующую задачу в очередь класса ABActor, и завершим выполнение.
Дальше тред возьмет только что созданную задачу из класса ABActor, и выполнит ее, что приведет к выполнению кода getAProcess.
Этот код вызовет коллбэк, передав в него нужный аргумент — переменную a. Но так как коллбэк, которым он владеет, это обертка, то на самом деле настоящий коллбэк с заполненными параметрами положится в очередь класса Worker.
И когда на следующей итерации цикла тред вытащит и исполнит наш коллбэк из класса Worker, мы увидим вывод на экран строки «Result 10»
Акторный фреймворк — довольно удобный способ взаимодействия классов, раскиданных по разным физическим потокам, друг с другом. Особенность проектирования классов, как вы должны были в этом убедиться, в том, что внутри каждого отдельного актора все действия выполняются целиком и полностью в единственном потоке. Единственная точка синхронизации потоков вынесена в детали реализации акторного фреймворка и не видна программисту. Таким образом, программист может писать однопоточный код, не заботясь обкладыванием мьютексами и отслеживанием ситуаций гонок, deadlock-ов и прочей головной боли.
К сожалению, у такого решения есть своя цена. Так как результат выполнения другого актора доступен только из коллбэка, то рано или поздно акторный код превращается в нечто такое:
abActor.getA(ABActor::GetACallback(*this, [this](int a) {
abActor.getB(ABActor::GetBCallback(*this, [a, this](int b) {
abActor.saveAB(a - b, a + b, ABActor::SaveABCallback(*this, [this](){
abActor.getA(ABActor::GetACallback(*this, [this](int a) {
abActor.getB(ABActor::GetBCallback(*this, [a, this](int b) {
std::cout << "Result " << a << " " << b << std::endl;
}));
}));
}));
}));
}));
Давайте посмотрим, сможем ли мы этого избежать, используя нововведение C++20 — корутины.
Но сначала оговорим ограничения.
Естественно, мы никоим образом не можем менять код акторного фреймворка. Также, мы не можем менять сигнатуры публичных и приватных методов экземпляров класса Actor — ABActor и WorkerActor. Посмотрим, сможем ли мы выкрутиться из этой ситуации.
Корутины. Часть 1. Awaiter
Основная идея корутин — что при создании корутин для нее создается отдельный стековый фрейм на куче, из которого мы в любой момент можем «выйти», сохранив при этом текущую позицию выполнения, регистры процессора и другую необходимую информацию. Потом мы также в любой момент можем вернуться к выполнению приостановленной корутины и выполнить ее до конца или до следующей приостановки.
За управлением этими данными отвечает объект std::coroutine_handle<>, который по сути представляет указатель на стековый фрейм (и другие необходимые данные), и у которого есть метод resume (или его аналог, оператор ()), который возвращает нас к выполнению корутины.
Давайте на основе этих данных сначала напишем функцию getAAsync, а потом попробуем обобщить.
Итак, предположим, что у нас уже есть экземпляр класса std::coroutine_handle<> coro, что нам нужно сделать?
Необходимо вызвать уже существующий метод ABActor::getA, который разрулит ситуацию как нужно, но для начала необходимо создать для метода getA коллбэк.
Давайте вспомним, в коллбэк метода getA возвращается число — результат выполнения метода getA. Причем этот коллбэк вызывается в потоке Worker треда. Таким образом, из этого коллбэка мы можем безопасно продолжить выполнять корутину, которая была создана как раз из треда Worker-а и которая продолжит выполнять свою последовательность действий. Но также мы должны куда-то сохранить результат возвращенный в коллбэке, он нам, естественно, дальше пригодится.
auto callback = GetACallback(returnCallbackActor, [&value, coro](int result) {
value = result;
std::invoke(coro);
});
getA(callback);
Итак, теперь нужно откуда-то взять экземпляр объекта coroutine_handle и ссылку, куда можно сохранить наш результат.
В дальнейшем мы увидим, что coroutine_handle передается к нам в результате вызова функции. Соответственно, все что мы можем с ним сделать, передать его в какую-то другую функцию. Давайте подготовим эту функцию в виде лямбды. (Ссылку на переменную, где будет храниться результат выполнения коллбэка, передадим в эту функцию за компанию).
auto storeCoroToQueue = [&returnCallbackActor, this](auto &value, std::coroutine_handle<> coro) {
auto callback=GetACallback(returnCallbackActor, [&value, coro](int result){
value = result;
std::invoke(coro);
});
getA(callback);
};
Эту функцию мы сохраним в следующем классе.
struct ActorAwaiterSimple {
int value;
std::function<void(int &value,std::coroutine_handle<>)> forwardCoroToCallback;
ActorAwaiterSimple(
const std::function<void(int &value, std::coroutine_handle<>)> &forwardCoroToCallback
)
: forwardCoroToCallback(forwardCoroToCallback)
{}
ActorAwaiterSimple(const ActorAwaiterSimple &) = delete;
ActorAwaiterSimple& operator=(const ActorAwaiterSimple &) = delete;
ActorAwaiterSimple(ActorAwaiterSimple &&) = delete;
ActorAwaiterSimple& operator=(ActorAwaiterSimple &&) = delete;
// ...
Помимо функционального объекта, мы также будем здесь держать память (в виде переменной value) под ожидающее нас в коллбэке значение.
Так как мы здесь держим память под значение, то вряд ли мы хотим, чтобы экземпляр этого класса куда-то скопировался или переместился. Представьте, что например кто-то скопировал этот класс, сохранил значение под переменную value в старом экземпляре класса, а потом попытался прочитать его из нового экземпляра. А его там естественно нет, так как копирование произошло раньше сохранения. Неприятно. Поэтому оградим себя от этой неприятности, запретив конструкторы и операторы копирования и перемещения.
Давайте продолжим писать этот класс. Следующий метод, который нам нужен, это:
bool await_ready() const noexcept {
return false;
}
Он отвечает на вопрос, готово ли наше значение для того, чтобы быть выдано. Естественно, при первом вызове наше значение еще не готово, а в дальнейшем нас никто спрашивать об этом не будет, поэтому просто вернем false.
Экземпляр coroutine_handle нам будет передан в методе void await_suspend(std::coroutine_handle<> coro), так что давайте в нем вызовем наш подготовленный функтор, передав туда также ссылку на память под value:
void await_suspend(std::coroutine_handle<> coro) noexcept {
std::invoke(forwardCoroToCallback, std::ref(value), coro);
}
Результат выполнения функции в нужный момент нас попросят, вызвав метод await_resume. Не будем отказывать просящему:
int await_resume() noexcept {
return value;
}
Теперь наш метод можно вызывать, используя ключевое слово co_await:
const int a = co_await actor.abActor.getAAsync(actor);
Что здесь произойдет, мы уже примерно представляем.
Сначала создастся объект типа ActorAwaiterSimple, который передастся на «вход» co_await-у. Он сначала поинтересуется (вызвав await_ready), нет ли у нас случайно уже готового результата (у нас нет), после чего вызовет await_suspend, передав в него контекст (по сути, указатель на текущий стековый фрейм корутины) и прервет выполнение.
В дальнейшем, когда актор ABActor выполнит свою работу и вызовет коллбэк с результатом, этот результат (уже в треде потока Worker) сохранится в единственный (оставшийся на стеке корутины) экземпляр ActorAwaiterSimple и запустится продолжение корутины.
Корутина продолжит выполнение, возьмет сохраненный результат, вызвав метод await_resume, и передаст этот результат в переменную a
На данный момент ограничение текущего Awaiter-а в том, что он умеет работать только с коллбеками с одним параметром типа int. Давайте попробуем расширить применение Awaiter-а:
template<typename... T>
struct ActorAwaiter {
std::tuple<T...> values;
std::function<void(std::tuple<T...> &values, std::coroutine_handle<>)> storeHandler;
ActorAwaiter(const std::function<void(std::tuple<T...> &values, std::coroutine_handle<>)> &storeHandler)
: storeHandler(storeHandler)
{}
ActorAwaiter(const ActorAwaiter &) = delete;
ActorAwaiter& operator=(const ActorAwaiter &) = delete;
ActorAwaiter(ActorAwaiter &&) = delete;
ActorAwaiter& operator=(ActorAwaiter &&) = delete;
bool await_ready() const noexcept {
return false;
}
void await_suspend(std::coroutine_handle<> coro) noexcept {
std::invoke(storeHandler, std::ref(values), coro);
}
// Фиктивный параметр bool B здесь нужен,
// так как sfinae не работает не на шаблонных функциях
template<
bool B=true,size_t len=sizeof...(T),std::enable_if_t<len==0 && B, int>=0
>
void await_resume() noexcept {
}
// Фиктивный параметр bool B здесь нужен,
// так как sfinae не работает не на шаблонных функциях
template<
bool B=true,size_t len=sizeof...(T),std::enable_if_t<len==1 && B, int>=0
>
auto await_resume() noexcept {
return std::get<0>(values);
}
// Фиктивный параметр bool B здесь нужен,
// так как sfinae не работает не на шаблонных функциях
template<
bool B=true,size_t len=sizeof...(T),std::enable_if_t<len!=1 && len!=0 && B, int>=0
>
std::tuple<T...> await_resume() noexcept {
return values;
}
};
Здесь мы пользуемся std::tuple для того, чтобы иметь возможность сохранить сразу несколько переменных.
На метод await_resume наложен sfinae для того, чтобы можно было не возвращать во всех случаях tuple, а в зависимости от количества значений, лежащих в tuple, возвращать void, ровно 1 аргумент или tuple целиком.
Обертки для создания самого Awaiter-а теперь выглядит так:
template<typename MakeCallback, typename... ReturnArgs, typename Func>
static auto makeCoroCallback(const Func &func, Actor &returnCallback) {
return [&returnCallback, func](auto &values, std::coroutine_handle<> coro) {
auto callback = MakeCallback(returnCallback, [&values, coro](ReturnArgs&& ...result) {
values = std::make_tuple(std::forward<ReturnArgs>(result)...);
std::invoke(coro);
});
func(callback);
};
}
template<typename MakeCallback, typename... ReturnArgs, typename Func>
static ActorAwaiter<ReturnArgs...> makeActorAwaiter(const Func &func, Actor &returnCallback) {
const auto storeCoroToQueue = makeCoroCallback<MakeCallback, ReturnArgs...>(func, returnCallback);
return ActorAwaiter<ReturnArgs...>(storeCoroToQueue);
}
ActorAwaiter<int> ABActor::getAAsync(Actor &returnCallback) {
return makeActorAwaiter<GetACallback, int>(std::bind(&ABActor::getA, this, _1), returnCallback);
}
ActorAwaiter<int> ABActor::getBAsync(Actor &returnCallback) {
return makeActorAwaiter<GetBCallback, int>(std::bind(&ABActor::getB, this, _1), returnCallback);
}
ActorAwaiter<> ABActor::saveABAsync(Actor &returnCallback, int a, int b) {
return makeActorAwaiter<SaveABCallback>(std::bind(&ABActor::saveAB, this, a, b, _1), returnCallback);
}
Теперь давайте разберемся, как воспользоваться созданным типом непосредственно в корутине.
Корутины. Часть 2. Resumable
С точки зрения C++, корутиной считается функция, которая содержит в себе слова co_await, co_yield или co_return. Но также такая функция должна возвращать определенный тип. Мы условились, что не будем менять сигнатуру функций (здесь я подразумеваю, что возвращаемый тип тоже относится к сигнатуре), поэтому придется как-то выкручиваться.
Давайте создадим лямбду-корутину и вызовем ее из нашей функции:
void WokrerActor::workProcess() {
const auto coroutine = [](WokrerActor &actor) -> ActorResumable {
const int a = co_await actor.abActor.getAAsync(actor);
const int b = co_await actor.abActor.getBAsync(actor);
co_await actor.abActor.saveABAsync(actor, a - b, a + b);
const int newA = co_await actor.abActor.getAAsync(actor);
const int newB = co_await actor.abActor.getBAsync(actor);
std::cout << "Result " << newA << " " << newB << std::endl;
};
coroutine(*this);
}
(Почему не захватить this в capture-list лямбды? Тогда весь код внутри вышел бы чуть проще. Но так получилось, что, видимо, лямбда-корутины в компиляторе пока поддерживаются не полностью, поэтому такой код работать не будет.)
Как видите, наш страшный код на коллбэках превратился теперь в довольно приятный линейный код. Все, что нам осталось, это изобрести класс ActorResumable
Давайте посмотрим на него.
struct ActorResumable {
struct promise_type {
using coro_handle = std::coroutine_handle<promise_type>;
auto get_return_object() {
// Стандартное заклинание, чтобы создать объект ActorResumable из объекта promise_type
return coro_handle::from_promise(*this);
}
auto initial_suspend() {
// Не приостанавливать выполнение после подготовки корутины
return std::suspend_never();
}
auto final_suspend() {
// Не приостанавливать выполнение перед завершением корутины.
// Также, выполнить действия по очистке корутины
return std::suspend_never();
}
void unhandled_exception() {
// Для простоты считаем, что исключений изнутри корутины выбрасываться не будет
std::terminate();
}
};
ActorResumable(std::coroutine_handle<promise_type>) {}
};
Псевдокод сгенерированной корутины из нашей лямбды выглядит примерно следующим образом:
ActorResumable coro() {
promise_type promise;
ActorResumable retobj = promise.get_return_object();
auto intial_suspend = promise.initial_suspend();
if (initial_suspend == std::suspend_always) {
// yield
}
try {
// Наша программа.
const int a = co_await actor.abActor.getAAsync(actor);
std::cout << "Result " << a << std::endl;
} catch(...) {
promise.unhandled_exception();
}
final_suspend:
auto final_suspend = promise.final_suspend();
if (final_suspend == std::suspend_always) {
// yield
} else {
cleanup();
}
Это всего лишь псевдокод, некоторые вещи намеренно упрощены. Давайте тем не менее посмотрим, что происходит.
Вначале мы создаем promise и ActorResumable.
После initial_suspend() мы не приостанавливаемся, а идем дальше. Начинаем выполнять основную часть программы.
Когда доходим до co_await-а, понимаем, что нужно приостановиться. Мы эту ситуацию уже разбирали в предыдущем разделе, можно вернуться к нему и пересмотреть.
После того, как мы продолжили выполнение и вывели результат на экран, выполнение корутины заканчивается. Проверяем final_suspend, и очищаем весь контекст корутины.
Корутины. Часть 3. Task
Давайте вспомним, до какого этапа мы сейчас дошли.
void WokrerActor::workProcess() {
const auto coroutine = [](WokrerActor &actor) -> ActorResumable {
const int a = co_await actor.abActor.getAAsync(actor);
const int b = co_await actor.abActor.getBAsync(actor);
co_await actor.abActor.saveABAsync(actor, a - b, a + b);
const int newA = co_await actor.abActor.getAAsync(actor);
const int newB = co_await actor.abActor.getBAsync(actor);
std::cout << "Result " << newA << " " << newB << std::endl;
};
coroutine(*this);
}
Выглядит неплохо, но несложно заметить, что код:
const int a = co_await actor.abActor.getAAsync(actor);
const int b = co_await actor.abActor.getBAsync(actor);
повторяется 2 раза. Нельзя ли отрефакторить этот момент и вынести его в отдельную функцию?
Давайте набросаем, как это может выглядеть:
CoroTask<std::pair<int, int>> WokrerActor::readAB() {
const int a = co_await abActor.getAAsync2(*this);
const int b = co_await abActor.getBAsync2(*this);
co_return std::make_pair(a, b);
}
void WokrerActor::workCoroProcess() {
const auto coroutine = [](WokrerActor &actor) -> ActorResumable {
const auto [a, b] = co_await actor.readAB();
co_await actor.abActor.saveABAsync2(actor, a - b, a + b);
const auto [newA, newB] = co_await actor.readAB();
std::cout << "Result " << newA << " " << newB << " " << a << " " << b << std::endl;
};
coroutine(*this);
}
Нам осталось лишь изобрести тип CoroTask. Давайте подумаем. Во-первых, внутри функции readAB используется co_return, это значит, что CoroTask должен удовлетворять интерфейсу Resumable. Но также, объект этого класса используется на вход co_await-а другой корутины. Значит, класс CoroTask также должен удовлетворять интерфейсу Awaitable. Давайте реализуем оба этих интерфейса в классе CoroTask:
template <typename T = void>
struct CoroTask {
struct promise_type {
T result;
std::coroutine_handle<> waiter;
auto get_return_object() {
return CoroTask{*this};
}
void return_value(T value) {
result = value;
}
void unhandled_exception() {
std::terminate();
}
std::suspend_always initial_suspend() {
return {};
}
auto final_suspend() {
struct final_awaiter {
bool await_ready() {
return false;
}
void await_resume() {}
auto await_suspend(std::coroutine_handle<promise_type> me) {
return me.promise().waiter;
}
};
return final_awaiter{};
}
};
CoroTask(CoroTask &&) = delete;
CoroTask& operator=(CoroTask&&) = delete;
CoroTask(const CoroTask&) = delete;
CoroTask& operator=(const CoroTask&) = delete;
~CoroTask() {
if (h) {
h.destroy();
}
}
explicit CoroTask(promise_type & p)
: h(std::coroutine_handle<promise_type>::from_promise(p))
{}
bool await_ready() {
return false;
}
T await_resume() {
auto &result = h.promise().result;
return result;
}
void await_suspend(std::coroutine_handle<> waiter) {
h.promise().waiter = waiter;
h.resume();
}
private:
std::coroutine_handle<promise_type> h;
};
(Настоятельно рекомендую открыть фоном заглавную картинку этого поста. В дальнейшем это вам сильно поможет.)
Итак, давайте разберемся, что здесь происходит.
1. Заходим в лямбду coroutine и сразу же создаем корутину WokrerActor::readAB. Но после создания этой корутины, не начинаем выполнять ее (initial_suspend == suspend_always), что вынуждает нас прерваться и вернуться к выполнению лямбды coroutine.
2. co_await лямбды проверяет, готов ли результат выполнения readAB. Результат не готов (await_ready == false), что вынуждает ее передать свой контекст в метод CoroTask::await_suspend. Этот контекст сохраняется в CoroTask, и запускается resume корутины readAB
3. После того, как корутина readAB выполнила все нужные действия, она доходит до строки:
co_return std::make_pair(a, b);
в результате чего вызывается метод CoroTask::promise_type::return_value и внутри CoroTask::promise_type сохраняется созданная пара чисел
4. Так как вызвался метод co_return, выполнение корутины подходит к концу, а значит, самое время вызвать метод CoroTask::promise_type::final_suspend. Этот метод возвращает самописную структуру (не забывайте поглядывать на картинку), которая вынуждает вызвать метод final_awaiter::await_suspend, из которого возвращает сохраненный на шаге 2 контекст лямбды coroutine.
Почему мы не могли вернуть здесь просто suspend_always? Ведь в случае initial_suspend этого класса у нас это получилось? Дело в том, что в initial_suspend у нас это получилось потому, что эту корутину вызывала наша лямбда coroutine, и мы в нее вернулись. Но в момент, когда мы дошли до вызова final_suspend, нашу корутину скорее всего продолжали уже из другого стека (конкретно, из лямбды, которая подготовила функция makeCoroCallback), и, вернув здесь suspend_always, мы вернулись бы в нее, а не в метод workCoroProcess.
5. Так как метод final_awaiter::await_suspend вернул нам контекст, то это вынуждает программу продолжить выполнение возвращенного контекста, то есть лямбды coroutine. Так как выполнение вернулось в точку:
const auto [a, b] = co_await actor.readAB();
то мы должны вычленить сохраненный результат, вызвав метод CoroTask::await_resume. Результат получен, передан в переменные a и b, и теперь экземпляр CoroTask уничтожается.
6. Экземпляр CoroTask уничтожился, но что сталось с контекстом WokrerActor::readAB? Если бы мы из CoroTask::promise_type::final_suspend вернули бы suspend_never (точнее, вернули бы то, что на вопрос await_ready вернуло бы true), то в тот момент контекст корутины почистился бы. Но так как мы этого не сделали, то обязанность очищать контекст переносится на нас. Мы же очистим этот контекст в деструкторе CoroTask, на этот момент это уже безопасно.
7. Корутина readAB выполнена, результат из нее получен, контекст очищен, продолжается выполнение лямбды coroutine…
Уф, вроде разобрались. А помните, что из методов ABActor::getAAsync() и подобных мы возвращаем самописную структуру? На самом деле, метод getAAsync также можно превратить в корутину, объединив знания, полученные из реализации классов CoroTask и ActorAwaiter, и получив что-то вроде:
CoroTaskActor<int> ABActor::getAAsync(Actor &returnCallback) {
co_return makeCoroCallback<GetACallback, int>(std::bind(&ABActor::getA, this, _1), returnCallback);
}
но это я уже оставлю для самостоятельного разбора.
Выводы
Как видите, с помощью корутин можно довольно неплохо линеаризовать асинхронный код на коллбэках. Правда, процесс написания вспомогательных типов и функций пока не выглядит слишком интуитивным.
Весь код доступен в репозитории
Также рекомендую для более полного погружения в тему посмотреть эти лекции
.
Большое количество примеров на тему корутин от тогоже автора есть здесь.
И еще можно посмотреть эту лекцию