Pull to refresh

Техника написания аналога await/async из C# для C++

Reading time 8 min
Views 47K
Обычно в таких статьях делают заголовок вида «аналог await/async для C++», а их содержимое сводится к описанию ещё одной библиотеки, выложенной где-то в интернете. Но в данном случае нам не требуется ничего подобного и заголовок точно отражает суть статьи. Почему так смотрите ниже.

Предыстория

Все примеры кода из этой статьи были придуманы мною для аргументации в одном из «классических» споров вида «C# vs. C++» на одном форуме. Спор закончился, а код остался, и я подумал почему бы не оформить это в виде нормальной статьи, которая послужила бы входной на Хабре. Вследствие таких исторических причин, в статье будет много сравнений C# и C++ подходов.

Постановка задачи — асинхронное программирование


Весьма часто в работе встаёт задача произвести какие-то действия в отдельном потоке и потом обработать результат в изначальном (обычно UI) потоке. Это одна из разновидностей так называемого асинхронного программирования. Это задача хорошо известная и имеет множество различных решений в большинстве языков программирования. Например в C++ это может выглядеть так:
auto r=async(launch::async, [&]{return CalcSomething(params);});
DoAnother();
ProcessResult(r.get());//get - блокирующая

для схемы с блокировкой вызывающего потока. Или так:
auto r=async(launch::async, [&]{return CalcSomething(params);});
while(r.wait_for(chrono::seconds(0))!=future_status::ready) DoAnother();
ProcessResult(r.get());

с опрашивающей схемой. Ну а для UI потоков вообще проще всего воспользоваться уже работающим циклом и сделать уведомляющую схему:
thread([=]{PostMessage(CalcSomething(params));}).detach();
...
OnDataMessage(Data d){ProcessResult(d.get<type>());}

Как видно ничего особо сложного тут нет. Это код на C++, а скажем на C# всё запишется буквально так же, только вместо thread и future будет Thread и Task. Но у последнего варианта есть один небольшой минус: код вычисления и код обработки находятся в разных контекстах (и могут находиться даже в разных файлах исходников). Иногда это даже полезно для более строгой архитектуры, но ведь всегда хочется поменьше писанины… В последних версиях C# появилось любопытное решение.

C# реализация


В последних версиях C# мы можем написать просто:
private async void Handler(Params prms)
{
    var r = await Task.Run(() => CalcSomething(prms));
    ProcessResult(r);
}

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

Прямо волшебство какое-то не так ли? На самом деле там конечно есть пара минусов (которые мы кстати устраним в своей реализации), но в целом это выглядит как именно то, что на нам надо для удобства написания асинхронного кода. Как же работает это волшебство? На самом деле очень просто — здесь используются так называемые сопроцедуры (coroutine).

Сопроцедуры


Сопроцедура по простому — это блок кода с множественными точками входа. Применяются они чаще всего для случаев очень большего числа параллельных задач (например в реализации сервера), где наличие подобного числа потоков уже совершенно неэффективно. В таком случае они позволяют создать видимость потоков (кооперативная многозадачность) и этим сильно упрощают код. Так же с помощью сопроцедур можно реализовывать так называемые генераторы. Реализация сопроцедур бывает как встроенная в язык, так и в виде библиотеки и даже предоставляемая ОС (в Windows сопроцедуры называются Fiber).

В C# же сопроцедуры применили не для таких классических целей, а для реализации любопытного синтаксического сахара. Реализация у нас тут встроенная в язык, но при этом далеко на самая лучшая. Это так называемя stackless реализация, которая по сути представляет собой конечный автомат хранящий в себе нужные локальные переменные и точки входа. Именно из этого следует большая часть недостатков C# реализации. И необходимость расставлять «async» по всему стеку вызова и лишние накладные расходы автомата. Кстати, await — это не первое появление сопроцедур в C#. yield — это тоже самое, только ещё более ограниченное.

