Pull to refresh
0
Инфопульс Украина
Creating Value, Delivering Excellence

Resumable функции

Reading time 11 min
Views 26K
Original author: Jens Weller
На прошлой неделе в мире С++ произошло интересное событие. Компания Microsoft объявила о выходе обновления к компилятору С++ в Visual Studio 2013. Само по себе обновление компилятора отдельно от Visual Studio или её сервис-пака — уже нетривиальное для Microsoft событие. Но ещё интереснее то, что вошло в это обновление. Полный список можно почитать по ссылке выше, а я остановлюсь только на одном моменте — resumable функции. Для полного понимания ситуации: Microsoft изрядно протроллила и комитет по стандартизации С++ и разработчиков gcc\clang, выпустив (тут надо внимательно) реализацию экспериментальной и не утверждённой ещё возможности будущего стандарта C++17, основанной на экспериментальных и не утверждённых ещё возможностях будущего стандарта C++14, которые в свою очередь являются исправлениями не сильно ещё вошедших в повседневное программирование возможностей С++11.

Достаточно гиковский ход, не находите?

А ниже будет перевод статьи с meetingcpp.com, рассказывающей о том, что это за фича и как её использовать.


На прошедшей недавно конференции BUILD Херб Саттер рассказывал о будущем языка С++. Его доклад был полон красивых примеров на С++11 и С++14. И вдруг, прямо из ниоткуда — resumable функции. Херб — один из авторов документа, описывающего std::future и resumable функции, так что само их упоминание не было для меня сюрпризом, но вот что меня удивило, так это то, сколько внимания он уделил этой теме и упоминание того факт, что resumable функции войдут в обновление к Visual Studio 2013 (пусть не в сам релиз VS2013, но всё же намного раньше следующей версии IDE).

Я начну с небольшого спойлера: это возможность как минимум С++1y, она не войдёт в С++14, но в дальнейшем именно асинхронное и параллельное программирование будет в тренде развития языка, так что resumable функции станут органичной частью нового стандарта. В дальнейшем эта фича будет поддерживаться всеми компиляторами, а на данный момент Microsoft шагает впереди планеты всей с собственной реализацией. Не секрет, что данная функциональность имеет некоторую аналогию с async/await из языка C#.

Что такое resumable функции?

Это, вообще-то и есть главный вопрос, который мы тут пытаемся выяснить. Прежде чем я начну объяснять, чем бы это могло быть и как их определяет документ N3650, я должен сделать небольшую остановку и рассказать, что такое futures, поскольку resumable функции основаны на том предположении, что std::future будет расширено методом .then(), как это предполагается в документе N3634. future — это результат выполнения асинхронной операции. Это одно из базовых понятий асинхронного программирования. future — это место, где хранится информация о статусе выполнения асинхронной задачи и её результат, если он уже доступен. Вы можете вызывать метод get(), который дождётся завершения асинхронной операции и вернёт вам её результат (это уже реализовано в стандарте), либо зарегистрировать обработчик её завершения через метод .then() (который пока что не в стандарте). Отсутствие .then() в С++11 — одна из наиболее критикуемых ошибок, она наверняка будет исправлена в С++14, вместе с некоторыми другими улучшениями std::future.

С++11 добавил в С++ лямбды, так что в комбинации это даёт возможность построить цепочку асинхронных вызовов лямбда-функций (колбеков). Теперь будет возможно запустить выполнение асинхронной задачи и отреагировать на её завершение в обработчике, зарегистрированном через метод .then(). «Прочитать ответ сервера, then — распарсить его, then — обработать его, ...». С проверкой ошибок и логированием по ходу дела. Такой подход является обыденным делом в некоторых языках, но пока не в С++. Правильное понимание столь мощного механизма может серьёзно повлиять на то, как вы будете писать код в будущем.

Короткий пример, чтобы продемонстрировать std::future:

std::future<int> f_int = make_dummy_future(42);
int i = f_int.get() // ждём окончания работы функции
f_int.then([](std::future<int> i){/* deal with it */}) // регистрируем обработчик


Идея resumable функции в том, чтобы позволить компилятору самому позаботиться о построении цепочки futures, присоединённых друг к другу, и правильном их вызове через .then().
Достичь этого предлагается через объявление двух новых ключевых слов: async и await. Обратите внимание, в этом нет ничего общего с библиотекой std::async, это НЕ библиотека, это расширение языка программирования. Функция помечается ключевым словом async, после её объявления, но до её спецификации по генерируемым исключениям:

void resumable_function(int i) async


