Прим. Wunder Fund: В статье описаны базовые подходы к работе с корутинами в 20м стандарте С++, на паре практических примеров разобраны шаблоны классов для промисов и фьючеров. По нашему скромному мнению, можно было бы реализовать и поизящнее. Приходите к нам работать, если имеете сильные мнения о корутинах хе-хе.

Возникает такое ощущение, что тема реализации корутин в C++20 окутана серьёзной неопределённостью. Полагаю, это так из-за того, что в проекте технической спецификации C++20 сказано, что работа над механизмами корутин всё ещё ведётся, в результате в данный момент нельзя ожидать полной поддержки этих механизмов компиляторами и стандартной библиотекой. Множество проблем, вероятно, возникает из-за отсутствия официальной документации по работе с корутинами. Нам дали синтаксическую поддержку корутин в C++ (co_yield и co_return), но не всё то, что я счёл бы признаками их полной библиотечной поддержки. В стандартной библиотеке имеются хуки и базовый функционал поддержки корутин, но нам приходится самостоятельно встраивать всё это в наши собственные классы. Я ожидаю, что полная поддержка корутин-генераторов появится в C++23.

Спецификация C++20, очевидно, направлена на поддержку параллельных (или асинхронных) корутин с использованием co_await, что усложняет реализацию более простых синхронных корутин-генераторов. Среди требований к реализации наших корутин имеются сведения об использовании Future и Promise, что похоже на то, как при реализации асинхронных потоков используется std::async.

Если вы — Python- или C#-разработчик и ожидаете увидеть в C++ простую механику работы с корутинами, то вас ждёт разочарование, так как фреймворк общего назначения C++20 недоработан. Учитывая это, можно отметить, что в интернете имеется множество публикаций, в состав кода, обсуждаемого в которых, входит шаблонный класс, поддерживающий корутины-генераторы. В этом материале вы найдёте шаблон корутины, применимый на практике, а также примеры кода. Всё это предваряется общими сведениями о корутинах.

Что такое корутины?

