Pull to refresh

Coroutines everywhere

Reading time4 min
Views5.5K
В своем докладе на C++ Russia 2016 Гор Нишанов упомянул, что корутины могут работать в любых окружениях, даже там, где нет C++ рантайма. Мне захотелось попробовать корутины в таких средах. Посмотреть, как самому «с нуля» реализовать поддержку корутин в стандартной библиотеке. Проверить, как корутины живут без исключений, и работают ли они вне операционной системы (на голом железе).


Поддержка со стороны компилятора


Я использовал компилятор от Microsoft — cl.exe. Начиная с Visual Studio 2015, компилятор имеет экспериментальную поддержку сопрограмм. Для того, чтобы начать использовать сопрограммы (coroutines, корутины) необходимо передать компилятору ключик /await.

Как реализованы корутины в cl.exe


Корутины поддержаны на уровне компилятора, при этом компилятор предоставляет набор intrinsic функций, которые позволяют управлять процессами создания, приостановки и возобновления корутин:
extern "C" size_t _coro_resume(void *);
extern "C" void _coro_destroy(void *);
extern "C" size_t _coro_done(void *);
#pragma intrinsic(_coro_resume)
#pragma intrinsic(_coro_destroy)
#pragma intrinsic(_coro_done)


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

Требования к реализации стандартной библиотеке


Когда компилятор встречает конструкцию вида co_await EXPR, он требует, чтобы тип выражения EXPR реализовывал следующие методы:

bool await_ready();

void await_suspend(std::experimental::coroutine_handle<promise_type> coroutineHandle);

value_type await_resume();


Иначе говоря, чтобы тип удовлетворял концепту Awaitable (более подробно, см здесь)

Требования к C++ рантайму


Вот тут кроется самое интересное, компилятор требует наличия следующих операторов:
void operator delete(void* location, size_t);
void * operator new(size_t size);


Или же наличия операторов
void operator delete(void* location, size_t);
void * operator new(size_t size, std::nothrow_t const &);


Больше компилятору не нужно ничего для полноценной поддержки корутин!

Из приведенных сигнатур операторов new и delete видно, что корутины могут работать как с throwing вариантом new, так и nothrow вариантом new, что как раз подходит для различных сред, в которых исключения отсутствуют!

Что такое coroutine_handle


Мы видели, что один из методов (await_suspend) требует тип coroutine_handle, что это за тип?
По сути, coroutine_handle — это C++ обертка над void*, более того, этот тип имеет семантику близкую к семантике указателя, его можно присваивать, копировать, и что самое важное, ответственность за разрушение этого объекта лежит на программисте (есть некая аналогия с удалением памяти, на которую указывает указатель).
Объект этого типа ответственен за исполнение и завершение корутины.
От coroutine_handle требуется совсем немного методов:

static coroutine_handle from_address(void *_Addr) noexcept;

void *address() const noexcept;

void operator()() const noexcept {
	resume();
}

void resume() const;

void destroy();

bool done() const;


Отдельно стоит упомянуть метод from_address — этот метод предназначен для получения объекта из void*, зачем это нужно? Обычно асинхронные операции позволяют задать контекст при выполнении callback, так вот, в качестве этого самого контекста и передается объект coroutine_handle (точнее, адрес, возвращаемый coroutine_handle::address).

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

Контекст выполнения


Итак, coroutine_handle предоставляет нам возможность приостановить (заморозить) выполнение текущей функции в одном контексте, и продолжить выполнение в другом контексте. Продолжиться выполнение может где угодно: это может быть другой поток, асинхронная обработка событий в какой-нибудь Wait* функции. Чисто теоретически это может быть даже другой процесс или прерывание. При этом, все локальные переменные и параметры нашей функции в общем случае находятся НЕ на стеке, а в куче (вспоминаем операторы new и delete), хотя текущий proposal позволяет компилятору проводить оптимизацию, чтобы избежать выделения памяти из кучи.

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

Proof of concept


У меня есть Proof-of-concept работающих корутин в следующих окружениях:
  • Корутины в user-mode с поддержкой исключений
  • Корутины в user-mode без поддержки исключений
  • Корутины в kernel-mode без поддержки исключений и без C++ рантайма
  • Корутины на голом железе (EFI приложение) без поддержки исключений, без C++ рантайма и без операционной системы.


Этот пост можно считать затравкой, так как я буду выступать на C++ Siberia 2016, и там более подробно разберу все нюансы использования корутин.

Пожелания, замечания, исправления всячески приветствуются, до встречи на конференции!

p.s. Я рекомендую посмотреть вышеупомянутый доклад Гора, доклад действительно классный!
Tags:
Hubs:
+2
Comments6

Articles