Стандарт C++20: обзор новых возможностей C++. Часть 5 «Корутины»

bsuxzhf28lzu5gp_ju_iyetuin8.png

25 февраля автор курса «Разработчик C++» в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ — Стандарте C++20. В лекции сделан обзор основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.

При подготовке вебинара стояла цель сделать обзор всех ключевых возможностей C++20. Поэтому вебинар получился насыщенным. Он растянулся на почти 2,5 часа. Для вашего удобства мы разбили текст на шесть частей:

  1. Модули и краткая история C++.
  2. Операция «космический корабль».
  3. Концепты.
  4. Ranges.
  5. Корутины.
  6. Другие фичи ядра и стандартной библиотеки. Заключение.

Это пятая часть, кратко рассказывающая о корутинах, или сопрограммах, в современном C++.

В программировании есть два стула — эффективность и красота. И если вы пишете эффективные программы и оптимизированный код, то иногда приходится жертвовать понятностью, читаемостью и, как следствие, надёжностью.

53zgppe8lhs4mk2ie1ezw86c3ti.png

Но существуют решения, позволяющие усидеть на двух стульях.

Мотивация


Очень часто при программировании возникает задача вернуть из функции не один объект, а целый набор. Есть несколько вариантов решения:
  1. Можно всё записать в вектор и вернуть его. Но это не очень хорошая идея, потому что тому, кто вызывает функцию, вектор может быть не нужен.
  2. Можно вернуть пару итераторов, но тогда кто-то должен этими объектами владеть. Функция уже завершилась, она ими владеть не может, поэтому этот вариант не всегда годится.
  3. Можно использовать выходной итератор — так делают стандартные алгоритмы C++, например copy_if.
  4. Callback — вы передаете функциональный объект. Когда элемент готов, функция его вызывает.

В целом способы неплохи, но минусы есть у каждого — либо это издержки, либо излишние шаблоны. Код становится некрасивым и теряет читаемость. А мы хотим её сохранить, чтобы было с одной стороны красиво и понятно, с другой эффективно, как callback; шаблонный callback — это очень эффективно.

Когда в программе происходит обмен данными, всегда есть альтернативы.

  1. Активный источник. У него появились данные, он говорит:»‎держи мои данные, обрабатывай». Тот, кто обрабатывает данные, может только принимать их, но не может запрашивать.
  2. Активный потребитель. В этой ситуации обработчик говорит: «мне нужны данные». Источник отвечает: «ОК, вот, держи, сейчас дам всё, что есть».

Совместить эти способы нельзя — либо одно, либо другое.

Ещё один мотивационный пример — программы, выполняющие много операций ввода-вывода. В них эффективность даётся потом и кровью. Типичный пример — веб-сервер. Он вынужден общаться со многими клиентами одновременно, но при этом больше всего он занимается одним — ожиданием. Пока данные будут переданы по сети или получены, он ждёт. Если мы реализуем веб-сервер традиционным образом, то в нём на каждого клиента будет отдельный поток. В нагруженных серверах это будет означать тысячи потоков. Ладно бы все эти потоки занимались делом, но они по большей части приостанавливаются и ждут, нагружая операционную систему по самые помидоры переключением контекстов.

Эффективные серверы, например 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.

Из сложившейся ситуации есть три выхода:

  1. Подождать следующего Стандарта.
  2. Написать реализацию нужных функций и классов самостоятельно.
  3. Использовать готовую библиотеку для корутин. Они уже существуют. Например, 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.

Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.

© Habrahabr.ru