Так что теперь компилятор знает, что это resumable функция. И начинается веселье. Хотя это и функция, но всё же достаточно ограниченная по возможностям. Первое из них это её возвращаемый тип — он может быть либо void, либо std::future/std::shared_future. Возможно, типы, которые могут быть преобразованы к std::(shared_)future тоже будут разрешены, но вообще-то неявные преобразования — не лучшее решение здесь, так что, возможно, комитет решить выбрать строгое соответствие типов. Текущий документ ещё пока разрешает возвращать T, неявно конвертируя его в
std::future.

Внутри resumable функции вещи происходят тоже слегка иначе. Используя ключевое слово await теперь можно "завернуть" выражение или вызов функции в future, которая посчитает это выражение или вызов в другом потоке. Ключевое слово await здесь определяется как унарный оператор (аналогично, например, оператору "!").

И вот мы подоходим к интересному. Вы можете использовать ключевое слово await неоднократно - каждое его применение создаст std::future, которая начнёт выполняться в параллельном потоке. Давайте посмотрим на пример, который в своём докладе использовал Hartmut Kaiser - это рассчёт чисел Фибоначчи:

std::future<uint64_t> fibonacci(uint64_t n) async { if (n < 2) return std::make_ready_future(n); std::future<uint64_t> lhs = std::async(&fibonacci, n-1); std::future<uint64_t> rhs = fibonacci(n-2); return await lhs + await rhs; }


Вот так resumable функции будут выглядеть в коде. Оборачивание функции lhs в std::future не требуется, вы можете вызывать любую функцию с ключевым словом await, компилятор обернёт её в std::future за вас.

Как я писал ранее, resumable функции - специальный вид функций, поскольку первый await возвращает future вызвавшему и здесь всё становится чуть сложнее. Реализация механизма в компиляторе должна предусматривать нечто более сложное, чем просто стек функции, который будет уничтожен при первом же await. Реализация должна убедиться, что локальные данные функции будут всё еще достижимы коду за await. Но для программиста, который будет использовать этот механизм эти детали не должны играть роли, вся магия происходит за сценой.

Библиотечное решение

Когда я впервые познакомился с resumable функциями, одной из моих мыслей было "разве всё это нельзя реализовать без изменений языка?". Ответ - можно. Думаю, многие из читателей могли бы представить себе библиотечное решение с похожей функциональностью. В resumable функциях почти нет выигрыша по производительности работы скомпилированного кода. Вот каким образом Thomas Heller продемонстрировал предыдущий пример (рассчет чисел Фибоначчи) без resumable функций.

std::future< uint64_t> fibonacci(uint64_t n)
{
    if (n < 2) return std::make_ready_future(n);

    std::future<uint64_t> lhs_future = std::async(&fibonacci, n-1); //.unwrap();
    std::future<uint64_t> rhs_future = fibonacci(n-2);

    return
        dataflow(
            unwrapped([](uint64_t lhs, uint64_t rhs)
            {
                return lhs + rhs;
            })
          , lhs_future, rhs_future
        );
}


Вот так оно может выглядеть. Но заметьте, "dataflow" будет иметь семантику await только являясь последним выражением функции. Только в этом случае можно вернуть объект future, соответствующий общему результату. Так что с С++11 или С++14 мы уже можем кое-что сделать и на уровне библиотек.

Так зачем изменять язык и вводить какие-то там resumable функции?

Как я писал выше, нет никаких прямых и видимых причин с точки зрения производительности, однако данное решение чуть более элегантно и имеет некоторые другие преимущества. Я разговаривал с Hartmut Kaiser об этой фиче и он дал мне чётко понять, что считает resumable функции хорошим решением. Он заметил, что благодаря возможности сохранять локальное состояние функции в некоторых случаях мы получим экономию на выделении\освобождении памяти. Библиотечные решения так или иначе будут вынуждены выделять и освобождать стек для вызовов своих функций, а вот решение на уровне языка может применить более эффективные приёмы.

Другие преимущества resumable функций

Скорость и производительность - не единственное (а может быть даже и не главное) в resumable функциях. Более приятное - сам их синтаксис, его легкость, простота и изящность. Вы можете просто начать писать код с использованием async/await, наряду с базовыми конструкциями языка типа (if/else, for и т.д.). Код становится чище. Вот пример из документа N3650, в начале с использованием только std::future:

future<int> f(shared_ptr str)
{
  shared_ptr<vector> buf = ...;
  return str->read(512, buf)
  .then([](future<int> op)// lambda 1
  {
    return op.get() + 11;
  });
}

future<void> g()
{
  shared_ptr s = ...;
  return f(s).then([s](future<int> op) // lambda 2
  {
  s->close();
  });
} 


А теперь напишем то же самое на resumable функциях:

