C++20. Coroutines
В этой статье мы подробно разберем понятие сопрограмм (coroutines), их классификацию, детально рассмотрим реализацию, допущения и компромиссы предлагаемые новым стандартом C++20.
Сопрограммы можно рассматривать как обобщение понятия подпрограмм (routines, функций) в срезе выполняемых над ними операций. Принципиальное различие между сопрограммами и подпрограммами заключается в том, что сопрограмма обеспечивает возможность явно приостанавливать свое выполнение, отдавая контроль другим программным единицам и возобновлять свою работу в той же точке при получение контроля обратно, с помощью дополнительных операций, сохраняя локальные данные (состояние выполнения), между последовательными вызовами, тем самым, обеспечивая более гибкий и расширенный поток управления.
Чтобы внести больше ясности в это определение и дальнейшие рассуждения и ввести вспомогательные понятия и термины, рассмотрим механику обычных функций в C++ и их стековую природу.
Мы будем рассматривать семантику функции в контексте двух операций.
Вызов (call). Передача управления вызываемой процедуре. Выполнение операции можно разделить на несколько этапов:
- Выделить доступную вызываемой процедуре область памяти — кадр (activation record, activation frame), необходимого размера;
- Сохранить значения регистров процессора (локальные данные) для последующего их восстановления, когда управление вернётся из вызываемой процедуры;
- Поместить значения аргументов вызова в доступную для процедуры область памяти. В этой же памяти размещаются локальные переменные;
- Поместить адрес возврата — адрес команды, следующей за командой вызова в доступную для процедуры область памяти.
После чего процессор переходит по адресу первой команды вызываемой процедуры, передавая поток управления.
Возврат из процедуры (return). Передача управления обратно вызывающей стороне. Выполнение этой операции так же состоит из несколько этапов:
- Сохранить (если необходимо) возвращаемое значение в области памяти доступной вызывающей процедуре;
- Удалить локальные переменные, переданные аргументы;
- Восстановить значения регистров.
Дальше процессор переходит по адресу команды из адреса возврата, передавая управление обратно вызывающей стороне. После получения управление, вызывающая сторона освобождает выделенную вызываемой процедуре память (кадр).
Важно заметить следующее:
- Выделяемая, вызываемой процедуре, память имеет строго вложенную структуру и время жизни (strictly nested lifetime) относительно вызывающей стороны. Другими словами в каждый момент времени есть один активный кадр: кадр вызванной процедуры, после возврата управления, активным становится кадр вызывающей стороны.
- Размер кадр известен на стороне вызывающей процедуры.
Эти свойства позволяют использовать структуру, которая называется аппаратным стеком или просто стеком. Аппаратный стек — это непрерывная область памяти аппаратно поддерживаемая центральным процессором и адресуемая специальными регистрами: ss (сегментный регистр стека), bp (регистр указателя базы стекового кадра), sp (регистр указателя стека), последний хранит адрес вершины стека (смешение относительно сегмента стека). Чтобы выделить память на стеке достаточно просто сместить указатель вершины стека (в сторону увеличения) на требуемый размер, чтобы освободить память нужно вернуть указатель в исходное положение.
Выделенная таким образом память называется стековым кадром или стекфреймом. Стекфрейм имеет строгую организацию, которая определяется соглашением о вызове (Calling Conversion). Соглашение зависит от компилятора, от особенностей аппаратной платформы и стандартов языка. Основные отличия касаются особенностей оптимизации (использование регистров) и порядка передачи аргументов. Так же им определяется, например, сторона на которой будут восстанавливаться регистры после вызова.
Рассмотрим простой пример:
void bar(int a, int b)
{}
void foo()
{
int a = 1;
int b = 2;
bar(a, b);
}
int main()
{
foo();
}
Без каких-либо оптимизаций будет сгенерирован следующий код (x86–64 clang 10.0.0 -m32,
код сгенерирован в 32х битном окружение просто чтобы продемонстрировать работу стека, по соглашению о вызовах для 64х битных систем при передачи аргументов в функцию, в таком простом случае, стек участвовать не будет, аргументы будут переданы напрямую через регистры):
bar(int, int):
push ebp
mov ebp, esp
mov eax, dword ptr [ebp + 12]
mov ecx, dword ptr [ebp + 8]
pop ebp
ret
foo():
push ebp
mov ebp, esp
sub esp, 24
mov dword ptr [ebp - 4], 1
mov dword ptr [ebp - 8], 2
mov eax, dword ptr [ebp - 4]
mov ecx, dword ptr [ebp - 8]
mov dword ptr [esp], eax
mov dword ptr [esp + 4], ecx
call bar(int, int)
add esp, 24
pop ebp
ret
main:
push ebp
mov ebp, esp
sub esp, 8
call foo()
xor eax, eax
add esp, 8
pop ebp
ret
Проиллюстрируем работу стека:
Начало работы функции main
, на стеке лежит адрес возврата из функции, пушем на стек значение регистра ebp
(указатель базы стекового кадра) т.к. дальше значение регистра будет меняться на базу текущего стекфрейма, сохраняем в ebp
значение регистра esp
(адрес вершины стека)
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp, esp
+----------------+
Выделяем необходимую память под локальные перемменные и аргументы для вызова функции foo
. Локальных переменных и аргументов у нас нет. Но т.к. стек имеет выравнивание в 16 байт и на стеке уже лежит 8 байт (4 для адреса возврата и 4 для сохраненного ebp
) выделяем дополнительные 8 байт, смещая указатель стека.
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| ... |
| 8 byte padding |
| ... | <-- esp
-----------------+
Вызываем функцию foo. Команда call
сохраняет на стеке адрес возврата и передает управление вызываемой функции. На стороне функции foo пушем на стек значение регистра ebp
(указатель базы стекового кадра) и сохраняем в ebp значение регистра esp
(адрес вершины стека), инициализируем новый стекфрем.
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp, esp
+----------------+
Выделяем необходимую память под локальные переменные и аргументы для вызова функции bar
. У нас две локальные переменные типа int
— это 8 байт, два аргумента для функции bar
типа int
— это 8 байт. И т.к у нас уже есть на стеке 8 байт (адрес возврата и сохраненный ebp
) нужно выделить еще 8 байт чтобы соблюсти требования к выравниванию. Таким образом всего выделяем 8 + 8 + 8 = 24
байта, смещая указатель стека.
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| local a | <-- ebp - 4
+----------------+
| local b | <-- ebp - 8
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| arg a | <-- esp + 4
+----------------+
| arg b | <-- esp
+----------------+
Вызываем функцию bar
. Все работает также как и при вызове функции foo
. Команда call
сохраняет на стеке адрес возврата и передает управление вызываемой функции. На стороне функции bar
пушем на стек значение регистра ebp
и сохраняем в ebp
значение регистра esp
, инициализируем новый стекфрем.
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| local a | <-- ebp - 4
+----------------+
| local b | <-- ebp - 8
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| arg a | <-- ebp + 12
+----------------+
| arg b | <-- ebp + 8
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp, esp
+----------------+
Функция bar
ничего не делает. Восстанавливаем значение указателя базы предыдущего стекового кадра ebp
(вызывающей стороны, функция foo
) и удаляем сохраненное значение со стека, смещая указатель вершины стека на 4 байта вверх. Забираем со стека адрес возврата и удаляем сохраненное значение со стека, таким же смещением указатель вершины стека на 4 байта вверх. Передаем управление обратно функции foo
.
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| local a | <-- ebp - 4
+----------------+
| local b | <-- ebp - 8
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| arg a | <-- esp + 4
+----------------+
| arg b | <-- esp
+----------------+
Функция foo
после вызова bar
завершает свою работу. Удаляем локальные переменные и аргументы предыдущего вызова, смещаем указатель вершины обратно на 24 байта вверх.
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| ... |
| 8 byte padding |
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp, esp
+----------------+
Восстанавливаем значение указателя базы предыдущего стекового кадра ebp
(вызывающей стороны, функция main
) и удаляем сохраненное значение со стека. Забираем со стека адрес возврата и удаляем сохраненное значение со стека. Передаем управление обратно функции main
.
| ... |
+----------------+
| return address |
+----------------+
| saved rbp | <-- ebp
+----------------+
| ... |
| 8 byte padding |
| ... | <-- esp
-----------------+
main
завершает свою работу, выполняя ровно те же действия, что и предыдущие вызовы и мы возвращаемся в исходное состояние.
| ... |
+----------------+
| return address |
+----------------+
Мы видим, что функции между вызовами ни как не сохраняют свое локальное состояние, каждый следующий вызов создаёт новый кадр и удаляет его по завершению работы.
Помимо фундаментального отличия сопрограмм в способности сохранять свое состояние, можно выделить три основные конструктивные особенности.
- Способ передачи управления;
- Способ представления в языке;
- Способ локализации внутреннего состояния (состояния выполнения).
По способу передачи управления сопрограммы можно разделить на симметричные (symmetric) и асимметричные (asymmetric, semi-symmetric).
Симметричные сопрограммы обеспечивают единый механизм передачи управления между друг другом, являются равноправными, работая на одном иерархическом уровне. В этом случае сопрограмма должна явно указывать кому она передаёт управление, другую сопрограмму, приостанавливая свое выполнение и ожидая пока ей вернут контроль таким же образом. По сути, стек и вложенная природа вызовов функций заменяется на множество приостановленных, равноправных сопрограмм и одной активной, которая может передать управление любой другой сопрограмме.
В то время как асимметричные сопрограммы предоставляют две операции для передачи управления: одна для вызова, передача управления вызываемой сопрограмме и одна для приостановление выполнения, последняя возвращает контроль вызывающей стороне.
Стоить заметить что обе концепции обладают одинаковой выразительная силой.
Хотя асимметричные сопрограммы семантически ближе к функциям, в то время как симметричные интуитивно понятнее в контексте кооперативной многозадачности.
По способ представления в языке сопрограммы могут быть представлены как объекты первого класса (first-class object, first-class citizen) или как ограниченные низкоуровневые языковые конструкции (constrained, compiler-internal), скрывающие детали реализации и предоставляющие управляющие описатели (handles), дескрипторы.
Объекты первого класса в контексте языка программирования — это сущности которые могут быть сохранены в переменные, могут передаваться в функции как аргументы или возвращаться в качестве результата, могут быть созданы в рантайме и не зависят от именования (внутренне самоопознаваемы). Например, нельзя создавать функции во время выполнения программы, поэтому функции не являются объектами первого класса. В то же время, существует понятие функционального объекта (function object): пользовательский тип данных, реализующий эквивалентную функциям семантику, который является объектом первого класса.
По способу локализации внутреннего состояния сопрограммы можно разделить на стековые (stackful) и стеконезависимые (stackless). Чтобы понять детальнее, что лежит в основе такого разделения, необходимо дать некоторую классификацию аппаратному стеку (proccesor stack).
Аппаратный стек может быть назначен разным уровням приложения:
- Стек приложения (Application stack). Принадлежит функции
main
. Система управления памятью операционной системы может определять переполнения или недопустимые аллокации такого стека. Стек расположен в адресном пространстве таким образом, что его можно расширять по мере необходимости; - Стек потока выполнения (Thread stack). Стек назначенный явно запущенному потоку. Обычно используются стеки фиксированного размера (до 1–2 мб);
- Стек контекста выполнения (Side stack). Контекст (Exection context) это некоторое окружение, пользовательский поток управления или функция (top level context function, контекстная функция верхнего уровня) со своим назначенным стеком. Стек обычно выделяется в пользовательском режиме (библиотечным кодом), а не операционной системой. Контекст имеет свойство сохранять и восстанавливать свое состояние выполнение: регистры центрального процессора, счётчик команд и указатель стека, что позволяет в пользовательском режиме переключатся между контекстами.
Каждая стековая сопрограмма использует отдельный контекст выполнения и назначенный ему стек. Передача управления между такими сопрограммами — это переключение контекста (переключение пользовательского контекста достаточно быстрая операция, в сравнение с переключением контекстов операционной системы: между потоками) и соответственно активного стека. Важное следствие из стековой архитектуры таких сопрограмм заключается в том что, из-за того что все фремы вложенных вызовов полностью сохраняются на стеке, выполнение сопрограммы может быть приостановлено в любом из этих вызовов.
Приведем пример простой сопрограммы, мы воспользуемся семейством функций для управления контекcтами: getcontext
, makecontext
и swapcontext
(см. Complete Context Control)
#include
#include
static ucontext_t caller_context;
static ucontext_t coroutine_context;
void print_hello_and_suspend()
{
// выводим Hello
std::cout << "Hello";
// точка передачи управления вызывающей стороне,
// переключаемся на контекст caller_context
// в контексте сопрограммы coroutine_context сохраняется текущая точка выполнения,
// после возвращения контроля выполнение продолжится с этой точки.
swapcontext(&coroutine_context, &caller_context);
}
void simple_coroutine()
{
// точка первой передачи управления в coroutine_context
// чтобы продемонстрировать преимущества использование стека
// выполним вложенный вызов функции print_hello_and_suspend.
print_hello_and_suspend();
// функция print_hello_and_suspend приостановила выполнение сопрограмма
// после того как управление вернётся мы выведем Coroutine! и завершим работу,
// управление будет передано контексту,
// указатель на который хранится в coroutine_context.uc_link, т.е. caller_context
std::cout << "Coroutine!" << std::endl;
}
int main()
{
// Стек сопрограммы.
char stack[256];
// Инициализация контекста сопрограммы coroutine_context
// uc_link указывает на caller_context, точку возврата при завершении сопрограммы.
// uc_stack хранит указатель и размер стека
coroutine_context.uc_link = &caller_context;
coroutine_context.uc_stack.ss_sp = stack;
coroutine_context.uc_stack.ss_size = sizeof(stack);
getcontext(&coroutine_context);
// Заполнение coroutine_context
// Контекст настраивается таким образом, что переключаясь на него
// исполнение начинается с точки входа в функцию simple_coroutine
makecontext(&coroutine_context, simple_coroutine, 0);
// передаем управление сопрограмме, переключаемся на контекст coroutine_context
// в контексте caller_context сохраняется текущая точка выполнения,
// после возвращения контроля, выполнение продолжится с этой точки.
swapcontext(&caller_context, &coroutine_context);
// сопрограмма приостановила свое выполнение и вернула управление
// выводим пробел
std::cout << " ";
// передаём управление обратно сопрограмме.
swapcontext(&caller_context, &coroutine_context);
return 0;
}
Отметим, что контексты выполнения лежат в основе реализации стековых сопрограмм библиотеки Boost: Boost.Coroutine, Boost.Coroutine2, только в Boost по умолчанию вместо ucontext_t
используется fcontext_t
— собственная, более производительная реализация (ассемблерная, с ручным сохранением/восстановлением регистров, без системных вызовов) POSIX стандарта.
В случае стеконезависимых сопрограмм контекст выполнения и назначенный ему стек не используются, сопрограмма выполняется на стеке вызывающей стороны, как обычная подпрограмма, до момента передачи управления. В силу того что стековые кадры имеют строго вложенную структуру и время жизни, такие сопрограмма, приостанавливая выполнение, сохраняют свое состояние, динамически размещая его в куче. Возобновление работы приостановленной сопрограммы, не отличается от вызова обычной подпрограммы, с тем исключение, что регистры и переменные восстанавливаются из сохраненного состояния также как и счетчик команд, т.е. управление передается в точку последней остановки. Структурно такие сопрограммы похожи на устройство Даффа и машину состояний.
Можно вывести несколько важных следствия стеконезависимых сопрограмм:
- Передача управления возможна только из самой сопрограммы (top level function), все вложенные вызовы к этому моменту должны быть завершены;
- Передача управления возможна только вызывающей стороне;
- Возобновление работы сопрограммы происходит на стеке вызывающей стороны, он может отличатся от стека первоначального вызова, это может быть даже другой поток;
- Для сохранения состояния (определения набора переменных), восстановления кадра и генерации шаблонного кода для возобновления работы с точки последней остановки, необходима поддержка со стороны стандартов и компиляторов языка.
Мы описали общую теорию и классификацию сопрограмм, рассмотрим техническую спецификацию сопрограмм нового C++20, какое место они занимают в общей теории, их особенности, семантику и синтаксис.
Техническая спецификация сопрограмм в новом C++ носит название Coroutine TS. Coroutine TS предоставляет низкоуровневые средства обеспечивающие характерную возможность передачи управления, описывает обобщенный механизм взаимодействия и настройки сопрограмми и набор вспомогательных высокоуровневых типов стандартной библиотеки, задача которых сделать разработку сопрограмм более доступной и безопасной.
Подход который применяется для реализации обобщенных механизмов уже встречается и используется стандартом. Это range based for, суть его в том что компилятор генерирует код цикла, вызывая определенный набор методов строго описанным способом, в данном случае это методы begin
и end
, тем самым давая возможность программистам настраивать необходимое поведение цикла, определяя эти методы и тип итератора который они возвращают. Точно также компилятор генерирует код сопрограммы, вызывая в строго определенный момент методы определенных пользователем типов, позволяя полностью настраивать и контролировать поведение сопрограммы.
В описанной нами классификации предоставляемые средства подпадают под определение compile-internal сопрограмм.
Далее, чтобы дать возможность компилятору более эффективно работать с памятью, оптимизировать размер и выравнивание сохраняемых состояний используется стеконезависимая модель. Более того, во-первых, выделенная ранее память может переиспользовать, по сути, работая в режиме высокопроизводительного блочного аллокатора, во-вторых, в случаях когда сопрограмма имеет строго вложенное время жизни и размер кадра известен на вызывающей стороне (как в примере с контекстами выполнения), компилятор может избавится от динамического размещения состояния в куче и хранить состояние на стеке вызывающей стороны если это обычная подпрограмма или внутри уже размещенного состояния вызывающей сопрограммы.
И в завершение, чтобы снизить сложность восприятия и сделать более понятным поток управления, сопрограммы семантически приближены к подпрограммам т.е. являются асимметричными.
В итоге, C++20 даёт нам возможность работать с compile-internal asymmetric stackless coroutines.
Мы начнем с того, что сделаем обзор тех выразительных средств, которые предоставляет новый стандарт для описания и работы со сопрограммами и будем постепенно углубляться в детали реализации.
New Keywords
Для оперирования сопрограммами стандарт вводит три ключевых оператора:
- co_await. Унарный оператор, позволяющий, в общем случае, приостановить выполнение сопрограммы и передать управление вызывающей стороне, пока не завершатся вычисления представленные операндом;
- co_yield. Унарный оператор, частный случай оператора co_await, позволяющий приостановить выполнение сопрограммы и передать управление и значение операнда вызывающей стороне;
- co_return. Оператор завершает работу сопрограммы, возвращая значение, после вызова сопрограмма больше не сможет возобновить свое выполнение.
Если в определение функции встречается хотя бы один из этих операторов, то функция считается сопрограммой и обрабатывается соответствующим образом.
Сопрограммой не может быть:
- Функция
main
; - Функция с оператором
return
; - Функция помеченная
constexpr
; - Функция с автоматическим выведение типа возвращаемого значения (auto);
- Функция с переменным числом аргументов (variadic arguments, не путать с variadic templates);
- Конструктор;
- Деструктор.
User types.
Со сопрограммами ассоциировано несколько интерфейсных типов, позволяющих настраивать поведение сопрограммы и контролировать семантику операторов.
Promise.
Объект типа Promise позволяет настраивать поведения сопрограммы как программной единицы. Должен определять:
- Поведение сопрограммы при первом вызове;
- Поведение при выходе из сопрограммы;
- Стратегию обработки исключительных ситуаций;
- Необходимость в дополнительном уточнении типа выражения операторов co_await;
- Передача промежуточных и конечных результатов выполнения вызывающей стороне.
Также тип promise участвует в разрешение перегрузки операторов new и delete, что позволяет настраивать динамическое размещение фрейма сопрограммы.
Объект типа promise создаётся и хранится в рамках фрейма сопрограммы для каждого нового вызова.
Тип Promise определяется компилятором согласно специализации шаблона std::coroutine_traits
по типу сопрограммы, в специализации участвует: тип возвращаемого значения, список типов входных параметров, тип класса, если сопрограмма представлена методом. Шаблон std::coroutine_traits
определен следующем образом:
template >
struct coroutine_traits_base
{};
template
struct coroutine_traits_base>
{
using promise_type = typename Ret::promise_type;
};
template
struct coroutine_traits : coroutine_traits_base
{};
Тип должен иметь строгое имя promise_type. Из определения std::coroutine_traits
, следует что существует как минимум одна специализация, которая ищет определение типа promise_type
в пространстве имен типа возвращаемого результата. promise_type
может быть как именем типа, так и псевдонимом.
Самый простой способ определения типа Promise для сопрограммы.
struct Task
{
struct Promise
{
...
};
using promise_type = Promise;
};
...
Task foo()
{
...
}
Также определение подобного типа Task
полезно в более сложных ситуациях, тип может определять дополнительную семантику внешнего оперирования сопрограммой: передавать и получать данные, передавать поток управления (пробуждать) или уничтожать сопрограмму.
Другой способ определить тип Promise — это явно специализировать шаблон std::coroutine_traits
. Это удобно, например, для сопрограмма представленных методом пользовательского типа
class Coroutine
{
public:
void call(int);
};
namespace std
{
template<>
struct coroutine_traits
{
using promise_type = Coroutine;
};
}
Если тип Promise имеет конструктор соответствующий параметрам сопрограммы, то он будет вызван, иначе будет вызван конструктор по умолчанию. Важно, что все аргументы будут переданы как lvalues, это нужно для того чтобы мы не смогли случайно переместить данные из переданных аргументов в объект Promise т.к. мы ожидаем аргументы в теле сопрограммы. Более подробно создание объекта типа Promise мы рассмотрим ниже.
Прежде чем определить интерфейс типа Promise, необходимо описать второй тип ассоциированный со сопрограммой: Awaitable.
Avaitable.
Объекты типа Avaitable определяют семантику потока управления сопрограммы. Позволяют:
- Определить следует ли приостанавливать выполнение сопрограммы в точке вызова оператора co_await;
- Выполнить некоторую логику после приостановления выполнения сопрограммы для дальнейшего планирования возобновления ее работы (асинхронные операции);
- Получить результат вызова оператора co_await, после возобновления работы.
Объект типа Awaitable определяется в результате разрешения перегрузки (overload resolution) и вызова оператора co_await. Если жизнеспособной перегрузки не было найдено, то результат вычисления самого операнда является объектом типа Awaitable. Далее вызов оператора транслируется в последовательность вызовов методов объекта данного типа.
Например, мы хотим создать механизм отложенного выполнения, который позволит функции уснуть на некоторое время, вернет управление вызывающей стороне, после чего продолжить свое выполнение через заданное время.
Task foo()
{
using namespace std::chrono_literals;
// выполнить некоторый набор операций
// вернуть управление
co_await 10s;
// через 10 секунд выполнить еще один набор операций.
}
В этом примере выражение переданное в качестве операнда имеет тип std::chrono::duration
, чтобы скомпилировать этот код нам нужно определить перегрузку оператора co_await для выражений такого типа.
template
auto operator co_await(std::chrono::duration duration) noexcept
{
struct Awaitable
{
explicit Awaitable(std::chrono::system_clock::duration duration)
: duration_(duration)
{}
...
private:
std::chrono::system_clock::duration duration_;
};
return Awaitable{ duration };
}
Внутри перегрузки мы описываем тип Awaitable, задача которого запланировать и вернуть управление сопрограмме через заданный промежуток времени, и возвращаем объект данного типа как результат.
Нам осталось определить интерфейс типа Awaitable, чтобы это сделать рассмотрим подробнее вызов оператора co_await
и код, который компилятор генерирует в месте вызова.
{
// в начале мы определили тип Promise
using coroutine_traits = std::coroutine_traits;
using promise_type = typename coroutine_traits::promise_type;
...
// вызов co_await в рамках сопрограммы
// 1.
// Cоздаем объект типа Avaitable, находим подходящую перегрузку оператора co_await,
// результат сохраняем во фрейме сопрограммы (как создается фрейм мы рассмотрим
// в рамках описания типа Promise), это необходимо
// т.к. с помощью Awaitable мы вернем результат вычисления, после возобновления работы.
frame->awaitable = create_awaitable();
// 2.
// Вызываем метод await_ready().
// Основная задача метода позволить нам избежать остановки сопрограммы
// в случаях когда операция (вычисления) могут быть завершены синхронно
// или уже завершены, сохранив вычислительные ресурсы.
if (!awaitable.await_ready())
{
// 3.
// Если вызов await_ready() вернул false,
// то сопрограмма приостанавливает свое выполнение,
// сохраняет состояние: состояние локальных переменных, точку остановки
// (это идентификатор состояния, на которое сопрограмма перейдет
// после возобновления своей работы,
// достаточная информация что бы перейти в точку )
// 4.
// Определяем тип coroutine_handle
// corotine_handle - это дескриптор фрейма сопрограммы.
// он обеспечивает низкоуровневую функциональность оперирования сопрограммой:
// передача управления (возобновление выполнения) и удаление.
using handle_type = std::coroutine_handle;
using await_suspend_result_type =
decltype(frame->awaitable.await_suspend(handle_type::from_promise(promise)));
// 5.
// Вызов метода await_suspend(handle),
// задача метода await_suspend выполнить некоторую логику
// на клиентской стороне после приостановления выполнения сопрограммы
// для дальнейшего планирования возобновления ее работы (если необходимо).
// Метод принимает один аргумент - дескриптор сопрограммы.
// Тип возвращаемого результата, определяет семантику передачи управления
if constexpr (std::is_void_v)
{
// Тип возвращаемого результата void,
// то мы безусловно передаем управление вызывающей стороне
// (под вызывающей стороной здесь понимается сторона,
// которая передала управление сопрограмме)
frame->awaitable.await_suspend(handle_type::from_promise(promise));
;
}
else if constexpr (std::is_same_v)
{
// Тип возвращаемого результата bool,
// если метод вернул false, то управление не передается вызывающей стороне
// и сопрограмма возобновляет свое выполнение
// Это полезно, например, когда асинхронная операция
// инициированная объектом Awaitable завершилась синхронно
if (frame->awaitable.await_suspend(handle_type::from_promise(promise))
;
}
else if constexrp (is_coroutine_handle_v)
{
// Тип возвращаемого результат std::coroutine_handle,
// т.е. вызов возвращает дескриптор другой сопрограммы,
// то мы передаем управление этой сопрограмме, это семантика позволяет
// эффективно реализовывать симметричный механизм передачи потока
// управления между сопрограммами
auto&& other_handle = frame->awaitable.await_suspend(
handle_type::from_promise(promise));
other_handle.resume();
}
else
{
static_assert(false);
}
}
// 6.
// Точка возобновления выполнения (пробуждения)
// Вызов метода await_resume(). Задача метода получить результат вычисления.
// Возвращаемое значение рассматривается как результат вызова оператора co_await.
resume_point:
return frame->awaitable.await_resume();
}
Здесь есть несколько важных замечаний:
- Если в процессе обработки возбуждается исключение, то исключение пробрасывается дальше, наружу оператора co_await. Если во время исключения выполнение сопрограммы было приостановлено, то исключение перехватывается, сопрограмма автоматически возобновляет свое выполнение и только после этого пробрасывается дальше;
- Крайне важно, что сопрограмма полностью останавливает свое выполнение до вызова метода
await_suspend
и передачи дескриптора сопрограммы пользовательскому коду. В этом случае дескриптор сопрограммы может быть свободно передаваться между потоками выполнения без дополнительной синхронизации. Например, дескриптор может быть передан в запланированную в пуле-потоков асинхронную операцию. Конечно здесь следует очень внимательно следить за тем, в какой момент методаawait_suspend
мы передаем дескриптор другому потоку и как другой поток оперирует этим дескриптором. Поток получившей дескриптор может возобновить выполнение сопрограммы до того как мы вышли изawait_suspend
. После возобновление работы и вызова методаawait_resume
, объект Awaitable может быть удален. Также потенциально фрейм и объект Promise может быть удалены, до того как мы завершим методawait_suspend
. Поэтому основное чего следует избегать, после передачи контроля над сопрограммой другому потоку вawait_suspend
: это не обращаться к полям (this может быть удален) и объекту Promise, они могут быть уже удалены.
Формально концепцию Awaitable можно определить в терминах type-traits примерно так:
// является ли тип std::coroutine_handle
template
struct is_coroutine_handle : std::false_type
{};
template
struct is_coroutine_handle> : std::true_type
{};
// типы возможных возвращаемых значений метода await_suspend
// - void
// - bool
// - std::coroutine_handle
template
struct is_valid_await_suspend_return_type : std::disjunction<
std::is_void,
std::is_same,
is_coroutine_handle>
{};
// метод await_suspent
template
using is_await_suspent_method = is_valid_await_suspend_return_type<
decltype(std::declval().await_suspend(std::declval>()))>;
// метод await_ready
template
using is_await_ready_method = std::is_constructible().await_ready())>;
// интерфейс типа Avaitable
/*
templae
struct Avaitable
{
...
bool await_ready();
void await_suspend(std::coroutine_handle<>);
Type await_resume();
...
}
*/
template>
struct is_awaitable : std::false_type
{};
template
struct is_awaitable().await_ready()),
decltype(std::declval().await_suspend(std::declval>())),
decltype(std::declval().await_resume())>> : std::conjunction<
is_await_ready_method,
is_await_suspent_method>
{};
template
constexpr bool is_awaitable_v = is_awaitable::value;
Дополним предыдущий пример:
template
auto operator co_await(std::chrono::duration duration) noexcept
{
struct Awaitable
{
explicit Awaitable(std::chrono::system_clock::duration duration)
: duration_(duration)
{}
bool await_ready() const noexcept
{
return duration_.count() <= 0;
}
void await_resume() noexcept
{}
void await_suspend(std::coroutine_handle<> h)
{
// Реализация timer::async в данном контексте не очень интересна.
// Важно что это асинхронная операция, которая через заданный
// промежуток времени вызовет переданный callback.
timer::async(duration_, [h]()
{
h.resume();
});
}
private:
std::chrono::system_clock::duration duration_;
};
return Awaitable{ duration };
}
// сопрограмма, которая через каждую секунду будет выводить текст на экран
Task tick()
{
using namespace std::chrono_literals;
co_await 1s;
std::cout << "1..." << std::endl;
co_await 1000ms;
std::cout << "2..." << std::endl;
}
int main()
{
tick();
std::cin.get();
}
- Вызываем функцию
tick
; - Находим нужную перегрузку оператора co_await и создаем объект Awaitable, передаем в конструктор временной интервал в 1 секунду;
- Вызываем метод
await_ready
, проверяем необходимо ли ожидание; - Приостанавливаем работу функции
tick
, сохраняем состояние; - Вызываем метод
await_suspend
и передаем дескриптор сопрограммы; - Метод await_suspend инициирует асинхронную операцию
timer::async
, которая ожидает заданное время и вызывает переданныйcallback
. Предаем вcallback
дескриптор сопрограммы чтобы после ожидания передать ей управление; - Передаем управление вызывающей стороне — функции
main
; - Функция
main
вызывает метод стандартного потока вводаget
, это синхронная операция, ожидающая ввода. Мы висим, чтобы просто дать завершится инициированным асинхронным операциям; - Ждем одну секунду, асинхронная операция вызывает переданный нами
callback
, вызов осуществляется в том же потоке, в котором происходило ожидание; - Вызываем метод
resume
у дескриптора. Метод передает управление сопрограмме: вызывается функцияtick
на стеке потока, восстанавливаем сохраненное во фрейме состояние, управление передается в точку последней остановки; - Вызывается метод
await_resume
у объекта Avaitable, созданного при вызове оператора co_await и сохраненного во фрейме; - Метода
await_resume
ничего не делает и не возвращает результата, оператор co_await завершает свою работу и