2021-06-20

vak: (Default)
В последнем стандарте языка Си++ 2020 появилась поддержка сопрограмм (coroutines). Я давно к ним присматриваюсь, в надежде применить для симуляции цифровой логики. При очередной попытке понять сопрограммы я натолкнулся на статью Дэвида Мазиереса, и она столкнула меня с мёртвой точки. Попробую ещё больше упростить здесь для начинающих.

Сопрограммы в языке Си++ это такие процедуры или функции, которые не завершаются до конца, и их можно продолжить. Выглядит сопрограмма как обычная функция, но с двумя отличиями:
  • У неё особый тип возвращаемого значения.
  • В её теле встречается оператор 'co_await'. Аргумент этого оператора тоже имеет специальный тип.
Типом возвращаемого значения сопрограммы может быть произвольный класс, внутри которого определен другой класс с фиксированным именем 'promise_type'. Методы этого класса подсказывают компилятору, как создавать сопрограмму, как завершать, и какое значение возвращать.

Оператор 'co_await' в теле сопрограммы приостанавливает её и возвращает управление в вызвавший (или продолживший) её код. Также могут встречаться операторы 'co_yield' и 'co_return'.

Вот пример простой сопрограммы, при каждом продолжении печатающей нарастающий счётчик:
co_void_t counter()
{
for (int i = 1; ; ++i) {
std::cout << "counter: " << i << std::endl;
co_await co_await_t{};
}
}
Вероятно, вам приходилось иметь дело с потоками std::thread или std::jthread. Сопрограммы - это не потоки. В отличие от потоков, они не используют никаких ресурсов операционной системы (наподобие pthreads). Также не стоит путать сопрограммы с fibers, или green threads, или традиционных вызовов setjmp()/longjmp(). В отличие от fibers, сопрограммы не имеют собственного стека. Они выполняются на стеке вызвавшей их процедуры. Вы можете создать миллионы сопрограмм, но в каждый момент будет работать только одна из них. Так что сопрограммы скорее похожи на функции, чем на потоки. Но функции, приостановленные с целью продолжения.

Сопрограммы вообще требуют очень мало ресурсов. При первом вызове в куче выделяется небольшая структура (std::coroutine_handle), в которую заносятся параметры и локальные переменные сопрограммы. Этого достаточно, чтобы продолжить её выполнение. Когда сопрограмма больше не нужна, эту структуру следует освободить вызовом метода destroy().

Вот пример вызова этой сопрограммы:
int main()
{
std::coroutine_handle<> continuation = counter();
for (int i = 0; i < 5; ++i) {
std::cout << "In main function\n";
continuation.resume();
}
continuation.destroy();
}
Программа напечатает:
In main function
counter: 1
In main function
counter: 2
In main function
counter: 3
In main function
counter: 4
In main function
counter: 5
Всё это выглядит довольно просто, но самая хитрость, как вы догадываетесь - в типах 'co_void_t' и 'co_await_t'. Сейчас я вам их покажу, и тут уже просто не будет. 😀 Разработчики стандарта Си++20 несколько перемудрили. К счастью, необязательно полностью осознавать потроха этих типов, чтобы ними пользоваться.

Класс co_void_t (я сам выбрал имя, вы можете выбрать любое другое) используется как возвращаемое значение для сопрограмм, возвращающих void. При первом вызове функции сопрограммы возвращается coroutine_handle, и происходит это до того, как начнёт выполняться собственно код функции. При завершении сопрограммы (посредством return), дескриптор не удаляется автоматически, и вызывающий код должен вызвать метод destroy(). Подробности смотрите в статье Дэвида Мазиереса.
class co_void_t {
public:
struct promise_type {
co_void_t get_return_object() { return { *this }; }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() {}
void return_void() noexcept {}
};

co_void_t(promise_type &promise)
: handle(std::coroutine_handle<promise_type>::from_promise(promise))
{
}

operator std::coroutine_handle<>() const { return handle; }

private:
std::coroutine_handle<promise_type> handle;
};
Класс co_await_t (опять же, имя не догма) безусловно приостанавливает сопрограмму и переключает выполнение на вызвавший код. Так мне было удобно для моих целей. Но здесь возможны варианты, и их можно изучить по статье Дэвида Мазиереса.
struct co_await_t {
constexpr bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> handle) {}
constexpr void await_resume() const noexcept {}
};
Если зафиксировать типы 'co_void_t' и 'co_await_t' как указано выше, сопрограммы превращаются в удобный механизм для реализации всяческих симуляторов. Про это я расскажу в отдельной статье.

Всё вышеуказанное замечательно работает на компиляторе g++ 10.3 под Линуксом и Mac OS. Полный исходный код примера находится здесь: demo.cpp. Компилируется он так:
g++ -std=c++20 -fcoroutines demo.cpp -o demo
То же самое можно собрать на Маке посредством последней версии родного компилятора clang++ 12.0. Но там сопрограммы пока ещё находятся в пространстве имён std::experimental, поэтому нужна другая версия исходного текста: demo-mac.cpp. Вызов компилятора:
clang++ -std=c++20 -stdlib=libc++ -fcoroutines-ts demo-mac.cpp -o demo
vak: (Default)
Не я первый сообразил задействовать сопрограммы для симуляции: есть уже один такой проект.

https://github.com/fschuetz04/simcpp20

И даже интерфейс похож на то, что у меня получается.