Стандарт C++20: обзор новых возможностей C++. Часть 5 «Корутины»
25 февраля автор курса «Разработчик C++» в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ — Стандарте C++20. В лекции сделан обзор основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.
При подготовке вебинара стояла цель сделать обзор всех ключевых возможностей C++20. Поэтому вебинар получился насыщенным. Он растянулся на почти 2,5 часа. Для вашего удобства мы разбили текст на шесть частей:
- Модули и краткая история C++.
- Операция «космический корабль».
- Концепты.
- Ranges.
- Корутины.
- Другие фичи ядра и стандартной библиотеки. Заключение.
Это пятая часть, кратко рассказывающая о корутинах, или сопрограммах, в современном C++.
В программировании есть два стула — эффективность и красота. И если вы пишете эффективные программы и оптимизированный код, то иногда приходится жертвовать понятностью, читаемостью и, как следствие, надёжностью.
Но существуют решения, позволяющие усидеть на двух стульях.
Мотивация
Очень часто при программировании возникает задача вернуть из функции не один объект, а целый набор. Есть несколько вариантов решения:
- Можно всё записать в вектор и вернуть его. Но это не очень хорошая идея, потому что тому, кто вызывает функцию, вектор может быть не нужен.
- Можно вернуть пару итераторов, но тогда кто-то должен этими объектами владеть. Функция уже завершилась, она ими владеть не может, поэтому этот вариант не всегда годится.
- Можно использовать выходной итератор — так делают стандартные алгоритмы C++, например
copy_if
. - Callback — вы передаете функциональный объект. Когда элемент готов, функция его вызывает.
В целом способы неплохи, но минусы есть у каждого — либо это издержки, либо излишние шаблоны. Код становится некрасивым и теряет читаемость. А мы хотим её сохранить, чтобы было с одной стороны красиво и понятно, с другой эффективно, как
callback
; шаблонный callback — это очень эффективно.Когда в программе происходит обмен данными, всегда есть альтернативы.
- Активный источник. У него появились данные, он говорит:»держи мои данные, обрабатывай». Тот, кто обрабатывает данные, может только принимать их, но не может запрашивать.
- Активный потребитель. В этой ситуации обработчик говорит: «мне нужны данные». Источник отвечает: «ОК, вот, держи, сейчас дам всё, что есть».
Совместить эти способы нельзя — либо одно, либо другое.
Ещё один мотивационный пример — программы, выполняющие много операций ввода-вывода. В них эффективность даётся потом и кровью. Типичный пример — веб-сервер. Он вынужден общаться со многими клиентами одновременно, но при этом больше всего он занимается одним — ожиданием. Пока данные будут переданы по сети или получены, он ждёт. Если мы реализуем веб-сервер традиционным образом, то в нём на каждого клиента будет отдельный поток. В нагруженных серверах это будет означать тысячи потоков. Ладно бы все эти потоки занимались делом, но они по большей части приостанавливаются и ждут, нагружая операционную систему по самые помидоры переключением контекстов.
Эффективные серверы, например nginx, так не делают. Вместо ожидания они поручают тому же потоку выполнять другую операцию. Иными словами, логика работы потока такая:
- выполнить активную фазу задачи до ожидания;
- спросить у планировщика, есть ли ещё активные задачи на данный момент;
- если они есть, начать их делать;
- только если уже всё сделано — ждать.
Так может оказаться, что одна и та же задача выполняется двумя или более разными потоками: вначале активная фаза выполнена одним потоком, затем задачу подбирает другой поток.
Программировать такую систему очень сложно, и код получается нечитаемым. Поэтому nginx использует нестандартные возможности — в C++17 стандартными средствами подобного не реализовать.
Что у других
В Python корутины, или сопрограммы, существуют уже давно. Достаточно написать в функции волшебное слово
yield
, и она станет частным случаем корутины — генератором: def csv_reader(file_name):
for row in open(file_name, "r"):
yield row
for line in csv_reader("abc.csv"):
print(line)
Тут реализована функция чтения файла по строкам.
yield
очень похож на return
— он тоже возвращает значение. Но в отличие от return
не прерывает работу функции. Вернее, прерывает, но не насовсем — лишь на то время, которое необходимо программе, чтобы обработать возвращённое значение. А после того, как значение обработано, и глобальный цикл перешёл к следующей итерации, функция csv_reader
продолжит работу дальше, с того места, где был yield
. Есть также возможность передавать данные в обратном направлении — из внешнего цикла в csv_reader.
Это совершенно новая концепция: управление возвращается в функцию после того, как ушло из неё. Поработала функция csv_reader
, потом поработал цикл, потом снова csv_reader
с того же места, где был сделан yield
.
Привычная логика, при которой функция, вызывающая другую функцию, ждёт её завершения, нарушается. Две функции работают попеременно. Стек вызовов превращается в дерево вызовов.
def csv_reader(file_name):
for row in open(file_name, "r"):
yield row
def line_reader(str):
for c in str:
yield c;
for line in csv_reader("abc.csv"):
for x in line_reader(line):
print(x)
В последней строке этого примера на Python работают сразу три функции. Глобальный цикл выполняется, а две корутины ждут, пока управление вернётся к ним.
Пример
Не хочу вас разочаровать, но примера не будет. На то есть причина. Дело в том, что корутины реализованы в Стандарте C++20 не полностью. Вернее так: всё, что касается ядра языка, уже сделано. А вот часть стандартной библиотеки комитет доработать не успел и оставил на Стандарт C++23.
Из сложившейся ситуации есть три выхода:
- Подождать следующего Стандарта.
- Написать реализацию нужных функций и классов самостоятельно.
- Использовать готовую библиотеку для корутин. Они уже существуют. Например, Folly или cppcoro.
Найти пример и попробовать корутины уже можно. Удивительный код, в котором выполнение функции начинается в одном thread, а заканчивается в другом, есть в cppreference.
Немного теории
На этом рассказ о корутинах в вебинаре заканчивался. Но учитывая живой интерес к предыдущим частям на Хабре, я решил слегка расширить его и даже добавить пример. Но прежде немного теории.
Если вы не хотите бежать впереди паровоза и реализовывать логику корутин вручную, не дожидаясь, пока это сделает комитет в C++23, то возможно, эти знания вам никогда в жизни не понадобятся. Но в сложных случаях, а также при борьбе с ошибками хотя бы примерное знание устройства корутин может сэкономить вам много времени.
Итак, корутиной становится функция, содержащая одно из трёх ключевых co_
-слов:
co_await
— для прерывания функции и последующего продолжения;co_yield
— для прерывания функции с одновременным возвратом результата. Это синтаксический сахар для конструкции сco_await
;co_return
— для завершения работы функции.
Вся магия заключается в типе возвращаемого значения — это Proxy-класс, описывающий поведение корутины. Его нужно написать явно: использовать
auto
для типа возврата нельзя. Также корутина не может иметь variadic template в списке аргументов и return
нигде внутри себя: все возвращения — только через co_yield
и co_return
.Вызывается корутина как функция, возвращающая некоторый объект произвольного типа T
. С точки того, кто её вызвал, она и выглядит как обычная функция. Всё взаимодействие осуществляется через тот объект, который она вернула при запуске.
Например, если корутина — генератор значений, то в типе T
можно определить метод GetNextValue()
, который будет возобновлять корутину до получения следующей порции данных. Либо можно определить в T
методы begin
и end
, чтобы на лету итерироваться по значениям, выдаваемым корутиной, обычным циклом for
по диапазону.
Сам класс T
, который вы указали как тип возврата при написании корутины, отдаёт команды через объект класса std::coroutine_handle
, определённого в
. Например, у него есть метод resume
, возобновляющий приостановленную корутину, и метод destroy
, окончательно завершающий её выполнение. Корутина должна быть готова к уничтожению через destroy
: однажды уснув, она может и не проснуться. При этом все её локальные переменные будут корректно деаллоцированы.
Пользователь, вызвавший корутину, не обязан знать, что на самом деле эта функция не завершилась и периодически возобновляет работу. Отсюда возникает опасность утечки памяти: вызывающая функция может закончить работу, не прогнав полностью всю корутину и не уничтожив её. Тогда все локальные переменные навечно до завершения процесса останутся в памяти ждать возобновления корутины.
Сейчас может возникнуть вопрос: откуда возьмётся объект типа T
, если корутина только начинает работу и ещё ничего не вернула? И главное: откуда у него возьмётся coroutine_handle
? Ответы на эти эти вопросы ниже, потерпите немного.
Добавлю пару слов о сердце корутины — типе возвращаемого значения, в котором описывается вся магия прерывания, возобновления и передачи значений через co_yield
. Тип корутины T
довольно произвольный, но одна деталь в нём присутствовать обязана — вложенная структура T::promise_type
. Её объект будет сконструирован первым делом при запуске. promise_type
определяет поведение корутины и может иметь следующие методы:
??? initial_suspend()
— определяет поведение корутины при её вызове. Типичная корутина возвращаетstd::suspend_never
, определённый в
, если она должна стартовать сразу при вызове, либоstd::suspend_always
, если это ленивое вычисление и приостановка работы происходит сразу;??? final_suspend()
— аналогично определяет поведение корутины при завершении;T get_return_object()
— тут конструируется объект типаT
— главное возвращаемое значение. Какой конструктор использовать — дело того, кто разрабатывает этот метод. В частности, можно передатьcoroutine_handler
в конструктор, вызвав статический методstd::coroutine_handle
, принимающий на вход объект типа::from_promise promise_type
;??? yield_value(??? value)
— метод, который вызывается при возврате из корутины черезco_yield
. Если вы не вызываетеco_yield
, то реализовывать его не обязательно. Он может возвращатьstd::suspend_always
, чтобы корутина приостанавливалась для обработки значения одновременно с возвратом. За сохранение и передачуvalue
полностью отвечаетT
иpromise_type
, никакой магии тут нет. Типичный способ — сделатьmove
во внутреннюю переменную;??? return_value(??? value)
— метод, который вызывается при возврате из корутины черезco_return
. Если вы не вызываетеco_return
, то реализовывать его не обязательно;??? await_transform(??? value)
— метод, вызываемый приco_await
. На вход ему подаётся значение выражения стоящего послеco_await
, а на выходе —std::suspend_always
или другой awaitable-объект;??? await_resume()
— этот метод вызывается при возобновлении работы послеco_await
илиco_yeild
. Он может возвращатьvoid
или другое значение, которое становится значением выраженияco_await ???
;static void unhandled_exception()
— поведение при необработанном исключении в корутине.
Быстрее понять устройство корутин можно на примере:
#include
#include
#include
template
class Lazy {
public:
// Объект типа promise_type создаётся первым делом при запуске корутины.
// Он содержит обработчики всевозможных событий корутины.
struct promise_type {
// Этот метод вызывается в начале работы корутины.
// Он конструирует «традиционное» возвращаемое значение —
// то, что сразу же получает функция, вызвавшая корутину.
Lazy get_return_object() {
return Lazy{std::coroutine_handle::from_promise(*this)};
}
// Этот метод вызывается при старте корутины. Он определяет,
// будет ли она выполняться сразу, или начнёт с приостановки.
// std::suspend_always означает приостановку
static std::suspend_always initial_suspend() noexcept { return {}; }
// Этот метод вызывается при завершении корутины.
static std::suspend_always final_suspend() noexcept { return {}; }
// Это обработчик события co_return. Ему передаётся то, что
// корутина хочет вернуть.
std::suspend_never return_value(T value) noexcept {
current_value = std::move(value);
return {};
}
// Обработчик исключений также обязательно реализовывать.
[[noreturn]]
static void unhandled_exception() {
throw;
}
// Сюда будем сохранять возвращённые значения.
// Можем добавить любые поля по своему желанию.
std::optional current_value;
};
// Объект будет конструировать promise_type в методе get_return_object.
// Опишем все конструкторы.
explicit Lazy(const std::coroutine_handle coroutine) :
m_coroutine{coroutine} {
}
Lazy(Lazy&& other) noexcept :
m_coroutine{std::exchange(other.m_coroutine, {})}
{
}
Lazy(const Lazy&) = delete;
Lazy& operator=(const Lazy&) = delete;
// В деструкторе нужно не забыть жёстко прервать корутину, чтобы
// предотвратить утечку памяти, если значение осталось невостребованным.
~Lazy() {
if (m_coroutine) {
m_coroutine.destroy();
}
}
// Этот метод мы добавили для того, чтобы получить значение из корутины.
T get_value() {
// Если значения нет, дадим корутине команду поработать
if (!m_coroutine.promise().current_value)
m_coroutine.resume();
return std::move(*m_coroutine.promise().current_value);
}
private:
// Хендлер, который нам дал promise_type при конструировании
std::coroutine_handle m_coroutine;
};
template
Lazy lazy_sum(T x, T y) {
std::cout << "[sum performed]" << std::flush;
co_return x + y;
}
int main() {
// Запускаем корутину, но она сразу же
// приостанавливается и ничего не суммирует
Lazy x = lazy_sum(42, 58);
// Вызов get_value() отдаёт команду возобновления
std::cout << "Computed sum: " << x.get_value() << std::endl;
// Вывод: Computed sum: [sum performed]100
}
Пример носит иллюстративный характер — для такой задачи использовать корутину совсем не обязательно. Того же эффекта можно было добиться лямбда-функцией. Более содержательный пример можно найти на cppreference.
Логика выполнения корутин полностью отличается от логики работы обычных функций. Например, потому что автоматические переменные корутины не всегда можно разместить в стеке: одна корутина может завершиться до другой, запущенной позже. Тем самым нарушается правило FIFO. Поэтому при запуске корутины создаётся специальный объект для хранения её переменных. Компилятор будет стараться разместить его в стеке, ну, а если не получится, то придётся прибегнуть к динамической памяти.
Заключение
На этом краткий обзор корутин окончен. Более подробно об их применении можно узнать из доклада Антона Полухина.
Во время вебинара мы спросили у аудитории, нравится ли эта функция. Результаты опроса:
- Суперфича — 16 (34,04%)
- Так себе фича — 7 (14,89%)
- Пока неясно — 25 (53,19%)
Мнения разошлись, но большинство слушателей пока не готовы к корутинам. Я бы тоже повременил с их использованием, по крайней мере до выхода C++23.
Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.