А что у нас в C++? В самом языке нет никаких сопроцедур, но существует множество различных реализаций в виде библиотек. Есть она и в Boost'e, причём там реализован как раз самый эффективный вариант — stackfull. Он работает через сохранение/восстановление всех регистров процессора и стека соответственно — по сути как у настоящих потоков, только это всё без обращения к ОС, так что практически мгновенно. И как всё в Boost'e, оно отлично работает на разных ОС, компиляторах, процессорах.

Ну что же, раз в C++ у нас имеется даже более мощная реализация сопроцедур чем в C#, то просто грех не написать свой вариант await/async синтаксического сахара.

C++ реализация


Посмотрим что нам даёт библиотека Boost.Coroutine. Первым делом нам надо создать экземпляр класса coroutine, передав ему в конструкторе нашу функцию (функтор, лямбда-функцию), причём у этой функции должен быть один (может быть и больше, уже для наших целей) параметр, в который будет передан специальный функтор.
using Coro=boost::coroutines::coroutine<void()>;
Coro c([](Coro::caller_type& yield){
    ...
    yield();//прерывает выполнение
    ...
    yield();//прерывает выполнение
    ...
});
...
c();//исполнение нашей функции с точки последнего прерывания

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

Теперь у нас есть всё что нужно. Осталось применить немного магии шаблонов и макросов (это только чтобы было внешне совсем похоже на C# вариант) и получаем:
using __Coro=boost::coroutines::coroutine<void()>;
void Post2UI(const void* coro);
template<typename L> auto __await_async(const __Coro* coro, __Coro::caller_type& yield, L lambda)->decltype(lambda())
{
	auto f=async(launch::async, [=](){
		auto r=lambda();
		Post2UI(coro);
		return r;
	});
	yield();
	return f.get();
}
void CallFromUI(void* c)
{
	__Coro* coro=static_cast<__Coro*>(c);
	(*coro)();
	if(!*coro) delete coro;
}
#define async_code(block) { __Coro* __coro=new __Coro; *__coro=__Coro([=](__Coro::caller_type& __yield){block});}
#define await_async(l) __await_async(__coro, __yield, l)

Вся реализация занимает какие-то жалкие 20 строчек простейшего кода! Их конечно можно засунуть в отдельный hpp файл и обозвать чем-то типа библиотеки, но это будет просто смешно. Правда нам требуется определить ещё пару строк, уже зависящих от выбора нашего GUI-фреймворка (или вообще нативного api). Что-то типа:
void Post2UI(const void* coro) {PostMessage(coro);}
void OnAsync(Event& event) {CallFromUI(event.Get<void*>());}

Но это всего пара строк, одна на всё приложение и одинаковая для всех приложений на одном фреймворке. После этого мы сможем легко писать такой код:
void Handler(Params params) async_code
(
    auto r = await_async([&]{return CalcSomething(params);});
    ProcessResult(r);
)

И последовательность вычислений будет в точности как в C# варианте. Причём нам не пришлось менять сигнатуру функции (добавлять async по всему стеку вызова) как в C#. Более того, здесь мы не ограничены запуском одной асинхронной задачки на функций. Мы можем запустить на параллельное исполнение сразу несколько асинхронных блоков или вообще пройтись в цикле. Например такой код:
void Handler(const list<string>& urls)
{
    for(auto url: urls)  async_code
    (
        result+=await_async([&]{return CheckServer(url);});
    )
}

запустит параллельное выполнение CheckServer для каждого элемента в списке и соберёт все результаты в переменной result. Причём очевидно что никакой синхронизации, блокировок и прочего не требуется, т.к. код result+=… будет исполняться только в UI потоке. В C# такое естественно тоже без проблем записывается, но надо делать ещё отдельную функцию, которую и вызывать в цикле.

Тестирование


Несмотря на размер и простоту нашей реализации, всё же протестируем её, чтобы убедиться точно в корректности работы. Для этого лучше всего написать на вашем любимом GUI-фреймворке простейшее тестовое приложение из одного поля ввода (многострочного) и одной кнопки. Тогда наш тест будет обобщённо (убрал лишние подробности) выглядеть так:
class MyWindow: public Window
{
	void TestAsync(int n) async_code
	(
		output<<L"Запускаем асинхронное из потока "<<this_thread::get_id()<<'\n';
		auto r=await_async([&]{
			this_thread::sleep_for(chrono::seconds(1));
			wostringstream res;
			res<<L"Завершена работа в потоке "<<this_thread::get_id()<<L" над данными "<<n;
			return res.str();
		});
		output<<L"Показываем результат в потоке "<<this_thread::get_id()<<L": "<<r<<'\n';
	)	
	void OnButtonClick(Event&)
	{
		TestAsync(12345);
		TestAsync(67890);
		output<<L"Показываем MessageBox из потока "<<this_thread::get_id()<<'\n';
		MessageBox(L"Тест!");
		output<<L"MessageBox закрыт в потоке "<<this_thread::get_id()<<'\n';
	}
	Editbox output;
};

class MyApp : public App
{
	virtual bool OnInit()
	{
		SetTopWindow(new MyWindow);
		return true;
	}
	void OnAsync(Event& event) 
	{
		CallFromUI(event.Get<void*>());
	}
};
void Post2UI(const void* coro)
{
	GetApp().PostMessage(ID_ASYNC, coro);
}

MessageBox стоит для проверки работы с модальными окнами. Полученный результат:
Запускаем асинхронное из потока 1
Запускаем асинхронное из потока 1
Показываем MessageBox из потока 1
Показываем результат в потоке 1: Завершена работа в потоке 2 над данными 12345
Показываем результат в потоке 1: Завершена работа в потоке 3 над данными 67890
MessageBox закрыт в потоке 1


Итоги


Думаю что теперь уже не надо объяснять замечание в начале статьи насчёт библиотек. Обладая современным инструментарием (C++11, Boost) любой C++ программист способен за несколько минут и десяток строчек кода написать себе полноценную реализацию await/async из C#. Причём эта реализация будет ещё и гибче (по несколько async блоков на функцию), удобнее (не надо размножать async по стеку вызова) и намного эффективнее (в смысле накладных расходов).

Литература


1. en.cppreference.com/w/cpp/thread — поддержка многопоточности в стандартной библиотеке.
2. www.boost.org/doc/libs/1_54_0/libs/coroutine/doc/html/index.html — реализация сопроцедур в Boost'е.

Дополнение 1


В комментариях справедливо заметили, что await/async из C# может работать не только с разными потоками, но и в рамках одного. Ну собственно это очевидно так и должно быть, т.к. это всего лишь не самая лучшая реализация сопроцедур, а они изначально создавались как раз для такой работы. И естественно что с помощью сопроцедур из Boost'a это реализуется совсем тривиально. Я же не показал подобный код лишь потому, что он не входил в мою изначальную постановку задачи (см. начало статьи), да и вообще не вижу особого смысла в таком коде. Но раз в комментариях заинтересовались и для общности (что бы показать что подобное решение полностью заменяет await/async), то всё же покажу здесь.

И так, добавим к нашей реализации (которая из 20 строчек) ещё несколько:
template<typename L> auto __await(const __Coro* coro, __Coro::caller_type& yield, L lambda)->decltype(lambda())
{
	Post2UI(coro);
	yield();
	return lambda();
}
#define await(l) __await_async(__coro, __yield, l)

И после этого мы сможем писать так (код в точности как и раньше, только заменили await_async на await):
void Handler(Params params) async_code
(
    auto r = await([&]{return CalcSomething(params);});
    ProcessResult(r);
)

Здесь опять же будет возврат управления из Handler'a сразу же после вызова await, но при этом оставшийся код выполнится в том же UI потоке что и выполнялся Handler, только когда-нибудь потом, когда освободится. Никаких дополнительных потоков тут естественно не создаётся.
Tags:
Hubs:
+53
Comments 26
Comments Comments 26

Articles