future<void> f(stream str) async
{
  shared_ptr<vector> buf = ...;
  int count = await str.read(512, buf);
  return count + 11;
}

future g() async
{
  stream s = ...;
  int pls11 = await f(s);
  s.close();
}


Код с resumable функциями стал короче и намного лучше читаемым (что, на самом деле, самое важное в коде). Но настоящие преимущества начинают проявляться когда асинхронный код комбинируется с базовыми управляющими элементами языка. Я покажу вам короткий пример, который привел Herb Sutter в своём докладе на BUILD:

std::string read( std::string file, std::string suffix ) {
   std::istream fi = open(file).get();
   std::string ret, chunk;
   while( (chunk = fi.read().get()).size() )
      ret += chunk + suffix;
   return ret;
}


Это простой пример "синхронной асинхронности" - в коде используется future::get() чтобы дождаться результата асинхронной операции в std::future. Неплохо было бы улучшить и ускорить этот код, заменив get() на then(). Давайте посмотрим, что выйдет.

task<std::string> read( std::string file, std::string suffix ) {
   return open(file)
   .then([=](std::istream fi) {
      auto ret = std::make_shared<std::string>();
      auto next = 
         std::make_shared<std::function<task()>>(
      [=]{
         fi.read()
         .then([=](std::string chunk) {
            if( chunk.size() ) {
               *ret += chunk + suffix;
               return (*next)();
            }
            return *ret;
         });
      });
      return (*next)();
   });
}


Для того чтобы использовать .then() корректно нам пришлось слегка усложнить код. А теперь давайте посмотрим как то же самое могло бы выглядеть на async/await:

task<std::string> read( std::string file, std::string suffix ) __async {
   std::istream fi = __await open(file);
   std::string ret, chunk;
   while( (chunk = __await fi.read()).size() )
      ret += chunk + suffix;
   return ret;
}


В обоих случаях возвращаемое значение должно быть типа task<std::string>, поскольку на момент возврата оно всё ещё может быть в процессе рассчёта. Версия с использованием await значительно проще, чем версия с .then(). Данная реализация использует ключевые слова __async и __await в том виде, в каком они будут добавлены в Visual Studio.

Давайте вернёмся к реальному коду. Вашей работой часто будет его поддержка, даже если его написал кто-то другой. Представьте себе цепочку из std::future, auto и .then выражений, с вкраплениями лямбда функций - скорее всего это не то, на что вам хотелось бы смотреть каждый день. Но именно этим всё и закончится без resumable функций. Этот код не будет менее производительным, но вот время, которое вы потратите на его модификацию будет значительно больше, чем в случае с простыми и логичными async/await. С использованием resumable функций компилятор берёт на себя массу отвлекающих деталей, заботится о корректности "границ" областей видимости, правильно заворачивает результаты вызовов асинхронных функций в std::future, так что на данный момент счёт как минимум 1:0 в пользу resumable функций.

Идём дальше. Мы уже выяснили, что resumable функции добаляют изящества в код и делают вещи более простыми. Но достаточно ли этого, чтобы вот прямо взять и изменить язык С++? Вряд ли. Должны быть и другие причины. И они есть. Смотрите - у нас есть 2 варианта: поддержка на уровне библиотек и поддержка на уровне языка. Если забыть о красоте синтаксиса - равнозначны ли они в остальном? Нет. Давайте представим себе, каким образом может происходить отладка асинхронного кода. В случае использования библиотечного решения вопрос отладки стоит очень остро. Вы пробовали отлаживать чужие библиотеки? Закрытые библиотеки? Даже в случае открытой библиотеки мы будем постоянно терять контекст при переходам по цепочке futures (стек-то у каждой функции свой). И не факт, что при возникновении ошибки мы будет в состоянии понять, как сюда попали, кто нас вызвал и почему. В случае же поддержки resumable функций на уровне языка все инструменты (компилятор, отладчик) так или иначе будут работать "в одной связке": компилятор может сгенерировать код, удобный для отладчика, у отладчика не будет варианта НЕ дать нам нужного для отладки функционала, он не будет ссылаться на написанный не нами код - мы просто получим всё необходимое "из коробки".

Как я уже писал ранее, resumable функции в некоторой степени ограничены. Они могут возвращать лишь std::(shared_)future или void. Это не лучший вариант, было бы удобно иметь возможность вернуть boost::future или hpx::future. Возможно, этого удастся достичь через применения "концептов", но пока что есть как есть. Второе ограничение - resumable функции не могут использовать VArgs, для этого придётся написать отдельную функцию-обёртку. И я пока не очень понимаю, относится ли это ограничение к variadic templates. Ну и кроме того, на значение, возвращаемое resumable функцией накладываются ограничения, которые существуют для типов, которые можно использовать в std::future - на практике это означает обязательное наличие copy/move конструкторов.

