В С++ 20 появились coroutines (далее буду называть их корутинами, по-русски). Если кратко - они позволяют писать асинхронный код также как мы пишем синхронный. При этом асинхронный код это не обязательно должен работать с несколькими потоками. Асинхронным может быть код исполняемый в одном потоке.
Под капотом компилятора корутины - это просто синтактический сахар (syntax sugar). Т.е. корутины не создадут дополнительных потоков. Компилятор заменит корутины вызовом нескольких функций и не более того. Но давайте посмотрим как корутины выглядят в коде.
В этой статье я буду делать простейший таймер на основе корутин. При этом напишу классы, для Awaitable и Promise, которые необходимы для работы корутин.
Первая корутина
Собственно программа с корутинами может выглядеть так
timerCoroutineHandler timerCoroutine() {
co_await Timer::AwaitableTimer(3);
}
int main()
{
timerCoroutine();
return 0;
}
Да, все так просто :-), если классы timerCoroutineHandler и Timer::AwaitableTimer кто-то за нас уже сделал... (На практике их обычно делают разработчики асинхронной библиотеки). Но в этой статье мы это все реализуем сами.
Что такое корутины? Судя по документации, корутиной может быть любая функция, в которой есть co_await , co_yeild и/или co_return. Ничего не говориться про то, что в программе должно быть несколько потоков и т.п. Конечно корутины могут работать с несколькими потоками, но сами корутины не имеют механизмов синхронизации между потоками. В этой статье я подробно разберу только co_await. Мне кажется, что он самый инетречный с практической точки зрения.
Если попробовать скомпилировать программу выше, то компилятор скажет нам, что timerCoroutineHandler и Timer::AwaitableTimer не определны. Давайте это исправим и определим их
Awaitable
Собственно co_await это новый оператор, который принимает Avaitable значение. Что же такое Avaitable? Все просто, это обычный класс, который реализует три метода: await_ready, await_suspend и await_resume.
namespace Timer {
class AwaitableTimer {
public:
AwaitableTimer (unsigned int seconds);
bool await_ready();
void await_suspend(std::coroutine_handle<> coroutine);
void await_resume();
private:
std::chrono::time_point<std::chrono::steady_clock> _timepoint;
std::coroutine_handle<> _coroutine;
};
}
И компилятор будет вполне счастлив :-)
Назначения функций следующее (не стоит волноваться, если назначение функций сразу понятно не будет, далее все это закодим и запустим):
await_ready- вызывается в том месте, где находитсяco_await. Если она возвращает true, то корутина не приостанавливается, т.к. выполнение продолжается так, как будтоco_awaitвообще не было. В случаеfalse- корутина будет "ждать", т.е. ее выполнение будет приостановлено и управление будет передано вызывающей стороне.await_suspend- эта функция будет вызвана сразу послеawait_ready. Эта функция должна подготовить все необходимое для ожидания. Что именно надо готовить - решает программист. Единственный аргумент этой функции - это handler корутины. Его стоит сохранить где-нибудь, чтобы потом можно было разбудить (resume) нашу корутину. Да, будится именно корутина, в котором попалсяco_await, т.е. корутина сначала приостановится и чтобы она продолжила работу надо будет ее разбудить (resume) с помощьюstd::coroutine_handle<>::resume()await_resume- будет вызвана когда корутина будет разбужена (resume). Т.е. когда кто-то вызоветstd::coroutine_handle<>::resume()то после этого будет вызванawait_resume().Да-да, вызыватьstd::coroutine_handle<>::resume()должен именно кто-то :-) Никто автоматически корутину не разбудит.
Когда я пишу "будет вызвана" это означает, что компилятор уберет co_await и расставит функции из класса Timer::AwaitableTimer в нужной последовательности, чтобы они вызывались как описано выше. Как я говорил выше - корутины это всего лишь синтаксический сахар.
Теперь несколько слов про std::coroutine_handle<> . Это класс-шаблон, который реализован в библиотеки stl в С++. У меня он записан с <> в конце. Это означает, что компилятору не надо заботиться о конкретном типе, который будет передан этому шаблону.
В Timer::AwaitableTimer я добавил 2 переменные-члена: _timepoint и _coroutine. Avaitable объекту они не нужны (т. е. Компилятор все скомпилирует и без них). Эти переменные нужны для реализации логики работы таймера
Давайте теперь напишем простенькую реализацию класса Timer::AwaitableTimer . Как понятно из названия - это будет таймер. А значит он должен принимать значение в течении которого он будет "ждать".
// awaitableTimer.cpp
#include "awaitableTimer.hpp"
#include <iostream>
namespace Timer {
AwaitableTimer::AwaitableTimer (unsigned int seconds) {
std::cout << "AwaitableTimer ctor" << std::endl;
_timepoint = std::chrono::steady_clock::now() + std::chrono::seconds(seconds);
}
bool AwaitableTimer::await_ready() {
std::cout << "AwaitableTimer::await_ready" << std::endl;
return _timepoint <= std::chrono::steady_clock::now();
}
void AwaitableTimer::await_suspend(std::coroutine_handle<> coroutine) {
std::cout << "AwaitableTimer::await_suspend" << std::endl;
_coroutine = coroutine;
}
void AwaitableTimer::await_resume() {
std::cout << "AwaitableTimer::await_resume" << std::endl;
}
}
Думаю код понятен. Я добавил cout, чтобы можно было посмотреть что именно происходит "по капотом" когда мы используем корутины.
AwaitableTimer::await_suspend сохраняет переданный хендлер корутины (std::coroutine_handle<>). Если заглянуть в исходный код, то хендлер это класс с одним void* дата мембером и десятком функций. Выражение _coroutine = coroutine по сути сохранит указатель void* в котором хранится state корутины. Этот указатель внутренний, компилятор сам им манипулирует.
Promise
Чтобы наша корутина заработала компилятору потребуется еще один класс — Promise. Этот Promise не имеет ничего общего с std::promise, просто названия одинаковые. Promise нужен чтобы определить как будет вести себя сама корутина. В нашем примере это timerCoroutine(). Если внимательно читать документацию, то компилятор определяет какой Promise использовать для конкретной корутины при помощи typename coroutine_traits<timerCoroutineHandler>::promise_type, где timerCoroutineHandler это возвращаемый тип функции timerCoroutine. Строго говоря не обязательно возвращать Promise из корутины. Можно, например, сделать специализацию класса coroutine_traits. Но в данном примере я просто верну Promise.
// coroutinePromise.hpp
#pragma once
#include <iostream>
#include <coroutine>
struct promise;
struct timerCoroutineHandler : std::coroutine_handle<Coroutine::promise> {
using promise_type = Coroutine::promise;
};
struct promise {
timerCoroutineHandler get_return_object() {
std::cout << "promise::get_return_object" << std::endl;
return { timerCoroutineHandler::from_promise(*this) };
}
std::suspend_never initial_suspend() noexcept {
std::cout << "promise::initial_suspend" << std::endl;
return {};
}
std::suspend_never final_suspend() noexcept {
std::cout << "promise::final_suspend" << std::endl;
return {};
}
void return_void() {
std::cout << "promise::return_void" << std::endl;
}
void unhandled_exception() {
std::cout << "promise::unhandled_exception" << std::endl;
}
};
По сути в это реализции нет полезного кода, одни только cout. Но давайте разберем побробнее.
В структуре timerCoroutineHandler определен только тип promise_type (using в данном случае определяет синоним для типа promise). Этот promise_type будет использован coroutine_traits для определения типа promise в корутине.
Зачем нужен этот Promise? Он управляет поведением корутины. Компилятор сам создаст инстанс promise’a, затем поставит вызовы функций из этого промайза в функцию timerCoroutine. Если очень упрощенно, то функция timerCoroutine будет переписана так (перепишет ее компилятор).
// Эту стуктуру снедерирует сам компилятор для сохранения состояния корутины и аргументов.
struct coroutine_frame { ... };
timerCoroutineHandler timerCoroutine() {
// Создается инстанс coroutine_frame
auto* f = new coroutine_frame();
// Вызов функции get_return_object(), которую мы определили ранее
auto returnObject = f→promise.get_return_object();
co_await f→promise.initial_suspend();
try{
// Тело функции, которое написал пользователь. В нашем примере это
co_await co_await Timer::AwaitableTimer(3);
// На этом шаге присходит возврат из функции
f->promise.return_void();
}
catch (...)
{
f->promise.unhandled_exception();
}
// Удаление всех переменных с auto storage durations, т. е. Тех которые обычно выделяются в стеке в этой функции.
co_await f→promise.final_suspend();
// Удаление coroutine_frame
// Возврат к резьюмеру
}
Возможно последовательность вызова функций не будет сразу понятна, но думаю далее все встанет на свои места.
Сейчас давайте поговорим про функции из нашего Promise.
get_return_object— используется для получения значения которое будет возвращено из корутины, в нашем случае этоtimerCoroutineHandler. Его мы создаем с помощью функцииtimerCoroutineHandler::from_promise, которая есть вstd::coroutine_handle.return { ... }это выполнение конструктора класса, который будет возвращен из функции.initial_suspend— вызывется в начале выполнения корутины и может приостановить ее. В нашем примере приостанавливать корутину мы не будем, поэтому возвращаемsuspend_never. Это значит, что корутина приостанавливаться не будет.final_suspendпо аналигии сinitial_suspendприостановит корутину в самом конце. Но это также не нужно, поэтому возвращаемsuspend_neverreturn_void— это нужно чтобы удовлетворить компилятор, который предполагает наличие co_return в самом конце корутины. Она ничего не возвращает, просто должна быть.unhandled_exception— обработка исключений в теле корутины.
Ну что-ж, наша программа готова, давайте ее скомпилируем и запустим. Нам понадобится такие файлы
И вот что получаем в консоле
promise::get_return_object
promise::initial_suspend
timerCoroutine: Начало работы
AwaitableTimer ctor
AwaitableTimer::await_ready
AwaitableTimer::await_suspend
timerCoroutine завершена
И все? А почему корутина не просыпается (resume)? И почему программа выполняется мгновенно и не ждет 3 секунды? Это же таймер?
Как я уже говорил выше, корутины это всего лишь синтаксический сахар, при этом никто корутину не будет резьюмить и никто не будет запучкать наш таймер. Это все должны мы сделать сами.
Executor
Как наверное понятно из названия Executor будет что-то выполнять. Executor не имеет непосредственного отношения к корутинам. Это уже мое изобретение. В нашем примере он будет ожидать таймер и передавать управление корутине как только таймер окончится.
Но перед тем, как переходить к рассмотрению реализации Executor’a, давайте посмотрим как он будет использоваться. Для этого немного модифицируем нашу исходную программу.
// timerCoroutine.cpp
#include "timerCoroutine.hpp"
#include "awaitableTimer.hpp"
#include <iostream>
#include <memory>
timerCoroutineHandler timerCoroutine(Executor::Executor& executor) {
std::cout << "timerCoroutine: Начало работы" << std::endl;
co_await Timer::AwaitableTimer(3, executor);
std::cout << "timerCoroutine: Окнчание работы" << std::endl;
}
// main.cpp
int main() {
Executor::Executor executor{};
timerCoroutine(executor);
while(!executor.is_empty()) {
executor.execute();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
return 0;
}
Как видно Executor это класс, который передается в корутину и далее, в AwiatableTimer. Если вы знакомы с Boost.Asio, то там используется похожий подход, только вместо executor’a используется io_context.
После выполнения корутины (timerCoroutine) мы запускаем наш executor. Он работает в цикле. Функция std::this_thread::sleep_for нужна чтобы не сильно грузить процессор итерациями цикла. Ее может и не быть, для функционирования программы она не обязательна. Что делают функции is_empty() и execute() будет понятно чуть позже, когда мы напишем класс Executor.
Но нам надо немного изменить Timer::AwaitableTimer, т. к. его конструктор сейчас не принимает executor в качестве аргумента.
// awaitableTimer.hpp
#pragma once
#include <chrono>
#include <coroutine>
#include "executor.hpp"
namespace Executor {
class Executor;
}
namespace Timer {
class AwaitableTimer {
public:
AwaitableTimer (unsigned int seconds, Executor::Executor& executor);
bool await_ready();
void await_suspend(std::coroutine_handle<> coroutine);
void await_resume();
bool is_expired();
void resume();
~AwaitableTimer();
private:
std::chrono::time_point<std::chrono::steady_clock> _timepoint;
std::coroutine_handle<> _coroutine;
};
}
// awaitable.cpp
namespace Timer {
AwaitableTimer::AwaitableTimer (unsigned int seconds, Executor::Executor& executor) {
std::cout << "AwaitableTimer ctor" << std::endl;
_timepoint = std::chrono::steady_clock::now() + std::chrono::seconds(seconds);
executor.enque(this);
}
bool AwaitableTimer::is_expired() {
return _timepoint <= std::chrono::steady_clock::now();
}
void AwaitableTimer::resume() {
std::cout << "AwaitableTimer::resume" << std::endl;
_coroutine.resume();
}
AwaitableTimer::~AwaitableTimer() {
std::cout << "AwaitableTimer dtor" << std::endl;
}
}
Изменений не много. Добавлен вызов executor.enque(this) из конструктора, на которую мы посмотрим чуть позже, функция AwaitableTimer::resume(), которая резьюмит корутину и функция AwaitableTimer::is_expired(), логика работы который думаю понятна.
И наконец Execuitor
// executor.hpp
#pragma once
#include <vector>
#include "awaitableTimer.hpp"
namespace Timer {
class AwaitableTimer;
}
namespace Executor {
class Executor {
std::vector<Timer::AwaitableTimer*> _timers;
public:
void execute();
void enque(Timer::AwaitableTimer* t);
bool is_empty();
};
}
// executor.cpp
#include "executor.hpp"
#include <iostream>
namespace Executor {
void Executor::execute() {
std::erase_if(_timers, [](Timer::AwaitableTimer* t) {
if (t->is_expired()) {
t->resume();
std::cout << "coroutine resumed" << std::endl;
return true;
}
return false;
});
};
void Executor::enque(Timer::AwaitableTimer* t) {
_timers.push_back(t);
}
bool Executor::is_empty() {
return _timers.empty();
}
}
Executor содержит в себе вектор указателей на AwaitableTimer*. Этот вектор как раз и будет использоваться для того, чтобы понять какой таймер окончился и какую корутину надо резьюмить.
Возможно возникает вопрос, почему raw pointer, почему не smart pointer? Это связано с тем, как мы создаем экземпляр AwaitableTimer. Он создается в корутине в этой строке co_await Timer::AwaitableTimer(3, executor).
Он создается в стэке компилятором, а значит время жизни таймера связано с самой корутиной. Такой подход может быть не самым удачным, т. к. мы сохраняем указатель на объект выделенный в стэке другой функции. Потенциально можем словить dangling pointer, если мы попробуем обратиться к указателю после завершения корутины. Под завершением корутины я имею в виду полное ее завершение, когда она дождется таймера. Более удачным может быть такая конструкция
timerCoroutineHandler timerCoroutine(Executor::Executor& executor) {
auto timer = std::make_shared(3, executor);
co_await timer.get_awaiter();
}
В этом случае в функции get_awaiter AwaitableTimer можно сделать shared_from_this и уже его передать в enque. Но это обсуждение выходит за рамки этой статьи, поэтому реализовывать это не будем.
Функция enque добавляет таймер в вектор. Далее будем прокручивать все таймеры в _timers и проверять какие завершились.
Функция is_empty проверяет опустела ли очередь из таймеров или нет.
Самая интересная функция execute. Она перебирает все таймеры в _timers, используя функцию erase_if. Эта функция в качестве второго аргумента принимает функцию которая будет вызвана для каждого таймера и если она вернет true, то таймер будет удален из вектора _timers. Как раз здесь и происходить resume корутины, в которой находится таймер. И после резьюма таймер сразу удаляется. Это связано с тем, что после резьюма, корутина отработает до конца и все переменные в фрайме корутины будут удаллены, в том числе и таймер, указатель на который храниться в векторе _timers. Поэтому сразу после вызова t->resume(), t становиться невалидным и его надо сразу удалить из вектора timers. Поэтому после резьюма возвращается true и erase_if удалит этот элемент из вектора.
И давайте теперь запустим программу. Вот что она выведет на экран.
promise::get_return_object
promise::initial_suspend
timerCoroutine: Начало работы
AwaitableTimer ctor
AwaitableTimer::await_ready
AwaitableTimer::await_suspend
После timerCoroutine
AwaitableTimer::resume
AwaitableTimer::await_resume
AwaitableTimer dtor
timerCoroutine: Окнчание работы
promise::return_void
promise::final_suspend
coroutine resumed
Завершение программы
Теперь больше похоже на правду. Наша программа работает около 3х секунд и мы видим, что для корутина была разбужена (resume).
Условно выполнение программы можно разделить на две части. Сначала идет подготовка, запускается timerCoroutine и создается таймер. Затем мы ждем завершения таймера и резьюмим корутину уже из цикла, в котором работает executor. По сути мы входим в функцию timerCoroutine дважды. Сначала, когда мы ее вызвали (и она работает до первого co_await) и затем когда мы ее зарезьюмили, в этом случае она отрабатыват от co_await и до конца. При этом когда она дорабатыват до конца, мы возвращаемся в цикл executor’a, а не в то место, где она была вызвана изначально. Также отмечу, что «coroutine resumed» печатается уже после того, как был вызван деструктор для AwaitableTimer (AwaitableTimer dtor). Это к тому, зачем была использована erase_if для удаления элемента из вектора _timers.
И в заключении схема работы программы с основными шагами.

Надеюсь мой экскурс в корутины был интересным :-)
Если кто-то хочет глубже погрузится в корутины, рекомендую Asymmetric Transfer
maisvendoo
По-русски, если я верно помню, это называется сопрограммы