Я впервые столкнулся с корутинами, увидев инструкцию yield в CLU. Корутины, наподобие генераторов в Python (и конструкции yield return в C#), определяются с использованием функционального синтаксиса, а доступ к ним организуется с применением синтаксических конструкций цикла for. Корутины описывались как взаимодействующие программы (но не как конкурентные программы), выполняющиеся в одном потоке. Существуют и другие разновидности корутин. Для того чтобы разобраться в том, чем отличаются друг от друга функции, генераторы и потоки, можно начать с этой статьи из Википедии.

В этом материале я уделяю основное внимание корутинам, выполняемым в контексте вызывающей стороны, применение которых позволяет двум различным блокам кода периодически передавать друг другу управление ходом выполнения программы.

Новая инструкция co_yield в C++20 позволяет одной программе предоставить фрагмент данных, и в то же время вернуть управление ходом выполнения программы вызывающей программе для обработки этих данных. В общем-то, это — всего лишь витиеватый способ сказать о том, что корутины в C++20 дают нам однопоточную реализацию паттерна продюсер/консьюмер (producer/consumer).

Мы можем показать классический пример взаимодействия продюсера и консьюмера, связанный с применением корутин, подготовив следующую UML-диаграмму последовательности.

Корутина-генератор
Корутина-генератор

Блоки, олицетворяющие время, когда управление ходом выполнения программы находятся у той или иной сущности, показывают передачу управления от одной программе другой.

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

В спецификации C++20 сказано, что состояние корутины сохраняется в куче, то есть — корутины не подходят для встраиваемых систем, которые не используют динамическую память. Но в спецификации абсолютно чётко заявлено, что в конкретной реализации языка использование кучи может быть убрано при соблюдении следующих условий:

  • Если время жизни корутины строго ограничено временем жизни вызывающей стороны.

  • Если размер состояния корутины может быть определён во время компиляции кода.

На практике, в случае с простыми корутинами-генераторами, которым посвящён этот материал, подобные корутины соответствуют этим критериям, состояние таких корутин может быть сохранено в кадре стека вызывающей стороны. Исследование использования кучи в двух примерах, рассмотренных в этой статье, показывает, что и GCC-11, и Clang-12 (последние версии компиляторов на момент написания материала) используют для сохранения состояния корутин кучу. Учитывая то, что поддержка корутин в компиляторах появилась сравнительно недавно, и то, что она сейчас находится в процессе развития, вполне возможно, что в более поздних их версиях соответствующий код будет оптимизирован, или, возможно, появится поддержка опций компиляторов для включения или отключения сохранения состояния корутин в динамической памяти.

Для организации поддержки сохранения и восстановления состояния корутин мы должны предоставить системе вспомогательный класс, который интегрируется с механизмами поддержки корутин в стандартной библиотеке, описанными в заголовочном файле, подключаемом к коду с помощью конструкции #include <coroutine>. Именно в этой сфере сейчас и находится всё то, что вызывает сложности в реализации корутин.

Поддержка корутин в C++20

Для того чтобы приступить к разговору о корутинах — мы можем создать одну из них, выдающую фразу «Hello world!» в виде трёх отдельных объектов. Её код показан ниже (тут нам нужно подключить заголовочный файл <coroutine>).

#include <coroutine>

X coroutine()
{
    co_yield "Hello ";
    co_yield "world";
    co_return "!";
}

Первое, на что тут можно обратить внимание, заключается в том, что это — не определение функции! Мы только что использовали синтаксис функции для определения блока кода, которому могут быть переданы аргументы при создании его экземпляра. У функции имелась бы инструкция return (или как в случае с void-функциями, подразумевалось бы, что значение явным образом не возвращается). А тут код выдаёт три отдельных значения. Обратите внимание на то, что воспользоваться инструкцией return в корутине нельзя.

Второй интересный момент заключается в том, что мы возвращаем какой-то неизвестный (на данный момент) объект типа X. Это — объект, который реализует корутину. Компилятор реорганизует наш блок кода для реализации механизмов корутин, предусматривающих сохранение и восстановление состояния, но сейчас ему нужна небольшая помощь от нас, которая выражается в написании вспомогательного класса X.

В блоке кода, представляющего корутину, мы используем co_yield для выдачи значения и сохранения состояния корутины, а co_return — для выдачи значения и выхода из корутины без сохранения её состояния.

Это предельно простой пример использования корутины, где обратиться к ней нужно в точности три раза — как показано в следующем примере:

auto x = coroutine();
std::cout << x.next();
std::cout << x.next();
std::cout << x.next();
std::cout << std::endl;

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

В нашем примере у объекта корутины есть метод next, вызов которого приводит к выполнению следующих действий:

  1. Приостановка выполнения текущего кода консьюмера.

  2. Восстановление состояния корутины (продюсера).

  3. Возобновление выполнения кода корутины с предыдущей инструкции, выдающей значение (или с начала блока кода).

  4. Сохранение значения, полученного из следующей инструкции, выдающей значение.

  5. Сохранение состояния корутины.

  6. Восстановление состояния консьюмера.

  7. Возобновление выполнения кода консьюмера путём передачи ему значения, сохранённого после выполнения соответствующей инструкции, выдающей значение.

Сейчас в стандартной библиотеке нет шаблона для нашего класса X. В результате нам нужно разобраться с тем, что сейчас имеется в стандартной библиотеке в плане поддержки корутин. Пример шаблонного класса показан ниже, там, где мы будем говорить о практическом применении корутин, а пока мы взглянем на базовый пример, код которого написан исключительно в учебных целях.

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

Для использования корутин в C++20 нам нужно подготовить два взаимосвязанных вспомогательных класса:

  • Класс для сохранения состояния корутины и для сохранения выданных данных. Обычно его называют promise.

  • Класс для управления объектом корутины (promise). Это класс X, который, по традиции, называют future.

В объекте типа Promise нужно реализовать несколько методов жизненного цикла корутины. Пока мы сосредоточимся на поддержке выражений, выдающих значения, и не будем обращать внимания на методы, необходимые для управления состоянием корутины.

Так как наша корутина использует инструкцию co_yield со значением const char*, нам нужен метод со следующей сигнатурой:

std::suspend_always yield_value(const char* value);

Аргумент — это выдаваемый корутиной объект, возвращаемый тип сообщает системе выполнения кода о том, нужно ли сохранять состояние потока, что, в случае с однопоточной корутиной, мы всегда планируем делать, возвращая объект std::suspend_always. В данной ситуации есть возможность возврата объекта std::suspend_never, что допустимо при работе с асинхронными корутинами, но это ведёт к множеству сложностей, связанных с управлением приостановленными потоками и с возобновлением их работы. Мы не собираемся с этим связываться, работая над нашей простой синхронной корутиной.

Метод yield_value обязан сохранить свой аргумент, в результате он может быть возвращён вызывающей программе (консьюмеру). Вот как выглядит типичная реализация этого метода:

std::suspend_always yield_value(const char* value) {
    this->value = value;
    return {};
}

Если вы ещё не сталкивались с современной синтаксической конструкцией C++ return {}, то знайте, что её смысл заключается всего лишь в том, чтобы создать объект возвращаемого типа этого метода, конструируемый по умолчанию. Ещё тут можно было использовать return std::suspend_always{}.

Для поддержки инструкции co_return, которая выдаёт значение, но не сохраняет состояние, нам нужен второй метод жизненного цикла корутины:

void return_value(const char* value) {
    this->value = std::move(value);
}

Вызов co_return завершает выполнение корутины, соответствующая функция жизненного цикла корутины имеет возвращаемый тип void, так как состояние корутины будет уничтожено.

Если не вдаваться в детали реализации класса X, можно показать, как компилятор может расширить код сущности-консьюмера, превратив его в набор встроенных последовательных операций, а после этого взглянуть на методы жизненного цикла. В следующем примере метод promise даёт доступ к объекту Promise, который сохраняет состояние корутины и выдаваемое ей значение. Метод next может получить сохранённое значение из объекта Promise:

auto x = coroutine();
x.promise().yield_value("Hello "); // сохраняется значение и состояние
std::cout << x.next();
x.promise().yield_value("world");  // сохраняется значение и состояние
std::cout << x.next();
x.promise().return_value("!");    // сохраняется значение, но не состояние
std::cout << x.next();
std::cout << std::endl;

Можно видеть, что компилятор преобразовал наш код, превратив два блока кода в один, представленный последовательным набором вызовов чередующихся методов.

Перед тем как мы разберём полноценный пример, в котором имеется весь необходимый шаблонный код для классов Promise и Future, нам нужно взглянуть на альтернативный способ написания кода корутин-генераторов:

X coroutine()
{
    co_yield "Hello ";
    co_yield "world";
    co_yield "!";
//  подразумеваемый вызов co_return;
}

При применении такого подхода мы используем co_yield для всех значений, не выделяя особым образом (с помощью co_return) последнюю операцию выдачи значения; мы просто позволяем блоку кода завершить работу. Компилятор сам добавит в нужное место инструкцию co_return для завершения работы корутины.

Для обработки инструкции co_return (без значения) нам нужна реализация особого метода жизненного цикла корутины void return_void:

void return_void() {
    this->value = nullptr;
}

Класс Promise не может предоставить и метод return_value, и метод return_void, которые считаются взаимоисключающими.

В этом простейшем примере код консьюмера не меняется, так как он выполняет чтение в точности трёх значений. В более реалистичном примере, где чтение значений из корутин выполняется в цикле, нам нужно каким-то образом отметить конец потока данных. Тут используются указатели, в результате для завершения цикла может быть использован nullptr; в противном случае наиболее общим подходом можно назвать объект std::optional.

Наш новый консьюмер с возможностью остановки работы выглядит так:

auto x = coroutine();
while (const char* item = x.next()) {
    std::cout << item;
}
std::cout << std::endl;

Мы могли бы описать цикл так:

while (auto item = x.next()) {

Но тут мы решили сохранить явное объявление типа, в результате из этого кода ясно то, как именно используется генератор.

Полная версия этого кода находится в файле char_demo.cpp в GitHub-репозитории coroutines-blog.

Работа с корутинами

Корутины — это удобный механизм для реализации множества алгоритмов в виде отдельных блоков кода, а не в такой форме, когда соответствующий код собирают в одном месте, где реализации разных алгоритмов оказываются смешанными друг с другом.

В качестве примера рассмотрим встроенное устройство, которое наблюдает за некими значениями, например, за показателями температуры, и выводит эти значения на последовательный порт (RS232), снабжая их отметкой времени. Это может быть время с момента загрузки устройства, или точное время, синхронизированное по сети.

Отметка времени и значение сохраняются в виде числа с плавающей запятой (каждое занимает 4 байта) и в двоичном виде сохраняются в виде потока байтов. Сделано это ради снижения сложности кода и размера обрабатываемых данных. Поток данных выглядит примерно так, как показано ниже (тут используется обратный (little endian) порядок байтов).

Двоичный формат, используемый для хранения отметки времени и элемента данных
Двоичный формат, используемый для хранения отметки времени и элемента данных

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

Общий алгоритм работы системы будет выглядеть так:

  1. Чтение 4 байтов, необходимых для создания отметки времени.

  2. Чтение 4 байтов, необходимых для создания элемента данных.

  3. Создание структуры данных, содержащих оба значения, представленных числами с плавающей запятой.

  4. Вывод значений из структуры данных.

  5. Вывод предупреждения в том случае, если показатель температуры превысит заданное пороговое значение.

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

При использовании корутин подобный код можно разбить на два блока:

  1. Парсинг данных.

  2. Вывод данных и, возможно, предупреждающего сообщения.

Мы на практике пойдём ещё дальше и разобьём первый блок на две части:

  1. Разбор необработанного потока байтов и преобразование их в числа с плавающей запятой.

  2. Сохранение отметки времени и показателя температуры в структуре.

Шаблон Future для корутины

Первым шагом нашей работы будет создание шаблона для классов, о которых мы уже говорили, представляющих класс Future корутины и класс Promise для данных.

Класс Promise, хранящий данные

Вот — класс Promise, представляющий собой структуру, вложенную в класс Future:

template <typename T>
class Future
{
    class Promise
    {
    public:
        using value_type = std::optional<T>;
 
        Promise() = default;
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void unhandled_exception() { 
            std::rethrow_exception(std::move(std::current_exception())); 
        }
        std::suspend_always yield_value(T value) {
            this->value = std::move(value);
            return {};
        }
        void return_void() {
            this->value = std::nullopt;
        }
        inline Future get_return_object()
        value_type get_value() {
            return value;
        }
    private:
        value_type value{};
    };
    …
};

Структура Promise (которую мы объявили приватной для включающего её в себя класса Future) сохраняет отдельное значение данных в приватном объекте std::optional с методом доступа get_value. Используя объект std::optional мы можем воспользоваться std::nullop для проверки на завершение работы корутины после вызова метода return_void. Мы придерживаемся стиля метапрограммирования шаблонов C++, определяя признак типа value_type, что позволяет нам опрашивать класс для определения типа данных, лежащего в его основе.

Мы создаём конструктор, используемый по умолчанию и два метода жизненного цикла, необходимых для Promise-объекта корутины (initial_suspend и final_suspend), которые всегда приостанавливают работу корутины, чтобы мы могли бы работать в однопоточном режиме. Эти методы жизненного цикла необходимы, но это — лишь их стандартные реализации, которые не нуждаются в дальнейшем рассмотрении.

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

Методы yield_value и return_void, о которых мы уже говорили, определены для того, чтобы копировать или перемещать выданное корутиной значение в хранилище std::optional, или для того, чтобы использовать std::nullopt для указания на завершение работы корутины. Обратите внимание на использование std::move. Это сделано для того, чтобы обеспечить поддержку семантики перемещения данных для аргументов функции, передаваемых по значению: это необходимо, например, если надо выдать std::unique_ptr.

Ещё один метод, который нужно подготовить, это — get_return_object. Он должен возвращать объект Future для данного объекта Promise. Так как мы пока не завершили определение класса Future, нам нужно реализовать этот метод после того, как будут готовы классы Future и Promise.

Класс Future — менеджер контекста корутины

Сам класс Future предоставляет нам конструктор/деструктор для управления составным объектом Promise, а так же — механизм для получения значений, выданных корутиной (метод next, о котором мы уже говорили):

template <typename T>
class Future
{
    struct Promise { … };
public:
    using value_type = T;
    using promise_type = Promise;
    explicit Future(std::coroutine_handle<Promise> handle)
    : handle (handle)
    {}
    ~Future() {
        if (handle) { handle.destroy(); }
    }
    // Promise::value_type next() { … }
private:
    std::coroutine_handle<Promise> handle;
};

В стандартной библиотеке имеется поддержка управления объектами Promise через шаблонный класс std::coroutine_handle, передаваемый в виде аргумента конструктору класса Future. Нам нужно сохранить этот объект coroutine_handle и обеспечить вызов его метода destroy при уничтожении объекта Future.

Стандартная библиотека предъявляет ещё одно требование для класса Future, в соответствии с которым мы должны определить вложенный тип promise_type, что позволит шаблонам стандартной библиотеки выяснять тип данных, лежащий в основе класса.

using promise_type = Promise;

В нашей реализации метода next необходимо обеспечить проверку того, что объект Promise всё ещё актуален, или вернуть пустой объект std::optional:

Promise::value_type next() {
    if (handle) {
        handle.resume();
        return handle.promise().get_value();
    }
    else {
        return {};
    }
}

Вот что мы делаем для возврата значения, выданного корутиной:

  • Мы просто проверяем, существует ли всё ещё корутина (её объект handle не был уничтожен).

  • Мы вызываем метод resume объекта coroutine_handle для выполнения кода до следующей инструкции co_yield.

  • Мы возвращаем значение, сохранённое методом yield_value объекта Promise: благодаря поддержке стандартной библиотеки будут обработаны операции восстановления и сохранения состояния корутины.

  • Если корутин была уничтожена — мы возвращаем пустое значение (std::nullopt).

Теперь, когда определён класс Future, мы можем дополнить объект Promise необходимым методом get_return_object:

template <typename T>
inline Future<T> Future<T>::Promise::get_return_object()
{
    return Future{ std::coroutine_handle<Promise>::from_promise(*this) };
}

Тут мы используем метод std::from_promise для создания объекта coroutine_handle, который передаётся конструктору Future.

Как видите, перед нами — всего лишь стандартный код, заготовка для создания классов Future и Promise.

Теперь, когда завершены подготовительные операции, этот шаблон может быть использован в применении к большинству типов данных и классов. Я сказал бы, что он применим ко всем классам, но всегда найдутся особые случаи, в которых воспользоваться им не получится.

Корутина, занимающаяся сбором данных

Теперь можно сосредоточиться на нашей реальной задаче, которая заключается в организации сбора данных. Первый шаг работы заключается в написании корутины, занимающейся чтением данных из объекта istream и выдачей значений, представляющих собой числа с плавающей запятой:

Future<float> read_stream(std::istream& in)
{
    int count{};
    uint8_t byte;
    while (in >> byte) {
        data = data << 8 | byte;
        if (++count == 4) {
            co_yield reinterpret_cast<float>(&data);
            data = 0;
            count = 0;
        }
    }
}

Тут мы просто читаем блоки данных размером 4 байта и помещаем их в 32-битные слова, после чего используем приведение типов, интерпретируя соответствующую область памяти в виде числа с плавающей запятой для инструкции co_yield. Если поток данных завершается в ходе чтения 4-байтового слова, мы игнорируем частично прочитанное значение и завершаем работу корутины.

Мы можем подтвердить работоспособность этой корутины, просто выводя каждое float-значение, которое прочитано из стандартного потока ввода:

auto raw_data = read_stream(std::cin);
while (auto next = raw_data.next()) {
    std::cout << *next << std::endl;
}

А, так как надо сохранить пары значений в структуре данных, можно воспользоваться второй корутиной, которая инкапсулирует соответствующий алгоритм:

struct DataPoint
{
    float timestamp;
    float data;
};
Future<DataPoint> read_data(std::istream& in)
{
    std::optional<float> first{};
    auto raw_data = read_stream(in);
    while (auto next = raw_data.next()) {
        if (first) {
            co_yield DataPoint{*first, *next};
            first = std::nullopt;
        }
        else {
            first = next;
        }
    }
}

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

Последний шаг этого примера заключается в обработке значений, представляющих отметку времени и данные:

static constexpr float threshold{25.0};
int main()
{
    std::cout << std::fixed << std::setprecision(2);
    std::cout << "Time (ms)   Data" << std::endl;
    auto values = read_data(std::cin);
    while (auto n = values.next()) {
        std::cout << std::setw(8) << n->timestamp
                  << std::setw(8) << n->data
                  << (n->data > threshold ? " Threshold exceeded" : "")
                  << std::endl;
    }
    return 0;
}

Этот код выражает то, как мы решили обрабатывать данные, а все тонкости преобразования байтов в значения с плавающей запятой и их записи в структуру данных, скрыты в соответствующей корутине.

Хочется надеяться, что теперь вы способны прочувствовать плюсы использования корутин для разделения различных аспектов реализации сложных алгоритмов на более простые блоки кода. Сейчас, пользуясь C++20, нужно выполнять массу действий, которые кажутся сложными или ненужными для достижения нашей цели и заключаются в создании классов Future и Promise. Но я тем не менее надеюсь, что в C++23 уже будет встроено нечто подобное этому шаблону, что позволит программистам уделять внимание написанию собственного кода, не отвлекаясь на создание вспомогательных механизмов.

Протестировать этот код можно, воспользовавшись простым Python-скриптом, например, таким, который показан ниже. Он, в частности, выдаёт четыре жёстко заданных в коде элемента данных:

import struct
import sys
start = 0.0
for ms, value in enumerate([20.1, 20.9, 20.8, 21.1]):
    sys.stdout.buffer.write(struct.pack('>ff', start + ms*0.1, value))

Если скомпилированный исполняемый файл называется datapoint_demo, то мы можем воспользоваться следующим конвейером в командной строке Linux и убедиться в работоспособности корутин:

# Linux
python3 test_temp.py | ./datapoint_demo

В результате будет выведено следующее:

Time (ms)   Data
    0.00   20.10
    0.10   20.90
    0.20   20.80
    0.30   21.10 Threshold exceeded

Полный вариант кода этого примера, представленный файлами future.h и datapoint_demo.cpp, можно найти здесь. Для того чтобы скомпилировать эти примеры с использованием GCC (версии 10 или выше), нужно воспользоваться -std=c++20 и -fcoroutines в командной строке g++.

В следующем материале я планирую добавить в шаблонный класс Future поддержку итераторов, что позволит использовать корутину в цикле for или в виде входного итератора для неких библиотечных механизмов.

Итоги

Корутины — это мощная техника программирования, позволяющая разделять различные аспекты реализаций сложных алгоритмов, описывая их в виде самостоятельных и достаточно простых блоков кода.

C++20, как Python и C#, использует функциональный синтаксис для определения кода корутин. Многие программисты поначалу находят это странным, так как это — всего лишь синтаксическая конструкция для описания инструкций, входящих в состав корутины.

Как мы видели, то, что сейчас нет простого стандартного шаблона для создания корутин-генераторов, усложняет жизнь тем, кто только собирается попробовать корутины. Сейчас применение корутин несколько напоминает сборку пазла, картинки-загадки, в условиях, когда тот, кто пазл собирает, не видел картинки, которая у него должна получиться. Поначалу такая задача выглядит пугающе сложной, но решить её вполне реально. Надеюсь, что шаблон Future, который мы тут рассмотрели, это и есть та картинка, глядя на которую вы сможете собирать собственные пазлы.

О, а приходите к нам работать? ????

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
Присоединяйтесь к нашей команде.

Комментарии (11)


  1. artalex
    25.10.2021 21:22

    Интересно, какие накладные расходы несет использование корутин? Есть какие-нибудь тесты про это?


    1. Goron_Dekar
      26.10.2021 11:11
      +1

      Вот да. Когда люди выбирают писать на плюсах, они берут на себя множество сложностей: и полуручное управление памятью, и странную типизацию, и много другого непонятного. Но делают они это ради основного бонуса плюсов - низкими, понятными и управляемыми накладными расходами и реалтаймом. Но как в это укладываются современные тенденции вроде жиреющей стандартной библиотеки, и всех этих лямбд/корутин/многопоточности я не понимаю.

      Хорошо, я не профессиональный программист, я могу этого не понимать. Но мне кажется, что и профессиональные программисты, которых я встречаю по жизни (без учёта эмбеддеров, те любят современные плюсы за constexpr и шаблоны) вообще не задумываются как их код будет вести себя в реале. Их не интересует вопрос накладных расходов совсем. К примеру, они думают, что раз смартпоинтеры на плюсах, раз они в стандартной библиотеке, значит они zero-cost abstraction, или "оверхед мааааленький", и любой pointer должен быть smart. Зачем тогда писать на плюсах, для этого есть Java.


      1. Kelbon
        26.10.2021 12:23
        +3

        Извините какой то бред.

        они берут на себя множество сложностей: и полуручное управление памятью, и странную типизацию, и много другого непонятного

        Управления памятью вручную нет, странная типизация - первый раз слышу такое обвинение С++, "много другого" - непонятно о чём это.

         Но как в это укладываются современные тенденции вроде жиреющей стандартной библиотеки, и всех этих лямбд/корутин/многопоточности я не понимаю

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

        Чем вам многопоточность не угодила я вообще не понимаю

        И последнее, насчёт смартпоинтеров - накладные расходы действительно минимальны, если используется к месту. Естественно если всё в проекте заменить на shared_ptr, то это испортит проект, но С++ исходит из предположения, что программист не идиот


        1. kovserg
          26.10.2021 12:59
          +6

          С++ очень часто использует не обоснованные предположения.


        1. mrbaranovskyi
          27.10.2021 10:51

          "но С++ исходит из предположения, что программист не идиот" - так появился Go? :)


    1. rblaze
      28.10.2021 22:43

      Какие-то несёт. Как минимум на аллокации invocation record (того, что у корутин вместо стека) и переключения туда-сюда. Многое зависит от того, насколько хорошо написана библиотека поддержки корутин.

      Но корутины не будут заменять обычный линейный код, это бессмысленно. Они будут заменять или FSM, или код с мешком коллбэков, или futures. Тут есть неплохая вероятность получить ускорение за счёт более внятного кода. Если сравнивать folly::Future и корутины оттуда же, то мы получили небольшое ускорение за счёт того, что future всегда запускается асинхронно, а корутина может выполниться синхронно если проскочить мимо всех co_await.


  1. insecto
    26.10.2021 03:21
    +4

    А теперь с этим всем на борту мы попытаемся взлететь


  1. sshmakov
    26.10.2021 08:09
    +1

    А что случилось с std::promise, почему его нельзя использовать?


    1. ElegantBoomerang
      26.10.2021 12:58
      +1

      Так наверное можно же (разве что из коробки без поддержки, держитесь там), но std::promise это стандартный контейнер чтобы вернуть один «ответ», а пример на генераторе и «ответов» будет много или даже 0. А корутины чтобы сделать промис на вектор бесполезны.


  1. kovserg
    26.10.2021 10:38
    +2

    Сейчас применение корутин несколько напоминает сборку пазла, картинки-загадки, в условиях, когда тот, кто пазл собирает, не видел картинки, которая у него должна получиться

    А представьте каково будет тому, кому придётся разбираться и поддерживать код который был написан таким образом.


    1. rblaze
      28.10.2021 22:46

      Все эти проблемы у автора потому что он пытается писать библиотеку поддержки корутин самостоятельно. Разумеется это сложно, попробуйте в том же питоне написать asyncio самостоятельно или даже понять что там написано. Если же взять готовую библиотеку, то становится гораздо легче и писать, и разбираться.