Прим. 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>. Именно в этой сфере сейчас и находится всё то, что вызывает сложности в реализации корутин. Для того чтобы приступить к разговору о корутинах — мы можем создать одну из них, выдающую фразу «Hello world!» в виде трёх отдельных объектов. Её код показан ниже (тут нам нужно подключить заголовочный файл <coroutine>). Первое, на что тут можно обратить внимание, заключается в том, что это — не определение функции! Мы только что использовали синтаксис функции для определения блока кода, которому могут быть переданы аргументы при создании его экземпляра. У функции имелась бы инструкция return (или как в случае с void-функциями, подразумевалось бы, что значение явным образом не возвращается). А тут код выдаёт три отдельных значения. Обратите внимание на то, что воспользоваться инструкцией return в корутине нельзя. Второй интересный момент заключается в том, что мы возвращаем какой-то неизвестный (на данный момент) объект типа X. Это — объект, который реализует корутину. Компилятор реорганизует наш блок кода для реализации механизмов корутин, предусматривающих сохранение и восстановление состояния, но сейчас ему нужна небольшая помощь от нас, которая выражается в написании вспомогательного класса X. В блоке кода, представляющего корутину, мы используем co_yield для выдачи значения и сохранения состояния корутины, а co_return — для выдачи значения и выхода из корутины без сохранения её состояния. Это предельно простой пример использования корутины, где обратиться к ней нужно в точности три раза — как показано в следующем примере: После того как мы потребили все значения, выданные корутиной, её работа завершается, она освобождает всю память, использованную для хранения её состояния. В нашем примере у объекта корутины есть метод next, вызов которого приводит к выполнению следующих действий: Приостановка выполнения текущего кода консьюмера. Восстановление состояния корутины (продюсера). Возобновление выполнения кода корутины с предыдущей инструкции, выдающей значение (или с начала блока кода). Сохранение значения, полученного из следующей инструкции, выдающей значение. Сохранение состояния корутины. Восстановление состояния консьюмера. Возобновление выполнения кода консьюмера путём передачи ему значения, сохранённого после выполнения соответствующей инструкции, выдающей значение. Сейчас в стандартной библиотеке нет шаблона для нашего класса X. В результате нам нужно разобраться с тем, что сейчас имеется в стандартной библиотеке в плане поддержки корутин. Пример шаблонного класса показан ниже, там, где мы будем говорить о практическом применении корутин, а пока мы взглянем на базовый пример, код которого написан исключительно в учебных целях. Для того чтобы написать собственный вспомогательный класс X для корутин, нам нужно обеспечить поддержку операций жизненного цикла корутины, предоставив реализации особых методов. Стандарт C++20 определяет требования к этим методам, используя концепции, ознакомиться с которыми можно здесь и здесь. Для того чтобы тут мы не отвлекались от темы корутин, мы применим традиционный для C++ поход к описанию методов, наличие которых ожидается системой, в виде частей нашего класса. Для использования корутин в C++20 нам нужно подготовить два взаимосвязанных вспомогательных класса: Класс для сохранения состояния корутины и для сохранения выданных данных. Обычно его называют promise. Класс для управления объектом корутины (promise). Это класс X, который, по традиции, называют future. В объекте типа Promise нужно реализовать несколько методов жизненного цикла корутины. Пока мы сосредоточимся на поддержке выражений, выдающих значения, и не будем обращать внимания на методы, необходимые для управления состоянием корутины. Так как наша корутина использует инструкцию co_yield со значением const char*, нам нужен метод со следующей сигнатурой: Аргумент — это выдаваемый корутиной объект, возвращаемый тип сообщает системе выполнения кода о том, нужно ли сохранять состояние потока, что, в случае с однопоточной корутиной, мы всегда планируем делать, возвращая объект std::suspend_always. В данной ситуации есть возможность возврата объекта std::suspend_never, что допустимо при работе с асинхронными корутинами, но это ведёт к множеству сложностей, связанных с управлением приостановленными потоками и с возобновлением их работы. Мы не собираемся с этим связываться, работая над нашей простой синхронной корутиной. Метод yield_value обязан сохранить свой аргумент, в результате он может быть возвращён вызывающей программе (консьюмеру). Вот как выглядит типичная реализация этого метода: Если вы ещё не сталкивались с современной синтаксической конструкцией C++ return {}, то знайте, что её смысл заключается всего лишь в том, чтобы создать объект возвращаемого типа этого метода, конструируемый по умолчанию. Ещё тут можно было использовать return std::suspend_always{}. Для поддержки инструкции co_return, которая выдаёт значение, но не сохраняет состояние, нам нужен второй метод жизненного цикла корутины: Вызов co_return завершает выполнение корутины, соответствующая функция жизненного цикла корутины имеет возвращаемый тип void, так как состояние корутины будет уничтожено. Если не вдаваться в детали реализации класса X, можно показать, как компилятор может расширить код сущности-консьюмера, превратив его в набор встроенных последовательных операций, а после этого взглянуть на методы жизненного цикла. В следующем примере метод promise даёт доступ к объекту Promise, который сохраняет состояние корутины и выдаваемое ей значение. Метод next может получить сохранённое значение из объекта Promise: Можно видеть, что компилятор преобразовал наш код, превратив два блока кода в один, представленный последовательным набором вызовов чередующихся методов. Перед тем как мы разберём полноценный пример, в котором имеется весь необходимый шаблонный код для классов Promise и Future, нам нужно взглянуть на альтернативный способ написания кода корутин-генераторов: При применении такого подхода мы используем co_yield для всех значений, не выделяя особым образом (с помощью co_return) последнюю операцию выдачи значения; мы просто позволяем блоку кода завершить работу. Компилятор сам добавит в нужное место инструкцию co_return для завершения работы корутины. Для обработки инструкции co_return (без значения) нам нужна реализация особого метода жизненного цикла корутины void return_void: Класс Promise не может предоставить и метод return_value, и метод return_void, которые считаются взаимоисключающими. В этом простейшем примере код консьюмера не меняется, так как он выполняет чтение в точности трёх значений. В более реалистичном примере, где чтение значений из корутин выполняется в цикле, нам нужно каким-то образом отметить конец потока данных. Тут используются указатели, в результате для завершения цикла может быть использован nullptr; в противном случае наиболее общим подходом можно назвать объект std::optional. Наш новый консьюмер с возможностью остановки работы выглядит так: Мы могли бы описать цикл так: Но тут мы решили сохранить явное объявление типа, в результате из этого кода ясно то, как именно используется генератор. Полная версия этого кода находится в файле char_demo.cpp в GitHub-репозитории coroutines-blog. Корутины — это удобный механизм для реализации множества алгоритмов в виде отдельных блоков кода, а не в такой форме, когда соответствующий код собирают в одном месте, где реализации разных алгоритмов оказываются смешанными друг с другом. В качестве примера рассмотрим встроенное устройство, которое наблюдает за некими значениями, например, за показателями температуры, и выводит эти значения на последовательный порт (RS232), снабжая их отметкой времени. Это может быть время с момента загрузки устройства, или точное время, синхронизированное по сети. Отметка времени и значение сохраняются в виде числа с плавающей запятой (каждое занимает 4 байта) и в двоичном виде сохраняются в виде потока байтов. Сделано это ради снижения сложности кода и размера обрабатываемых данных. Поток данных выглядит примерно так, как показано ниже (тут используется обратный (little endian) порядок байтов). В нашем приложении, которое занимается сбором данных, нужно считывать этот поток и помещать данные в структуру, содержащую два значения с плавающей запятой, после чего — выводить эти значения на соответствующем устройстве, вроде некоего дисплея. А если показатель температуры превышает заданное пороговое значение — сообщение нужно снабдить предупреждением. Общий алгоритм работы системы будет выглядеть так: Чтение 4 байтов, необходимых для создания отметки времени. Чтение 4 байтов, необходимых для создания элемента данных. Создание структуры данных, содержащих оба значения, представленных числами с плавающей запятой. Вывод значений из структуры данных. Вывод предупреждения в том случае, если показатель температуры превысит заданное пороговое значение. Теперь подумаем о том, что произойдёт в том случае, если поток данных прервётся в ходе передачи значений, представленных числами с плавающей запятой. В нашем коде имеется средство для обработки ошибки, связанной с окончанием потока, применяемое для каждой отдельной операции чтения данных (речь идёт о восьми самостоятельных операциях по чтению одного байта). Даже при разумном использовании функций этот код будет представлять собой сложный набор условий и инструкций, направленных на реконструкцию данных. Такой код непросто, хотя и интересно, писать и поддерживать. При использовании корутин подобный код можно разбить на два блока: Парсинг данных. Вывод данных и, возможно, предупреждающего сообщения. Мы на практике пойдём ещё дальше и разобьём первый блок на две части: Разбор необработанного потока байтов и преобразование их в числа с плавающей запятой. Сохранение отметки времени и показателя температуры в структуре. Первым шагом нашей работы будет создание шаблона для классов, о которых мы уже говорили, представляющих класс Future корутины и класс Promise для данных. Вот — класс Promise, представляющий собой структуру, вложенную в класс Future: Структура 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 предоставляет нам конструктор/деструктор для управления составным объектом Promise, а так же — механизм для получения значений, выданных корутиной (метод next, о котором мы уже говорили): В стандартной библиотеке имеется поддержка управления объектами Promise через шаблонный класс std::coroutine_handle, передаваемый в виде аргумента конструктору класса Future. Нам нужно сохранить этот объект coroutine_handle и обеспечить вызов его метода destroy при уничтожении объекта Future. Стандартная библиотека предъявляет ещё одно требование для класса Future, в соответствии с которым мы должны определить вложенный тип promise_type, что позволит шаблонам стандартной библиотеки выяснять тип данных, лежащий в основе класса. В нашей реализации метода next необходимо обеспечить проверку того, что объект Promise всё ещё актуален, или вернуть пустой объект std::optional: Вот что мы делаем для возврата значения, выданного корутиной: Мы просто проверяем, существует ли всё ещё корутина (её объект handle не был уничтожен). Мы вызываем метод resume объекта coroutine_handle для выполнения кода до следующей инструкции co_yield. Мы возвращаем значение, сохранённое методом yield_value объекта Promise: благодаря поддержке стандартной библиотеки будут обработаны операции восстановления и сохранения состояния корутины. Если корутин была уничтожена — мы возвращаем пустое значение (std::nullopt). Теперь, когда определён класс Future, мы можем дополнить объект Promise необходимым методом get_return_object: Тут мы используем метод std::from_promise для создания объекта coroutine_handle, который передаётся конструктору Future. Как видите, перед нами — всего лишь стандартный код, заготовка для создания классов Future и Promise. Теперь, когда завершены подготовительные операции, этот шаблон может быть использован в применении к большинству типов данных и классов. Я сказал бы, что он применим ко всем классам, но всегда найдутся особые случаи, в которых воспользоваться им не получится. Теперь можно сосредоточиться на нашей реальной задаче, которая заключается в организации сбора данных. Первый шаг работы заключается в написании корутины, занимающейся чтением данных из объекта istream и выдачей значений, представляющих собой числа с плавающей запятой: Тут мы просто читаем блоки данных размером 4 байта и помещаем их в 32-битные слова, после чего используем приведение типов, интерпретируя соответствующую область памяти в виде числа с плавающей запятой для инструкции co_yield. Если поток данных завершается в ходе чтения 4-байтового слова, мы игнорируем частично прочитанное значение и завершаем работу корутины. Мы можем подтвердить работоспособность этой корутины, просто выводя каждое float-значение, которое прочитано из стандартного потока ввода: А, так как надо сохранить пары значений в структуре данных, можно воспользоваться второй корутиной, которая инкапсулирует соответствующий алгоритм: И, опять же, если входной поток неожиданно завершается при чтении данных, относящихся к отметке времени или к элементу данных — мы отбрасываем неполные данные. Последний шаг этого примера заключается в обработке значений, представляющих отметку времени и данные: Этот код выражает то, как мы решили обрабатывать данные, а все тонкости преобразования байтов в значения с плавающей запятой и их записи в структуру данных, скрыты в соответствующей корутине. Хочется надеяться, что теперь вы способны прочувствовать плюсы использования корутин для разделения различных аспектов реализации сложных алгоритмов на более простые блоки кода. Сейчас, пользуясь C++20, нужно выполнять массу действий, которые кажутся сложными или ненужными для достижения нашей цели и заключаются в создании классов Future и Promise. Но я тем не менее надеюсь, что в C++23 уже будет встроено нечто подобное этому шаблону, что позволит программистам уделять внимание написанию собственного кода, не отвлекаясь на создание вспомогательных механизмов. Протестировать этот код можно, воспользовавшись простым Python-скриптом, например, таким, который показан ниже. Он, в частности, выдаёт четыре жёстко заданных в коде элемента данных: Если скомпилированный исполняемый файл называется datapoint_demo, то мы можем воспользоваться следующим конвейером в командной строке Linux и убедиться в работоспособности корутин: В результате будет выведено следующее: Полный вариант кода этого примера, представленный файлами future.h и datapoint_demo.cpp, можно найти здесь. Для того чтобы скомпилировать эти примеры с использованием GCC (версии 10 или выше), нужно воспользоваться -std=c++20 и -fcoroutines в командной строке g++. В следующем материале я планирую добавить в шаблонный класс Future поддержку итераторов, что позволит использовать корутину в цикле for или в виде входного итератора для неких библиотечных механизмов. Корутины — это мощная техника программирования, позволяющая разделять различные аспекты реализаций сложных алгоритмов, описывая их в виде самостоятельных и достаточно простых блоков кода. C++20, как Python и C#, использует функциональный синтаксис для определения кода корутин. Многие программисты поначалу находят это странным, так как это — всего лишь синтаксическая конструкция для описания инструкций, входящих в состав корутины. Как мы видели, то, что сейчас нет простого стандартного шаблона для создания корутин-генераторов, усложняет жизнь тем, кто только собирается попробовать корутины. Сейчас применение корутин несколько напоминает сборку пазла, картинки-загадки, в условиях, когда тот, кто пазл собирает, не видел картинки, которая у него должна получиться. Поначалу такая задача выглядит пугающе сложной, но решить её вполне реально. Надеюсь, что шаблон Future, который мы тут рассмотрели, это и есть та картинка, глядя на которую вы сможете собирать собственные пазлы. Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки. Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь. Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.Присоединяйтесь к нашей команде.