Сеанс разоблачения магии.
Думаю, многие согласятся, что реализация корутин в C++20 с первого взгляда выглядит страшновато, а документация скорее более запутывает, чем вносит ясность. Многие воспринимают работу приостанавливаемых функций как некую магию со своими странными co_abracadabra()'ми и прочими promise_type'ами.
В этой статье я хочу разоблачить якобы стоящую за корутинами магию, сдёрнуть покровы и показать, что спрятано под столом у фокусника.
А начну я, как ни странно, с Питона. Рассмотрим простенькую корутину-генератор на Питоне, которая генерирует последовательность спаренных чисел с заданным шагом (это чтобы в дальнейшем было более интересно, с двумя yield в ней). И к ней же пример её вызова.
def magicians_hat(start, end, step):
rabbit = start
while rabbit <= end:
yield rabbit
rabbit += 1
yield rabbit
rabbit += step
hat = magicians_hat(0, 10, 2)
for rabbits in hat:
print(f"{rabbits} rabbit(s)")
Запустив программу, получим такой вывод:
0 rabbit(s)
1 rabbit(s)
3 rabbit(s)
4 rabbit(s)
6 rabbit(s)
7 rabbit(s)
9 rabbit(s)
10 rabbit(s)
Теперь возьмём в руки старый добрый C++98 и начнём готовить свой вариант этого фокуса.
Вероятно, генератор должен выглядеть примерно как-то так:
some_hat magicians_hat(int start, int end, int step) {
for (int rabbit = start; rabbit <= end; rabbit += step) {
yield rabbit;
yield ++rabbit;
}
}
Однако пока непонятно, чем будет some_hat и как реализовать yield. Очевидно, что some_hat должен быть каким-то объектом, который можно возобновлять и проверять завершённость. Поэтому для начала создадим базовый интерфейс для работы с шляпами фокусника, ничего сложного, всё по канонам.
struct magicians_hat_base {
int state_; // Текущее состояние шляпы
int current_rabbit_; // Очередной вытащенный кролик
magicians_hat_base() : state_(1){} // Конструктор
virtual ~magicians_hat_base() {} // Деструктор, виртуальный естественно
// Функция возобновления
virtual void pullout_rabbit() {
state_ = 0;
}
// Проверка завершённости
bool done() const {
return state_ == 0;
}
// Уничтожение
void destroy() {
delete this;
}
};
Теперь будем реализовывать нашу генерирующую шляпу. Для этого наследуемся от базового объекта, и переопределим pullout_rabbit.
Ключевой момент - для сохранения состояния генератора между вызовами сделаем все "локальные" переменные полями класса, а также будем сохранять позицию, в которую надо перейти при следующем вызове.
struct some_hat : magicians_hat_base {
// Наши локальные переменные уходят в поля класса
int start_, end_, step_;
int rabbit_;
// Инициализация экземпляра шляпы указанными значениями
some_hat(int start, int end, int step) :
start_(start), end_(end), step_(step){}
virtual ~some_hat(){}
virtual void pull_rabbit();
};
Сама же функция генератора перепишется так:
virtual void pullout_rabbit() {
switch(state_) {
case 1:
for (rabbit_ = start_; rabbit_ <= end_; rabbit_ += step_) {
// Это наш такой yield rabbit вручную
current_rabbit_ = rabbit_; state_ = 2; return;
case 2:
// Это снова наш такой yield rabbit вручную
current_rabbit_ = ++rabbit_; state_ = 3; return;
case 3:;
}
state_ = 0;
}
}
Для тех, кого такой синтаксис switch вводит в ступор, напишем в более понятном виде, результат тот же:
virtual void pullout_rabbit() {
switch(state_) {
case 1: goto state1;
case 2: goto state2;
case 3: goto state3;
default:
return;
}
state1:
for (rabbit_ = start_; rabbit_ <= end_; rabbit_ += step_) {
// Это наш такой yield rabbit вр��чную
current_rabbit_ = rabbit_; state_ = 2; return;
state2:
// Это снова наш такой yield rabbit вручную
current_rabbit_ = ++rabbit_; state_ = 3; return;
state3:;
}
state_ = 0;
}
И теперь использование
int main() {
some_hat* hat = new some_hat(0, 10, 2);
for(;;) {
hat->pullout_rabbit();
if (hat->done()) {
break;
}
std::cout << hat->current_rabbit_ << " rabbit(s)\n";
}
hat->destroy();
return 0;
}
И при запуске программы получаем:
0 rabbit(s)
1 rabbit(s)
3 rabbit(s)
4 rabbit(s)
6 rabbit(s)
7 rabbit(s)
9 rabbit(s)
10 rabbit(s)
Бинго, как в Питоне! Посмотреть на godbolt
Внимательно следите за руками - только что, на глазах почтенной публики, без библиотек и обращения к ОС, без единой ассемблерной вставки, мы реализовали беcстековую асимметричную корутину, работающую от как минимум С++98 и до наших дней!
Всем спасибо за внимание, можем расходится.
Вы ещё здесь? Ждёте продолжения? Тогда снова pullout_rabbit(), я покажу вам, насколько глубока шляпа фокусника!
Как оно на самом деле
Вы удивитесь, но на самом деле главное отличие механизма работы корутин C++20 от приведённого примера в том, что вместо pullout_rabbit пишется resume, а тело самой корутины дополнительно обёрнуто в try{}catch(...){}. Более принципиальных отличий нет, только синтаксический сахар отвод глаз.
Подытожим, чем же является корутина с точки зрения реализации: подобно лямбда-функциям с захватом, корутина является экземпляром анонимного класса, который принято называть фреймом корутины. "Локальные" переменные корутины, которые "переживают" точки остановки корутины, становятся как-бы полями этого класса (фрейма), который помимо них также содержит и некоторые служебные поля.
Сама описанная программистом логика конкретной корутины реализуется в функции resume этого фрейма, а возможность приостановки и возобновления выполнения корутины обеспечивается созданием простой стэйт-машины aka "конечный автомат", который при очередном вызове функции просто переходит к её нужной части, в зависимости от сохранённого состояния корутины.
Достоинства и недостатки таких корутин
Оценивая меч Хаттори Хандзо, сравнивай его со всеми другими мечами, сделанными не Хаттори Хандзо.
Сравнивать достоинства и недостатки таких корутин можно с другим принципиальным подходом к реализации в виде стековых корутин.
Достоинства:
В отличии от стековых корутин компилятор точно знает размер памяти, необходимый для фрейма корутины. Стековые корутины требуют единовременного выделения памяти для отдельного стека, размер которого трудно прогнозировать. Если выделить мало - стека может не хватить, если много - память потратится зря.
Очень быстрые приостановка и возобновление корутины - приостановка это просто запомнить номер состояния и возврат, возобновление - это обычный вызов функции без параметров и один переход внутри неё. Для стековых корутин требуется сохранять и восстанавливать состояние всех регистров процессора.
Недостатки по сути продолжение достоинств
Невозможность приостановки во вложенных вызовах. Бесстековые корутины могут приостанавливаться только на "верхнем" уровне вложенности, в заранее известных компилятору местах. Стековые же корутины это просто функции, которые могут на любой глубине вложенных вызовов в любом произвольном месте осуществить приостановку и переключится обратно на запустившую их функцию. Компилятору не нужно делать никаких дополнительных действий или создавать "скрытые" классы.
Детали реализации
Как я уже говорил, описание реализации корутин в документации выглядит страшновато и с первого раза его мало кто может понять. Очень много различных деталей и узлов, которые сложно уложить в голове в единую систему. Но причина для этого есть. В отличии от других языков, где реализации корутин "приколочены гвоздями", у нас в C++ программисту предоставляется большая гибкость в тонкой настройке как поведения корутин, так и их семантики.
Существует множество точек для установки своих "хуков", в которых можно вписать свой код, добиваясь точных выстрелов в ногу. Я постараюсь постепенно распутать для вас этот клубок, где всё завязано друг на друга.
Начнём c
co_await и Awaitable объекты
Видишь
switchиcase? И я не вижу. А он есть.
Помните тот развесистый switch на всю функцию pullout_rabbit, которым мы реализовали конечный автомат для приостановки и возобновления работы функции? Давайте посмотрим, как компилятор создаёт и прячет его.
Ключом к пониманию работы является ключевое (извиняюсь за тавтологию) слово
co_await.
Встретив его как унарный оператор в функции, компилятор понимает, что она является корутиной, и в этом месте разработчик хочет иметь возможность приостановить её.
В терминах нашей доморощенной реализации, он вставляет здесь очередной case нашего внешнего switch'а и return после него. А дальше программисту предоставляется возможность задать, что конкретно сделать перед этим case'ом и сразу после него.
Задается это операндом оператора co_await. Им должен быть объект, который принято назвать "Awaitable", или как я их называю "объект, об который корутина может споткнутся".
Awaitable объект это тот, который имеет три метода:
bool await_ready()- constexpr, const, noexcept по вкусу. Проверяет, надо ли приостанавливать корутину. Еслиawait_readyвернётtrue, то объект ожидания уже готов и корутину останавливать не нужно. Если же вернутьfalse, то корутину надо приостановить. Тогда сначала сохраняется состояние корутины, а затем у объекта ожидания вызывается метод:await_suspend(std::coroutine_handle<> suspended)- этот метод объекта вызывается при остановке корутины с двумя целями. Во-первых, здесь можно описать дополнительные действия, которые вы хотите выполнить при остановке корутины. Во-вторых, в неё передается так называемый "хендл" корутины, используя который можно возобновить её выполнение. Помните, я писал, что все детали реализации "увязаны в клубок", где всё цепляется за всё, поэтому хендл корутины подробнее рассмотрим позже. Пока достаточно знать, что с помощьюhandle.resume()- можно возобновить её выполнение. У функцииawait_suspendможет быть три варианта типа возвращаемого значения:void- корутина всегда точно приостанавливается, происходитreturnв точку вызова корутины.bool- возвратtrueдействует так же какvoid, корутина приостанавливается, происходитreturnв точку вызова корутины. Возвратfalseозначает, что корутина передумала останавливаться и продолжает выполнение.std::coroutine_handle<>- возврат хендла какой-либо корутины вызывает возобновление выполнения корутины, чей хендл был возвращён. Стоит отметить, что может быть вернут и хендл этой же корутины, которая сейчас приостанавливается, это то же самое, что возвращатьfalse. Существует специальный хендл,std::noop_coroutine(), выдающий "пустую" корутину. После вызова другой корутины всё-равно происходит возврат в точку вызова текущей корутины.
auto await_resume()- метод вызывается сразу после точки возобновления корутины. Его результат становится результатом оператора co_await. Вызов происходит всегда, вне зависимости от того, приостанавливалась ли корутина или нет.
Таким орбазом, auto res = co_await expr(); разворачивается примерно в такой псевдо-код:
// Где-то скрыто в начале функции, переход к точке возобновления
switch(resume_state) {
....
// auto res = co_await expr();
{
// Так как awaitable "переживает" точку приостановки, располагаться он
// всегда будет во фрейме корутины.
// Однако инициализация его происходит только сейчас
awaitable = expr();
if (!awaitable.await_ready()) {
resume_state = SomeConst;
// Вариант с void await_suspend
awaitable.await_suspend(my_handle);
return; // Приостанавливаемся
// Или вариант с bool await_suspend
if (awaitable.await_suspend(my_handle)) {
return; // Приостанавливаемся
}
// Или вариант с std::coroutine_handle<> await_suspend
if (auto new_coro = awaitable.await_suspend(my_handle);
new_coro != my_handle) {
new_coro.resume(); // Возобновляем другую корутину
return; // Приостанавливаемся
}
}
case SomeConst: // Точка возобновления
res = awaitable.await_resume();
}
....
Если операндом co_await является не Awaitable-объект, компилятор пытается вызвать или obj.operator co_await() или operator co_await(static_cast<Awaitable&&>(obj)).
Таким образом, можно для обычных объектов создать к ним Awaitable-обёртку.
В стандартную библиотеку входят два готовых типа Awaitable объектов:
std::suspend_always- всегда приводит к остановке корутины.std::suspend_never- никогда не останавливает корутину.
Оба они ничего не делают в await_suspend и ничего не возвращают в await_resume.
ВАЖНО! При реализации await_suspend в своих Awaitable-объектах особо обратите внимание, что если вы каким-либо образом в этой функции сами возобновите только что остановленную корутину, вызвав handle.resume() (прямо здесь, или передав хендл корутины в другой поток), то сам Awaitable-объект возможно будет разрушен при возобновлении корутины, и после этого обращаться к любым данным этого объекта уже нельзя.
Возвращаемый корутиной объект
В отличии от других языков программирования, в C++ решили (возможно пока) не создавать какой-либо стандартный класс для передачи результатов работы корутины вовне, а предоставить программисту возможность создавать свои варианты таких объектов, для обеспечения гибкой настройки под свои нужды. Компилятор узнаёт о типе такого объекта из возвращаемого корутиной типа. При вызове корутины создаётся экземпляр объекта
этого типа, который возвращается в точку вызова, и через него пользователь корутины может получить доступ к управлению ею, способами, которые заложил в этот объект программист.
Этот тип подобен шляпе фокусника, внешне он может предоставлять ручки, за которые можно дёргать и "доставать кролика" из корутины, внутри же он имеет двойное дно, за которым прячутся механизмы для обеспечения "магии". Компилятор выясняет что именно будет "вторым дном" с помощью std::coroutine_traits<ВозвращаемыйТип, ТипыПараметровКорутины...>::promise_type.
По-умолчанию "вторым дном" является подтип promise_type, вложенный в тип, возвращаемый корутиной (может быть как типом, так и псевдонимом), и если к внешнему типу нет никаких требований, то к promise_type компилятор предъявляет жёсткие требования по составу методов.
При старте корутины компилятор создаёт во фрейме корутины скрытый объект promise этого типа, и использует его методы для обеспечения работы корутины.
Вот минимальная реализация корутины, которая скомпилируется и выполнится:
#include <coroutine>
#include <iostream>
// Тип для возврата из нашей корутины, имя может быть произвольным
struct some_hat {
// А вот этот вложенный тип должен быть обязательно и обязательно с таким именем
// При старте корутины компилятор создаёт во фрейме корутины скрытый объект
// "promise" этого типа
// При желании можно создавать в нём разные дополнительные поля и методы,
// тогда они будут входить во фрейм любой корутины, возвращающей some_hat
struct promise_type {
// Здесь перечислен минимальный набор методов, которые обязательны к наличию в promise_type
// Этот метод вызывает компилятор при старте корутины, чтобы сформировать возвращаемый ей объект
some_hat get_return_object() { return {}; }
// Этот метод вызывается, если при выполнении корутины произошло необработанное исключение
void unhandled_exception() {}
// Этот метод нужен для работы co_return;
void return_void() {}
// Этот метод вызывается перед выполнением основного тела корутины: co_await promise.initial_suspend()
// Тип метода не обязательно такой, главное возвращать Awaitable объект.
// В данном случае, так как внешний объект самый примитивный и пока не содержит логики для возобновления
// корутины, мы возвращаем std::suspend_never, чтобы она не приостанавливалась при старте, а сразу выполнялась.
// Обычно же возвращают std::suspend_always, для отложенного запуска.
std::suspend_never initial_suspend() { return {}; }
// Этот метод вызывается после выполнения основного тела корутины: co_await promise.final_suspend()
// Тип метода не обязательно такой, главное возвращать Awaitable объект.
// noexcept обязательно, вызов происходит вне try/catch блока
// В данном случае мы не останавливаемся, и фрейм корутины уничтожается автоматически
std::suspend_never final_suspend() noexcept { return {}; }
};
};
// Наша первая корутина
some_hat test_coro() {
std::cout << "test_coro run\n";
// Чтобы компилятор воспринял функцию как корутину, в ней обязательно должно быть хотя бы одно из co_await, co_yield, co_return
co_return;
}
int main() {
// Создаём объект корутины. Сейчас мы пока нигде в ней не сделали точек приостановки, поэтому она сразу
// выполнится полностью, то есть эффект как от вызова простой функции. Позже мы расширим функционал работы с ней.
some_hat hat = test_coro();
return 0;
}
Фрейм корутины и её хендл
Для хранения состояния и переменных корутины при её первом запуске создаётся так называемый "фрейм" корутины. Для себя можно рассматривать его как экземпляр скрытого анонимного класса, содержащий служебные поля и переменные корутины, которые должны "переживать" точки её приостановки. Служебные поля - обычно это указатели на функции возобновления корутины и её уничтожения, а также "состояние для возобновления", то есть номер точки перехода внутри функции возобновления, на который надо передать управление при её вызове. Также во фрейме корутины располагается ранее рассмотренный объект promise. Служебные поля являются скрытыми (хотя и могут быть видны в отладчике) и к ним невозможно достучаться по имени из самой корутины.
Для взаимодействия с самой корутиной предназначен её хендл, определяемый стандартом как класс std::coroutine_handle<Promise = void>. Для Promise=void существует специализация, которая "стирает тип" Promise и является "универсальной", хендл корутин любого типа может быть конвертирован в этот тип.
Внутри хендл хранит указатель на, как расплывчато написано в стандарте, "to the coroutine state". Хотя обычно это указатель на фрейм корутины.
Основные методы:
resume(),operator()- Возобновить выполнение корутины. Если корутина не приостановлена, то это UB, и обычно очень больно.done()- узнать, завершена ли корутина полностью.destroy()- уничтожить фрейм корутины. Если корутина не приостановлена, то это UB, и тоже очень больно.address()- получить адрес, на который указывает хендл. В текущих реализациях это фрейм корутины.promise()- есть у типизированного хендла, и возвращает ссылку на объектpromise, лежащий во фрейме корутины.
Также хендл корутины можно получить, зная адрес её фрейма или адрес её promise, с помощью статических функций:
std::coroutine_handle::from_address - Получить хендл по адресу фрейма.
std::coroutine_handle::from_promise - Получить хендл корутины по ссылке на её
promise.
Аллокация фрейма корутины
При вызове корутины компилятор в первую очередь создаёт её фрейм, динамически выделяя для неё память. Допускается, что если компилятор видит, что время жизни фрейма гарантировано не превышает текущей функции (хендл корутины не уходит никуда вовне текущей функции), то память для него выделяется на стеке.
Если в promise_type переопределены operator new, operator delete, то для выделения и освобождения памяти компилятор использует их. При этом, хотя operator new переопределён для типа promise_type, размер в operator new запрашивается не sizeof(promise_type), а для всего фрейма.
Если один из переопределённых operator new имеет дополнительные параметры, и их типы совпадают с типами параметров корутины, то будет использована именно эта перегрузка, в которую передадут фактические параметры вызова корутины.
Если в promise_type есть метод get_return_object_on_allocation_failure - то компилятор будет использовать non-throwing версию operator new, и в случае неудачи будет вызывать get_return_object_on_allocation_failure вместо get_return_object.
Передача параметров в корутину
Даже если программист написал корутину без точек приостановки, они всё-равно есть в её скрытом окружении - initial_suspend и final_suspend, поэтому параметры вызова корутины всегда должны "переживать" остановки и всегда копируются во фрейм корутины. При этом нужно очень внимательно относится к параметрам, передаваемым по ссылке - в отличии от обычной функции, корутина может иметь большее время жизни, чем объект, ссылка на который в неё передана. Передавая параметры в корутину по ссылке, чётко понимайте, что вы делаете. Если корутина является методом класса, то учтите, что в неё скрытым параметром передаётся ссылка на экземпляр этого класса, и всё вышесказанное относится и к этой ссылке - она должна оставаться валидной на всё время использования корутины.
Также обратите внимание, если корутина является лямбда-функцией с захватом, то захваченные переменные живут только до первой приостановки корутины, после чего уничтожаются. В лямбда-корутинах предпочтительнее использовать не захват, а явную
передачу нужных переменных параметрами корутины.
Конструктор promise_type
Если один из конструкторов promise_type подходит для вызова с фактическим параметрами корутины, то будет вызван он, иначе будет вызван дефолтный конструктор. Параметры корутины всегда копируются во фрейм корутины. При вызове конструктора с параметрами, для тех из них, которые пр��нимаются по ссылке, ссылка будет на уже скопированные
во фрейм параметры, то есть валидна на время жизни корутины.
co_return
Следующим ключевым словом, вводимым с корутинами, является co_return.
Встретив его, компилятор во-первых понимает, что функция является корутиной, во-вторых, создает код выхода из корутины:
если используется просто
co_return;илиco_return expr;иexprимеет типvoid, то вызываетсяpromise.return_void().если используется
co_return expr;иexprне типаvoid, то вызываетсяpromise.return_value(expr).Далее происходит переход к конечной точке корутины, в которой вызывается
co_await promise.final_suspend().
В виде псевдо-кода co_return выглядит так:
try {
.... тело корутины
// co_return;
promise.return_void();
goto final_point;
.... или
//co_return 1;
promise.return_value(1);
goto final_point;
....
} catch(...) {
promise.unhandled_exception();
}
final_point:
co_await promise.final_suspend();
....
Отметим, что в promise_type одновременно может быть только или метод return_void, или return_value, но не оба сразу.
А вот методов return_value может быть несколько, с разными типами аргументов.
Достижение конца корутины равнозначно co_return;, за исключением UB, если в promise_type не обнаружен return_void().
Получается, что то, что нужно сделать с тем, что вернул co_return, и вообще что предпринять при его вызове - возлагается на разработчика promise_type. Обычно переданное значение куда-либо сохраняют, например в promise, а во "внешнем" возвращаемом из корутины объекте делают метод типа get_result для получения сохранённого значения. Хотя вашу фантазию ничто не ограничивает, и вы можете создать какое-то свое поведение, например, отправить результат по email, или отформатировать диск.
Уже существует несколько различных библиотек со своими реализациями "обвеса" вокруг корутин, со своими взглядами на семантику возврата, возможно со временем какая-то из них станет стандартом.
co_yield
Ключевое слово co_yield expr; просто разворачивается в
co_await promise.yield_value(expr);
Разработчик promise_type должен реализовать в нём метод yield_value, в котором что-то сделать с переданным значением, и вернуть Awaitable объект, на котором корутина может остановиться. Обычно просто в promise запоминается указатель на переданное значение (этого достаточно, так как оно гарантировано переживёт точку приостановки корутины) и возвращают std::suspend_always, а во "внешнем" возвращаемом объекте делают метод типа current_value() для получения ссылки на это значение.
await_transform
В теле конкретных корутин могут встречаться остановки на самых разнообразных Awaitable-объектах. Однако в promise_type также есть способ "перехвата" их различных типов, с целью кастомизировать или изменять их поведение.
Встретив co_await expr компилятор сначала проверяет выражение co_await promise.await_transform(expr). Если такое выражение является правильным с точки зрения компилятора, то будет использован такой вариант (кроме приостановки на promise.initial_suspend и promise.final_suspend, там трансформация не вызывается).
Усложняем минимальную реализацию
Мы уже рассмотрели минимальную реализацию работы корутин, но пока она по-сути была равнозначна обычному вызову функции,
так как в самой корутине не было точек приостановки, а "снаружи" не было способов возобновлять и приостанавливать корутину.
Давайте для начала просто добавим отладочного вывода, чтобы посмотреть более детально процесс работы.
Посмотреть на godbolt
#include <coroutine>
#include <iostream>
#include <stdint.h>
struct some_hat {
some_hat() {std::cout << this << " hat created\n";}
~some_hat() {std::cout << this << " hat destroyed\n";}
struct promise_type {
promise_type() {
// В методах promise_type получить хендл корутины довольно просто:
auto h = std::coroutine_handle<promise_type>::from_promise(*this);
std::cout << this << " promise created, Handle address " << h.address() << ", offset from promise " <<
((uintptr_t)this - (uintptr_t)h.address()) << "\n";
}
~promise_type() {std::cout << this << " promise destroyed\n";}
some_hat get_return_object() {
std::cout << "get_return_object called\n";
return {};
}
std::suspend_never initial_suspend() {
std::cout << this << " initial_suspend called\n";
return {};
}
std::suspend_never final_suspend() noexcept {
std::cout << this << " final_suspend called\n";
return {};
}
void return_void() { std::cout << this << " return_void called\n"; }
void unhandled_exception() {}
static void* operator new(size_t size) {
void* res = ::operator new(size);
std::cout << "Call new for coro frame: " << size << " bytes, sizeof(promise_type) " << sizeof(promise_type) << ", return " << res << "\n";
return res;
}
static void operator delete(void* ptr) {
std::cout << "Destroy coro frame at " << ptr << "\n";
::operator delete(ptr);
}
};
};
some_hat test_coro() {
uint64_t local = 1;
std::cout << "Coroutine ran, local var at " << (void*)&local << "\n";
//co_await std::suspend_never{};
co_return;
}
int main() {
char local_var = 0;
std::cout << "Local var in stack at " << (void*)&local_var << "\n";
some_hat hat = test_coro();
std::cout << "after coro hat is " << (void*)&hat << "\n";
return 0;
}
C Clang получаем примерно такое:
Local var in stack at 0x7fff8d74a60b
Call new for coro frame: 24 bytes, sizeof(promise_type) 1, return 0x5d302bbb52c0
0x5d302bbb52d0 promise created, Handle address 0x5d302bbb52c0, offset from promise 16
get_return_object called
0x7fff8d74a60a hat created
0x5d302bbb52d0 initial_suspend called
Coroutine ran, local var at 0x7fff8d74a5a8
0x5d302bbb52d0 return_void called
0x5d302bbb52d0 final_suspend called
0x5d302bbb52d0 promise destroyed
Destroy coro frame at 0x5d302bbb52c0
after coro hat is 0x7fff8d74a60a
0x7fff8d74a60a hat destroyed
Видно, что сначала выделяется память под фрейм, размером больше, чем promise_type.
Далее конструируется promise, видно, что handle.address() - как раз созданный нами фрейм, a promise размещён в нём, со смещением 16 байт. Из-за возможных различиях в реализациях и возможных разных выравниваний promise_type, не закладывайтесь на такое постоянное смещение, всегда используйте функции handle.promise() и std::coroutine_handle<promise_type>::from_promise`.
Далее вызовом get_return_object конструируется возвращаемый объект. Видно, что благодаря RVO и copy elision возвращаемый объект создаётся сразу на стеке вызывающей функции, то есть он может вообще не иметь конструкторов копирования и перемещения.
Затем вызывается co_await promise.initial_suspend(), после чего начинается выполнение тела корутины.
Обратите внимание, что при компиляции Clang'ом, так как в теле корутины нет точек остановки, локальной переменной local нет необходимости "переживать" их, и она размещается просто на стеке, а не во фрейме корутины. Если расскоментировать строку с co_await std::suspend_never{};, то это создаст в корутине точку приостановки, и мы увидим, что размер фрейма корутины увеличится, а переменная local - уйдёт во фрейм:
Local var in stack at 0x7ffd5391921b
Call new for coro frame: 32 bytes, sizeof(promise_type) 1, return 0x63d5492322c0
...
Coroutine ran, local var at 0x63d5492322d8
...
Если же напишем так:
some_hat test_coro() {
{
uint64_t local = 1;
std::cout << "Coroutine ran, local var at " << (void*)&local << "\n";
}
co_await std::suspend_never{};
}
То переменной local опять не нужно переживать точку приостановки, и она снова уйдёт на стек:
Call new for coro frame: 24 bytes, sizeof(promise_type) 1, return 0x5e40866a72c0
...
Coroutine ran, local var at 0x7ffe76d44868
...
К сожалению, пока ни GCC (15.2), ни MSVC (19.44) не делают таких оптимизаций, и в них все локальные переменные, независимо от времени жизни,
помещаются во фрейм корутины.
Теперь, когда мы разобрались как работают друг с другом части корутины, реализуем возможность внешнего управления корутиной и получением результатов.
Этот простой пример даст понятие, как это делать.
Показательно, что в корутине для примера используется и co_yield, и co_return, и во внешнем объекте имеются отдельные методы для получения как co_yield'еного значения, так и co_return'утого, просто чтобы показать, что "одно другому не мешает" и их можно совмещать.
#include <coroutine>
#include <iostream>
#include <optional>
struct some_hat {
// Запустить получение следующего значения
bool next() {
if (!coro_.done()) {
// возобновляем выполнение корутины
coro_.resume();
}
return !coro_.done();
}
// Получить очередное значение
int current() const {
return *coro_.promise().cv;
}
// Получить результат
int result() const {
return *coro_.promise().result;
}
struct promise_type {
int* cv{}; // yielded value, будем сохранять указатель на него
std::optional<int> result; // returned value
some_hat get_return_object() {
// Получим хендл корутины и вернём объект с ней
auto h = std::coroutine_handle<promise_type>::from_promise(*this);
return {h};
}
// Теперь будем корутину сразу приостанавливать при создании, чтобы тело корутины запускалось только по требованию
std::suspend_always initial_suspend() { return {}; }
// Теперь после выполнения тела корутины внешний объект всё ещё может обращаться к promise, поэтому
// чтобы фрейм не разрушился сразу, надо в конце приостановить корутину. А удалить её должен внешний объект.
std::suspend_always final_suspend() noexcept { return {}; }
// Тут запоминаем co_return'утое значение
void return_value(int v) {
result = v;
}
// А тут co_yield'енное
std::suspend_always yield_value(int& v) {
// Переданный нам по ссылке объект точно переживёт точку остановки, и указатель
// будет валиден до следующего возобновления.
cv = &v;
return {};
}
void unhandled_exception() {std::terminate();}
};
// Теперь так как на final_suspend корутина останавливается, за уничтожение фрейма отвечаем мы
~some_hat() { coro_.destroy(); }
// хендл нашей корутины
std::coroutine_handle<promise_type> coro_;
};
// Корутина с нашей шляпой фокусника
some_hat magicians_hat(int start, int end, int step) {
for (int rabbit = start; rabbit <= end; rabbit += step) {
co_yield rabbit;
co_yield ++rabbit;
}
co_return 42;
}
int main() {
some_hat hat = magicians_hat(0, 10, 2);
while(hat.next()) {
std::cout << hat.current() << " rabbits(s)\n";
}
std::cout << hat.result() << "\n";
return 0;
}
Стандартные генераторы
Начиная с C++23 есть стандартизованный класс для работы с co_yield и корутинами-генераторами: std::generator, который позволяет просто использовать корутины-генераторы в range-based for, так же, как в Python.
С ним использование нашей "шляпы фокусника" будет выглядеть очень просто:
#include <iostream>
#include <generator>
std::generator<int> magicians_hat(int start, int end, int step) {
for (int rabbit = start; rabbit <= end; rabbit += step) {
co_yield rabbit;
co_yield ++rabbit;
}
}
int main() {
for (int rabbit : magicians_hat(0, 10, 2)) {
std::cout << rabbit << " rabbit(s)\n";
}
return 0;
}
Однако совместить co_yield и co_return здесь не получится.
Многопоточность
Тут не будет много слов. Реализация корутин со стороны компилятора сделана без какой-либо оглядки на многопоточность, и всю синхронизацию по безопасному использованию корутин в нескольких потоках должен делать их пользователь. Если вы посмотрите в самом начале на нашу "доморощенную реализацию корутин", то вполне увидите, что вызов корутины из разных потоков по сути является конкурентным доступом к разделяемым данным - фрейму корутины, со всеми вытекающими последствиями и мерами предосторожности.
Также замечу, что в-принципе корутину можно спокойно остановить в одном потоке, а возобновить в другом. Если вы собираетесь их использовать в таком сценарии, например, реализовывать систему задач на тредпуле и т.п., то прежде всего стоит обратить внимание на такие моменты:
Двойной запуск корутины - вызывать
resume()когда корутина работает - UB, и почти всегда будет ломать программу. Используйте какую-либо синхронизацию или очереди, чтобы случайно не запустить корутину одновременно в нескольких потоках.В реализациях
await_suspendваших Awaitable объектов, как только вы передаёте её хендл в другой поток, обращаться к любым данным объекта или корутины становится небезопасно - она уже может быть возобновлена другим потоком, что приведёт к разрушению awaitable-объекта, или даже уже полностью завершится. Если какие-то из этих данных вам будут всё-таки нужны после этого, предварительно скопируйте их в локальные переменные вawait_suspend.Стандартные мьютексы - не рассчитаны на захват в одном потоке, а освобождение в другом, поэтому использовать их в корутинах надо очень аккуратно - если в корутине захватили мьютекс, проследите, чтобы она до момента его освобождения или не приостанавливалась, или после приостановки обязательно возобновлялась в том же потоке, в котором захватила мьютекс. Также некоторые библиотеки предоставляют свои варианта асинхронных примитивов синхронизации, заточенных для использования с корутинами.
Симметричные корутины
Если управление после приостановки корутины всегда возвращается к вызвавшему её запуск, такая схема работы называется "асимметричные корутины". Однако Awaitable объекты поддерживают и так называемую "симметричную передачу управления". Это реализуется, когда из await_suspend возвращается хендл какой-либо корутины. Тогда сначала происходит возобновление работы корутины, чей хендл был вернут. Это соответствует примерно такому псевдо-коду:
....
}
// вариант с std::coroutine_handle<> await_suspend
if (auto new_coro = awaitable.await_suspend(my_handle); new_coro != my_handle) {
new_coro.resume(); // Возобновляем другую корутину
return; // Приостанавливаемся
}
....
Стоит отметить, что вызванная таким образом корутина далее сама может при своей приостановке или окончании вызвать запуск какой-либо корутины, в том числе и той, из которой она была запущена.
Обычно для этого реализуют возвращаемый корутиной объект таким образом, чтобы он сам мог быть Awaitable-объектом, сам или через operator co_await. И при вызове co_await на таком объекте - запускать управляемую им корутину. При этом в его promise_type реализуют final_suspend::await_suspend так, что он снова возобновляет запустившую его корутину. Однако, если корутина к примеру в большом цикле много раз запускает таким образом другие корутины, а они при завершении снова возобновляют вызывавшую их, то легко добиться переполнения стека, поэтому такая техника крайне нуждается, чтобы компилятор использовал оптимизацию "хвостовых вызовов", которая в этом случае заменит вызовы resume на обычные переходы. Оптимизация -O2 обычно уже включает её, а -O0 - нет.
Для GCC и Clang включить отдельно такую оптимизацию можно как -foptimize-sibling-calls.
Заключение
Тех, кому вся эта машинерия кажется сложной, хочу успокоить. Обычно она пишется один раз или берётся какая-либо готовая реализация, а дальше сами корутины пишутся легко, просто, удобно, красиво.
В статье я постарался охватить наиболее ключевые моменты для понимания работы корутин в C++, но возможно описал ещё не все тонкости. Однако надеюсь, у вас теперь появилось более полное представление о том, как оно работает, и документацию или cppcoro теперь будет легче понять.
Спасибо за внимание!
