Обычно в таких статьях делают заголовок вида «аналог 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 new Task(() => 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'е.
Автор: AlexPublic