Пользователь
0,0
рейтинг
5 июля 2013 в 17:23

Разработка → Техника написания аналога await/async из C# для C++ из песочницы

Обычно в таких статьях делают заголовок вида «аналог 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, только когда-нибудь потом, когда освободится. Никаких дополнительных потоков тут естественно не создаётся.
Алексей @AlexPublic
карма
18,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (26)

  • +5
    Годный входной пост. :) Библиотечные штуки весьма полезно писать самому, просто чтобы понять получше как оно работает.
  • +3
    Получилось неплохо, но вы немного путаете. У вас получился не async/await, те немного для другого. Вы правильно пишете, что
    часто в работе встаёт задача произвести какие-то действия в отдельном потоке и потом обработать результат в изначальном (обычно UI) потоке
    В .NET для этого существовало средство и до async/await, это ThreadPool + SyncronizationContext. Именно последний отвечает за то, чтобы (опять же, цитата)
    и когда UI поток освободится от своих текущих задач, он выполнит ProcessResult с данных полученными из второго потока
    . Просто применение async/await никакого переключения в исходный поток не делает
    • 0
      В .NET для этого существовало средство и до async/await, это ThreadPool + SyncronizationContext. Именно последний отвечает за то, чтобы (опять же, цитата)

      Конечно) И даже не только это средство. Я и написал об этом в начале, что полный набор инструментов для асинхронного программирования есть во многих языках и давным давно. Но это всё без вскусного синтаксического сахара… )))
      Просто применение async/await никакого переключения в исходный поток не делает

      Тогда в каком потоке по вашему будет исполняться ProcessResult из этого
      private async void Handler(Params prms)
      {
          var r = await new Task(() => CalcSomething(prms););
          ProcessResult(r);
      }
      

      примера? Считаем что Handler вызвали из UI потока.
      • +4
        Ну, в том виде, в котором вы привели код, оно вовсе не будет компилировать.
        Однако, предположим, что мы устраним синтаксические ошибки, а так же добавим тип возвращаемого значения из CalcSomething. Получим

        private async void Handler(Params prms)
        {
            var r1 = await new Task<object>(() => CalsSomething())
            ProcessResult(r1);
        }
        

        В таком виде ProcessResult никогда не будет вызван, потому что задачу, отвечающая за CalcSomething, никто никогда не стартует.
        Ок, перепишем, как надо

        private async void Handler(Params prms)
        {
            var r2 = await Task.Run(() => CalsSomething());
            ProcessResult(r2);
        }
        

        Ое, задача стартовала, ProcessResult вызван. Теперь самое интересное.

        Если у входного потока был установлен SyncronizationContext, или в параметрах Task.Run был передан вот такой аргумент TaskScheduler.FromCurrentSynchronizationContext(), то по окончании исполнения CalсSomething исполнение неявного коллбека с ProcessResult будет добавлено в очередь к этому SyncronizationContext-у, и исполнено на входном потоке.
        Если нет — исполнение ProcessResult будет выполнено на первом попавшемся треде, не обязательно UI.
        Так вот, у WinForms у входного потока SyncronizationContext есть, поэтому ProcessResult отсылается исполняться на UI.

        Если интересно почитать, то вот есть удовлетворительная тематическая ссылка programmers.stackexchange.com/questions/114605/how-will-c-5-async-support-help-ui-thread-synchronization-issues
        • 0
          Промахнулся с ответом — он ниже ушёл. )
      • 0
        Прикол в том, что Async сам по себе вообще не создает отдельных тредов.

        The async and await keywords don't cause additional threads to be created. Async methods don't require multithreading because an async method doesn't run on its own thread. The method runs on the current synchronization context and uses time on the thread only when the method is active. You can use Task.Run to move CPU-bound work to a background thread, but a background thread doesn't help with a process that's just waiting for results to become available.

        msdn.microsoft.com/en-us/library/vstudio/hh191443.aspx
        • 0
          Ну так всё правильно. Await/async — это по сути реализация сопроцедур и здесь мы видим описание кооперативной многозадачности. Теоретически через них можно было бы даже попробовать порешать классические задачи сопроцедур… Но с учётом их не особо эффективной реализации в C# наверное всё же нет смысла.
        • 0
          Сделал небольшое дополнение к статье. )))
  • 0
    Ну, в том виде, в котором вы привели код, оно вовсе не будет компилировать.

    Ай, не то скопировал… Да и в статье тоже самое… Ужас какой. )))

    Если у входного потока был установлен SyncronizationContext, или в параметрах Task.Run был передан вот такой аргумент TaskScheduler.FromCurrentSynchronizationContext(), то по окончании исполнения CalсSomething исполнение неявного коллбека с ProcessResult будет добавлено в очередь к этому SyncronizationContext-у, и исполнено на входном потоке.
    Если нет — исполнение ProcessResult будет выполнено на первом попавшемся треде, не обязательно UI.
    Так вот, у WinForms у входного потока SyncronizationContext есть, поэтому ProcessResult отсылается исполняться на UI.

    Я в курсе) И я как раз поэтому везде и указывал что Handler вызываем из UI потока — тогда он точно туда и вернётся (если не укажем иного, а оно нам и не надо). При вызове же из не UI потока ситуация уже естественно совсем другая, но в таком случае на мой взгляд уже и вообще вся эта схема не требуется, т.к. в потоках без цикла сообщений мы свободны использовать более просты и удобные методы (см. самое начало статье).
  • –2
    Чем людям std::future и std::async не хватает?
    • 0
      Там then у future еще нет во первых, хотя в бусте последнем уже реализовали. Во вторых даже если then будет await все равно намного удобней писать чем .then(лямбда), особенно если код внутри циклов и условий.
      • 0
        ок, then — аргумент. Даже удивляюсь почему его в стандарте нету. Но в бусте есть, и в PPL есть. А чем await удобнее?
        • 0
          then же работает только как однократное поствыполнение. А с await у нас произвольная сложность, т.е. это просто обобщение then до полного подобия линейного кода. Вот смотрите пример:
          void Handler(const URL url) async_code
          (
              log_window+="Downloading "+url;
              auto xml=ParseXML(await_async([&]{return Download(url);}));
              log_window+="Downloading "+xml.GetValue("/update/text/url ");
              result_window+=await_async([&]{return Download(xml.GetValue("/update/text/url "));});
          )
          

          В нём такая последовательность исполнения:
          1. «log_window+= в UI потоке и потом возврат из функции Handler
          2. Download(url) в отдельном потоке
          3. ParseXML и log_window+= снова в UI потоке
          4. Download(xml.GetValue(»/update/text/url ")) в отдельном потоке
          5. result_window+= снова в UI потоке.

          Теперь представьте себе как некрасиво выглядел бы подобный код на вложенных then'ах.
          • 0
            Ну, они ведь не вложенные на самом деле:

            task<int> t([]()
            {
                return 1;
            }).then([](int n)
            {
                return n+1;
            }).then([](int n)
            {
                return n+1;
            }).then([](int n)
            {
                return n+1;
            })
            

            • 0
              А где там запуск новых потоков? ) Вложенность же от них идёт…

              Ну и в любом случае, а если нам if или вообще for понадобится? )

              Да и вообще, я же ещё в самом самом начале статьи написал что в принципе у нас давно есть полный набор инструментов для написания асинхронного кода. Т.е. формально мы можем записать что угодно (и даже без then ещё) давным давно. Так что вопрос остаётся только в простоте и удобстве кода — по сути синтаксическом сахаре.
        • 0
          >ок, then — аргумент. Даже удивляюсь почему его в стандарте нету.

          Он есть в С++14. Как только его предложили в стандарт буст его и реализовали.

          > А чем await удобнее?

          Герб Саттер объясняет в этом видео channel9.msdn.com/Events/Build/2013/2-306 на 00:51:08
          • 0
            Вот лучше бы они добавили в C++14 полноценные сопроцедуры! Они мощнее — позволяют реализовать и любой await и ещё много всего другого интересного.
  • 0
    Stackful-реализация сопроцедур может привести к ряду проблем, если в цепочке вызовов есть какой-то код, который об этом не подозревает, при этом используя вещи наподобие обработки исключений. Или я ошибаюсь?
    • 0
      Boost реализация сопроцедур корректно перекидывает исключение в родительский стек: www.boost.org/doc/libs/1_54_0/libs/coroutine/doc/html/coroutine/coroutine.html#coroutine.coroutine.exceptions_in__emphasis_coroutine_function__emphasis_
      • +2
        Кстати, вы в сторону Mono.Tasklets не смотрели? Насколько я понимаю, реализован тот же функционал с сопрограммами, по крайней мере код, похожий на async/await (т. е. без лямбд) на них можно было делать задолго до C# 5. Единственное, требуется поддержка со стороны рантайма, предоставляемая только Mono, т. е. решение непереносимое.
        • 0
          Первый раз вижу эту ссылку, но судя по описанию в ней, это как раз самые классические сопроцедуры, причём не такие ограниченные как await/async, а больше похоже на Boost'ие. Это в смысле сценария использования. А в смысле внутренней реализации надо смотреть уже более подробное описание/исходники.
  • 0
    Я не c++ разработчик, и сильно спотыкаюсь при чтении кода в статье, поэтому лучше спрошу, чем буду пытаться самостоятельно.

    У вас в примере все async_code функции ничего не возвращают, это случайность или ограничение данного подхода?
    • +1
      Ээээ, async_code — это не функции, а блоки асинхронного кода. Аналогом этого является всё тело async функции (причём самой первой по стеку вызова) в C# варианте. Возвращать из них какое-то значение (сразу) не имеет никакого смысла, т.к. ещё ничего не вычислено. Поэтому в моей реализации никаких возвратов нет, хотя реализация сопроцедур в Boost и поддерживает такое. Если же речь идёт о том, что бы возвращать значение после отработки всего асинхронного кода, то это просто означает что и предыдущая по стеку вызовов функция должна быть асинхронной и соответственно надо просто переставить asyn_code в неё.
  • +1
    Я пишу на с++ уже более 5 лет, несколько успешных проектов закончены, но когда я читаю фразу:
    Вся реализация занимает какие-то жалкие 20 строчек простейшего кода!

    мне становиться страшно и не зря… Где вы там увидели простой код, да там через строчку меня повергает в размышления… И когда вижу преобразования в коде, где из void* static_cast-ом получают то, что хотят, может я и не прав и так делать можно, но я бы хотел, чтобы на такие преобразования компилятор выдавал ошибку.
    • +2
      По поводу cast'а… В данном случае мы передаём свои данных (в виде указателя) через очередь сообщений ОС — типизацию при этом сохранить невозможно в принципе.

      Да, и кстати, все наши красивые и формальные ООП библиотеки внутри работают именно через подобные cast'ы, потому что иначе работать с функциями ОС невозможно.
    • +3
      Ну тут все просто: 5 лет для C++ это довольно мало. Еще лет через 5-7 разработки на C++, код покажется действительно простым.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.