Планы на будущее

Как я писал ранее, эта фича не войдет в С++14. Она была бы просто киллер-фичей, но к сожалению (к счастью?) С++14 по определению не должен (и не будет) содержать киллер-фич на уровне языка. Его задача - исправить явные баги С++11 и заложить основу для масштабных улучшений в будущем. Так что всё, о чём мы здесь говорили - удел С++y1. Следующий большой шаг для resumable функций - стать частью технической спецификации (TS), ответственная за это подгруппа в комитете - WG21. Несмотря на то, что синтаксис и ограничения resumable функций понять достаточно легко, реализация в компиляторе простой не является. Есть несколько вариантов реализации и пока нет согласованного взгляда, какой из них является лучшим. Как я уже говорил, первая реализация войдет в CTP к Visual Studio в конце этого года (прим.переводчика: и она вышла!). Эта реализация будет использовать ключевые слова __async и __await.

Важно понять, что всё написанное в статье ещё находится в работе. Многое зависит от утверждения в С++14 спецификации на .then() для future, await в итоге может быть даже построен на std::future::get.

Мнение

Пару слов с моей колокольни. Я вообще не очень-то по всей этой асинхронности и параллелизму, есть ребята поумнее меня - им и решать. Мне нравятся resumable функции в предложенном варианте. Также неплохим является вариант, предложенный в Cilk (уже есть реализация и она работает - проверено). Сравнивая их - resumable функции всё-равно кажутся мне чуть более элегантными - кода меньше, а возможности те же.

Новые ключевые слова могут поломать существующий код (а вдруг у вас были переменные с именами __async\__await ?). Авторы предложения resumable функций проанализировали STL и Boost и не нашли ничего подобного - уже неплохо.

Немного меня смущает то, что resumable функции - неполноценные функции из-за своих ограничений (см. выше). Но, наверное, они и не должны ими быть. В конце-концов, вы ведь не собираетесь использовать их вот прямо везде? Это лишь один из удобных механизмов асинхронности, такой себе синтаксический сахар, иногда полезный, а иногда - нет.

Кроме того, есть целый зоопарк вопросов, связанный с деталями реализации resumable функций. Например - должны ли существовать resumable lambda functions ? Если вы заинтересовались вопросом - почитайте заметки группы WG21, начиная с июля 2013 года.

Обновление - документ N3722

В конце Августа был опубликован документ с обновлением предложения resumable функций. Первое изменение состоит в том, что ключевое слово async заменено на resumable. Это хорошо, поскольку resumable функции теперь называются "resumable", что в общем-то логично. Значение ключевого слова await не изменилось.

Также появился параграф на счет использования типов, отличных от std::future в качестве результата функции. Документ определяет, что функция может возвращать любой тип вида s, который реализует следующий интерфейс:

  • метод get(), который возвращает тип Т или генерирует исключение
  • метод then(), который получает callable object с параметром типа s, s& или const s. Значение этого параметра должно быть сразу же доступно методу get().
    опциональный метод is_ready(), возвращающий состояние future


    Далее авторы рассуждают о том, что должен быть определён тип
    s::promise_type, который будет доступен реализации resumable функции. Этот тип должен предоставлять методы set_value(T) и set_exception(exception_ptr). Должно существовать неявное преобразование между s::promise_type и s.

    Генераторы

    Документ также включает в себя концепт, который называется "генераторная функция" или просто "генератор". В STL имеются алгоритмы, позволяющие применить некоторую функцию на все элементы контейнера, но они возвращают результат только по применению ко всем элементам контейнера, а генератор - возвращает результат непосредственно перед применением к первому элементу. Тип возвращаемого значения - sequence и тот, кто вызвал генератор, может использовать это значения для итерирования по последовательности, которая будет создаваться по ходу итерирования (ленивые вычисления).

    Для достижения этого эффекта предлагается ввести новое ключевое слово yield:

    sequence<int> range(int low, int high) resumable { for(int i = low; i <= high; ++i) { yield i; } }


    yield будет вычислять значение i когда это понадобится при запросе к sequence. Каждая итерация по sequence будет вызывать функцию, ожидая получить следующий результат от yield. Здесь не идёт речь о многопоточном или асинхронном программировании - всё выполняется в одном потоке, но только лишь тогда, когда понадобится. Документ предлагает комбинировать в коде yield и await для достижения нужных программисту результатов.
Tags:
Hubs:
+54
Comments 65
Comments Comments 65

Articles

Information

Website
www.infopulse.com
Registered
Founded
1992
Employees
1,001–5,000 employees
Location
Украина