В этой статье мы подробно разберем понятие сопрограмм (coroutines), их классификацию, детально рассмотрим реализацию, допущения и компромиссы, предлагаемые новым стандартом C++20.  Сопрограммы можно рассматривать как обобщение понятия подпрограмм (routines, функций) в срезе выполняемых над ними операций. Принципиальное различие между сопрограммами и подпрограммами заключается в том, что сопрограмма обеспечивает возможность явно приостанавливать свое выполнение, отдавая контроль другим программным единицам и возобновлять свою работу в той же точке при получении контроля обратно, с помощью дополнительных операций, сохраняя локальные данные (состояние выполнения), между последовательными вызовами, тем самым обеспечивая более гибкий и расширенный поток управления. Чтобы внести больше ясности в это определение и дальнейшие рассуждения и ввести вспомогательные понятия и термины, рассмотрим механику обычных функций в C++ и их стековую природу. Мы будем рассматривать семантику функции в контексте двух операций. Вызов (call). Передача управления вызываемой процедуре. Выполнение операции можно разделить на несколько этапов: После чего процессор переходит по адресу первой команды вызываемой процедуры, передавая поток управления. Возврат из процедуры (return). Передача управления обратно вызывающей стороне. Выполнение этой операции также состоит из несколько этапов: Дальше процессор переходит по адресу команды из адреса возврата, передавая управление обратно вызывающей стороне. После получения управления вызывающая сторона освобождает выделенную вызываемой процедуре память (кадр). Важно заметить следующее: Эти свойства позволяют использовать структуру, которая называется аппаратным стеком или просто стеком. Аппаратный стек — это непрерывная область памяти, аппаратно поддерживаемая центральным процессором и адресуемая специальными регистрами: ss (сегментный регистр стека), bp (регистр указателя базы стекового кадра), sp (регистр указателя стека), последний хранит адрес вершины стека (смещение относительно сегмента стека). Чтобы выделить память на стеке достаточно просто сместить указатель вершины стека на требуемый размер, чтобы освободить память, нужно вернуть указатель в исходное положение. Выделенная таким образом память называется стековым кадром или стекфреймом. Стекфрейм имеет строгую организацию, которая определяется соглашением о вызове (Calling Convention). Соглашение зависит от компилятора, от особенностей аппаратной платформы и стандартов языка. Основные отличия касаются особенностей оптимизации (использование регистров) и порядка передачи аргументов. Также им определяется, например, сторона, на которой будут восстанавливаться регистры после вызова. Рассмотрим простой пример: Без каких-либо оптимизаций будет сгенерирован следующий код (x86-64 clang 10.0.0 -m32,
код сгенерирован в 32х битном окружении просто чтобы продемонстрировать работу стека. По соглашению о вызовах для 64х битных систем при передачи аргументов в функцию, в таком простом случае, стек участвовать не будет, аргументы будут переданы напрямую через регистры): Проиллюстрируем работу стека: Начало работы функции main, на стеке лежит адрес возврата из функции, пушим на стек значение регистра ebp (указатель базы стекового кадра) т.к. дальше значение регистра будет меняться на базу текущего стекфрейма, сохраняем в ebp значение регистра esp (адрес вершины стека) Выделяем необходимую память под локальные переменные и аргументы для вызова функции foo. Локальных переменных и аргументов у нас нет. Но т.к. стек имеет выравнивание в 16 байт и на стеке уже лежит 8 байт (4 для адреса возврата и 4 для сохраненного ebp) выделяем дополнительные 8 байт, смещая указатель стека. Вызываем функцию foo. Команда call сохраняет на стеке адрес возврата и передает управление вызываемой функции. На стороне функции foo пушим на стек значение регистра ebp (указатель базы стекового кадра) и сохраняем в ebp значение регистра esp (адрес вершины стека), инициализируем новый стекфрйем. Выделяем необходимую память под локальные переменные и аргументы для вызова функции bar. У нас две локальные переменные типа int — это 8 байт, два аргумента для функции bar типа int — это 8 байт. И т.к. у нас уже есть на стеке 8 байт (адрес возврата и сохраненный ebp) нужно выделить еще 8 байт чтобы соблюсти требования к выравниванию. Таким образом всего выделяем 8 + 8 + 8 = 24 байта, смещая указатель стека. Вызываем функцию bar. Все работает так же, как и при вызове функции foo. Команда call сохраняет на стеке адрес возврата и передает управление вызываемой функции. На стороне функции bar пушим на стек значение регистра ebp и сохраняем в ebp значение регистра esp, инициализируем новый стекфрейм. Функция bar ничего не делает. Восстанавливаем значение указателя базы предыдущего стекового кадра ebp (вызывающей стороны, функция foo) и удаляем сохраненное значение со стека, смещая указатель вершины стека на 4 байта вверх. Забираем со стека адрес возврата и удаляем сохраненное значение со стека таким же смещением указателя вершины стека на 4 байта вверх. Передаем управление обратно функции foo. Функция foo после вызова bar завершает свою работу. Удаляем локальные переменные и аргументы предыдущего вызова, смещаем указатель вершины обратно на 24 байта вверх. Восстанавливаем значение указателя базы предыдущего стекового кадра ebp (вызывающей стороны, функция main) и удаляем сохраненное значение со стека. Забираем со стека адрес возврата и удаляем сохраненное значение со стека. Передаем управление обратно функции main. main завершает свою работу, выполняя ровно те же действия, что и предыдущие вызовы и мы возвращаемся в исходное состояние. Мы видим, что функции между вызовами никак не сохраняют свое локальное состояние, каждый следующий вызов создаёт новый кадр и удаляет его по завершению работы. Помимо фундаментального отличия сопрограмм в способности сохранять свое состояние, можно выделить три основные конструктивные особенности. По способу передачи управления сопрограммы можно разделить на симметричные (symmetric) и асимметричные (asymmetric, semi-symmetric). Симметричные сопрограммы обеспечивают единый механизм передачи управления между друг другом, являются равноправными, работая на одном иерархическом уровне. В этом случае сопрограмма должна явно указывать кому она передаёт управление: другую сопрограмму, приостанавливая свое выполнение и ожидая пока ей вернут контроль таким же образом. По сути, стек и вложенная природа вызовов функций заменяется на множество приостановленных, равноправных сопрограмм и одной активной, которая может передать управление любой другой сопрограмме. В то время как асимметричные сопрограммы предоставляют две операции для передачи управления: одна для вызова, передача управления вызываемой сопрограмме и одна для приостановки выполнения, последняя возвращает контроль вызывающей стороне. Стоить заметить что обе концепции обладают одинаковой выразительной силой.
Aсимметричные сопрограммы семантически ближе к функциям, в то время как симметричные интуитивно понятнее в контексте кооперативной многозадачности. По способу представления в языке сопрограммы могут быть представлены как объекты первого класса (first-class object, first-class citizen) или как ограниченные низкоуровневые языковые конструкции (constrained, compiler-internal), скрывающие детали реализации и предоставляющие управляющие описатели (handles), дескрипторы. Объекты первого класса в контексте языка программирования — это сущности, которые могут быть сохранены в переменные, могут передаваться в функции как аргументы или возвращаться в качестве результата, могут быть созданы в рантайме и не зависят от именования (внутренне самоопознаваемы). Например, нельзя создавать функции во время выполнения программы, поэтому функции не являются объектами первого класса. В то же время, существует понятие функционального объекта (function object): пользовательский тип данных, реализующий эквивалентную функциям семантику, который является объектом первого класса. По способу локализации внутреннего состояния сопрограммы можно разделить на стековые (stackful) и стеконезависимые (stackless). Чтобы понять детальнее, что лежит в основе такого разделения, необходимо дать некоторую классификацию аппаратному стеку (proccesor stack). Аппаратный стек может быть назначен разным уровням приложения: Каждая стековая сопрограмма использует отдельный контекст выполнения и назначенный ему стек. Передача управления между такими сопрограммами — это переключение контекста (переключение пользовательского контекста достаточно быстрая операция, в сравнение с переключением контекстов операционной системы: между потоками) и соответственно активного стека. Важное следствие из стековой архитектуры таких сопрограмм заключается в том, что из-за того, что все фреймы вложенных вызовов полностью сохраняются на стеке, выполнение сопрограммы может быть приостановлено в любом из этих вызовов. Приведем пример простой сопрограммы, мы воспользуемся семейством функций для управления контекcтами: getcontext, makecontext и swapcontext (см. Complete Context Control) Отметим, что контексты выполнения лежат в основе реализации стековых сопрограмм библиотеки Boost: Boost.Coroutine, Boost.Coroutine2, только в Boost по умолчанию вместо ucontext_t используется fcontext_t — собственная, более производительная реализация(ассемблерная, с ручным сохранением/восстановлением регистров, без системных вызовов) POSIX стандарта. В случае стеконезависимых сопрограмм контекст выполнения и назначенный ему стек не используются, сопрограмма выполняется на стеке вызывающей стороны, как обычная подпрограмма, до момента передачи управления. В силу того что стековые кадры имеют строго вложенную структуру и время жизни, такие сопрограммы, приостанавливая выполнение, сохраняют свое состояние, динамически размещая его в куче. Возобновление работы приостановленной сопрограммы, не отличается от вызова обычной подпрограммы, с тем исключением, что регистры и переменные восстанавливаются из сохраненного состояния также как и счетчик команд, т.е. управление передается в точку последней остановки. Структурно такие сопрограммы похожи на устройство Даффа и машину состояний. Можно вывести несколько важных следствий стеконезависимых сопрограмм: Мы описали общую теорию и классификацию сопрограмм, рассмотрим техническую спецификацию сопрограмм нового C++20, какое место они занимают в общей теории, их особенности, семантику и синтаксис. Техническая спецификация сопрограмм в новом C++ носит название Coroutine TS. Coroutine TS предоставляет низкоуровневые средства обеспечивающие характерную возможность передачи управления, описывает обобщенный механизм взаимодействия и настройки сопрограмми и набор вспомогательных высокоуровневых типов стандартной библиотеки, задача которых сделать разработку сопрограмм более доступной и безопасной. Подход который применяется для реализации обобщенных механизмов уже встречается и используется стандартом. Это range based for, суть его в том что компилятор генерирует код цикла, вызывая определенный набор методов строго описанным способом, в данном случае это методы begin и end, тем самым давая возможность программистам настраивать необходимое поведение цикла, определяя эти методы и тип итератора, который они возвращают. Точно также компилятор генерирует код сопрограммы, вызывая в строго определенный момент методы определенных пользователем типов, позволяя полностью настраивать и контролировать поведение сопрограммы. В описанной нами классификации предоставляемые средства подпадают под определение compile-internal сопрограмм. Далее, чтобы дать возможность компилятору более эффективно работать с памятью, оптимизировать размер и выравнивание сохраняемых состояний используется стеконезависимая модель. Более того, во-первых, выделенная ранее память может переиспользовать, по сути, работая в режиме высокопроизводительного блочного аллокатора, во-вторых, в случаях когда сопрограмма имеет строго вложенное время жизни и размер кадра известен на вызывающей стороне (как в примере с контекстами выполнения), компилятор может избавится от динамического размещения состояния в куче и хранить состояние на стеке вызывающей стороны если это обычная подпрограмма или внутри уже размещенного состояния вызывающей сопрограммы. И в завершение, чтобы снизить сложность восприятия и сделать более понятным поток управления, сопрограммы семантически приближены к подпрограммам т.е. являются асимметричными. В итоге, C++20 даёт нам возможность работать с compile-internal asymmetric stackless coroutines. Мы начнем с того, что сделаем обзор тех выразительных средств, которые предоставляет новый стандарт для описания и работы с сопрограммами и будем постепенно углубляться в детали реализации. Для оперирования сопрограммами стандарт вводит три ключевых оператора: Если в определении функции встречается хотя бы один из этих операторов, то функция считается сопрограммой и обрабатывается соответствующим образом. Сопрограммой не может быть: С сопрограммами ассоциировано несколько интерфейсных типов, позволяющих настраивать поведение сопрограммы и контролировать семантику операторов. Объект типа Promise позволяет настраивать поведения сопрограммы как программной единицы. Должен определять: Тип Promise определяется компилятором согласно специализации шаблона std::coroutine_traits по типу сопрограммы, в специализации участвует: тип возвращаемого значения, список типов входных параметров, тип класса, если сопрограмма представлена методом. Шаблон std::coroutine_traits определен следующем образом: Тип должен иметь строгое имя promise_type. Из определения std::coroutine_traits, следует что существует как минимум одна специализация, которая ищет определение типа promise_type в пространстве имен типа возвращаемого результата. promise_type может быть как именем типа, так и псевдонимом. Самый простой способ определения типа Promise для сопрограммы. Также определение подобного типа Task полезно в более сложных ситуациях, тип может определять дополнительную семантику внешнего оперирования сопрограммой: передавать и получать данные, передавать поток управления (пробуждать) или уничтожать сопрограмму. Другой способ определить тип Promise — это явно специализировать шаблон std::coroutine_traits. Это удобно, например, для сопрограмм представленных методом пользовательского типа Если тип Promise имеет конструктор соответствующий параметрам сопрограммы, то он будет вызван, иначе будет вызван конструктор по умолчанию. Важно, что все аргументы будут переданы как lvalues, это нужно для того чтобы мы не смогли случайно переместить данные из переданных аргументов в объект Promise т.к. мы ожидаем аргументы в теле сопрограммы. Более подробно создание объекта типа Promise мы рассмотрим ниже. Прежде чем определить интерфейс типа Promise, необходимо описать второй тип ассоциированный со сопрограммой: Awaitable. Объекты типа Awaitable определяют семантику потока управления сопрограммы. Позволяют: Объект типа Awaitable определяется в результате разрешения перегрузки (overload resolution) и вызова оператора co_await. Если жизнеспособной перегрузки не было найдено, то результат вычисления самого операнда является объектом типа Awaitable. Далее вызов оператора транслируется в последовательность вызовов методов объекта данного типа. Например, мы хотим создать механизм отложенного выполнения, который позволит функции уснуть на некоторое время, вернет управление вызывающей стороне, после чего продолжить свое выполнение через заданное время. В этом примере выражение переданное в качестве операнда имеет тип std::chrono::duration<long long>, чтобы скомпилировать этот код нам нужно определить перегрузку оператора co_await для выражений такого типа. Внутри перегрузки мы описываем тип Awaitable, задача которого запланировать и вернуть управление сопрограмме через заданный промежуток времени, и возвращаем объект данного типа как результат. Нам осталось определить интерфейс типа Awaitable, чтобы это сделать рассмотрим подробнее вызов оператора co_await <expr> и код, который компилятор генерирует в месте вызова. Здесь есть несколько важных замечаний: Формально концепцию Awaitable можно определить в терминах type-traits примерно так: Дополним предыдущий пример: После того как мы определили интерфейс и семантику оператора co_await и типа Awaitable, мы можем вернутся к более детальному изучению типа Promise и настройке сопрограммы. Также как и в случае с Awaitable, чтобы понять роль объекта Promise мы начнем с кода, который генерирует компилятор в процессе обработки сопрограммы. Генерируемый код можно разделить на три часть: Мы упоминали выше что тип Promise участвует в разрешение перегрузки операторов new и delete. Например, при таком определение, будут вызваны пользовательские операторы new и delete: Более того у нас есть возможность добавить перегрузку оператора new c дополнительными аргументами, набор параметров должен быть согласован с параметрами функции. Это позволяет использовать, например, стандартные механизмы такие как leading-allocator convention. Независимо от того, перегрузим мы операторы new и delete или нет, компилятор может избавиться от динамического размещения кадра и сохранить состояние на стеке вызывающей стороны. Следующим шагом мы сохраняем аргументы сопрограммы во фрейм. Здесь есть несколько важных замечаний.  Во-первых, нам следует быть крайне осторожными с передачей ссылок. Важно понимать, что сопрограмма никак не меняет время жизни переданных аргументов, сохраняя ссылочную семантику, но при этом время жизни кадра сопрограммы отличается от стекового кадра обычной функции. Следующий, казалось бы простой пример, в случае с сопрограммой, является случаем Undefined Behavior. Второе замечание касается порядка сохранения аргументов и создания объекта типа Promise. Нужно иметь ввиду, что все дальнейшее оперирование с аргументами в теле сопрограммы осуществляется с копиями аргументов, сохраненных в кадре сопрограммы, поэтому мы сначала сохраняем аргументы, а потом создаем объект типа Promise и вызываем (если нашли) соответствующим перегруженный конструктор, в конструктор передаем именно скопированные аргументы, а не изначальные объекты. Это позволяет сохранить естественное восприятие потока управления и избежать неприятных эффектов, если Promise в конструкторе сохранил ссылку на какой-нибудь аргумент, тогда объект, на который мы будем ссылаться из Promise, будет существовать на протяжение всего времени жизни кадра сопрограммы. Далее мы обращаемся к объекту типа Promise и вызываем метод get_return_object. Создаваемый объект не обязательно должен в точности соответствовать типу возвращаемого результата, по необходимости и возможности может быть выполнено неявное преобразование. В стандарте нет требования в какой момент выполнять неявное преобразование: в момент создания объекта и вызова get_return_object или в момент возвращения вызываемой стороне. Это важно, например, если мы используем какие-то операции с побочными эффектами, реализуя императивную последовательность выполнения. Пример эксплуатации такого преобразования Мonadic composition. Мы уже встречали тип std::coroutine_handle — эти дескриптор сопрограммы, обеспечивает низкоуровневую функциональность оперирования сопрограммой: передача управления (возобновление выполнения) и удаление. Статический метод from_promise, позволяет получить дескриптор сопрограммы по объекту Promise. В заключение мы вызываем функцию couroutine_states и передаем управление стейт-машине, после того как функция couroutine_states вернет управление, приостановив или завершив выполнение сопрограммы, мы возвращаем ранее созданный методом get_return_object объект вызывающей стороне. Функция couroutine_states описывает стейт-машину согласно пользовательскому набору вызовов операторов co_await/co_yield/co_return и вызывается автоматически при передаче управления сопрограмме: при первом вызове или при возобновление работы, вызовом метода дескриптора resume. Указатель на функцию сохранен в кадре сопрограммы. Если вернутся к коду, который генерируется вызовом оператора co_await, то можно заметить метку resume_point — точка пробуждения. Для каждого вызова оператора co_await в служебном или пользовательском коде генерируется уникальная метка — порядковый номер оператора co_await, этот номер также идентифицирует текущие состояние сопрограммы и сохраняется в кадре в поле state, перед передачей управления вызывающей стороне. И первое что мы делаем при вызове — это смотрим на текущее состояние, безусловно переходим к соответствующей ветке, возобновляем работу и завершаем вызов оператора co_await в пользовательском коде. При первом вызове сопрограммы состояние не будет задано и мы перейдем к вызову метода initial_suspend с последующей передачей результата оператору co_await. Задача этого вызова определить: следует ли начать выполнение пользовательского кода немедленно или выполнение должно быть отложено. Стандарт предоставляет два тривиальных типа реализующих концепцию Awaitable: std::suspend_never, std::suspend_always, которые упрощают реализацию метода initial_suspend, позволяя реализовать две наиболее распространенных модели поведения. Далее следует выполнение пользовательского кода. Если включена поддержка исключений, то пользовательский код заключается в блок try-catch с вызовом метода unhandled_exception в случае возбуждения исключения. В пользовательском коде, помимо оператора co_await, могут встречаться операторы co_yield и co_return. Эти операторы позволяют передавать вызываемой стороне промежуточный или конечный результат выполнения сопрограммы. Передача результата осуществляется средствами объекта Promise, но с разной семантикой. Оператор co_yield <expr> эквивалентен вызову: Т.е. оператор позволяет сохранить промежуточный результат в объекте Promise и вернуть управление вызывающей стороне для дальнейшей манипуляции полученными данными. Типичная реализация метода yield_value выглядит так: Пользовательский тип Task может реализовывать разные стратегии получения доступа к сохраненным в объекте Promise значениям. Один из наиболее выразительных вариантов использования семантики оператора co_yield — это генераторы. Пример генератора из библиотеки cppcoro В свою очередь оператор co_return в контексте пользовательского кода обладает такой же семантикой как и оператор return. Позволяет принудительно завершить выполнение и вернуть результат вызывающей стороне. Вызов co_rеturn без операндов эквивалентен: Если тип результат вычисления выражения, переданного оператору в качестве аргумента, отличен от void, то вызов co_rеturn эквивалентен следующему коду Если же тип результата вычисления выражения void, то вызов генерирует следующий код Важно, если в пользовательском коде нет операторов co_return, то в конце тела функции генерируется вызов оператора без аргументов co_return;. Т.е. выражение frame->promise.return_void() должно быть валидно. После передачи результата вычислений в Promise через вызов методов return_value или return_void, мы завершаем выполнение пользовательского кода и переходим к служебному вызову final_suspend. В противовес методу initial_suspend, вызов метода final_suspend позволяет настроить поведение сопрограммы в момент завершения своего выполнения. Задача вызова дать возможность пользователю обработать и опубликовать результат выполнения, сигнализировать о завершение вычислений или передать выполнение другой сопрограмме. Мы рассмотрели все случаи использования оператора co_await как в пользовательском так и в служебном коде. Он вызывается с результатами вызова init_suspend и final_suspend, приостанавливает работу сопрограммы в случае вызова оператора co_yield, может быть вызван в пользовательском коде с произвольным выражением. У последнего использования есть одна особенность. Если тип Promise определяет метод await_transform, то любой вызов оператора co_await в пользовательском коде транслируется в вызов Это достаточно мощный инструмент, например, помимо вызова оператора co_await с типами, которые не поддерживают концепцию Awaitable, мы можем запрещать использование или менять поведение уже определенных сущностей. Например можно запретить прямые вызовы оператора co_await в генераторе. Примеры кода: Возможное дальнейшее развитие: Ссылки: Буду рад комментариям и предложениям (можно по почте yegorov.alex@gmail.com)
Спасибо!