Корутины C++20 в примерах

Автор оригинала: Marius Bancila
  • Перевод

В преддверии старта курса "C++ Developer. Professional" приглашаем записаться на открытый вебинар на тему "Backend на современном C++".

Также делимся традиционным переводом полезного материала.


Одним из наиболее важных нововведений C++20 являются корутины. Корутина — это функция, которая может быть приостановлена ​​и после этого возобновлена. Функция становится корутиной, если она используете что-либо из следующего:

  • оператор co_await, чтобы приостановить выполнение до его возобновления

  • ключевое слово co_return, чтобы завершить выполнение и вернуть значение (опционально)

  • ключевое слово co_yield, чтобы приостановить выполнение и вернуть значение

Вдобавок тип возвращаемого значения корутины должен удовлетворять определенным условиям. Однако стандарт C++20 определяет только фреймворк для выполнения корутин, но не определяет никаких типов корутин, удовлетворяющих изложенным требованиям. Это означает, что нам нужно либо писать свои собственные, либо полагаться на сторонние библиотеки. В этой статье я покажу, как написать несколько простых примеров с использованием библиотеки cppcoro.

Библиотека cppcoro содержит абстракции для корутин C++20, включая задачу (task), генератор (generator) и async_generator. Задача представляет собой асинхронное вычисление, которое выполняется лениво (то есть только тогда, когда корутина ожидается (awaited)), а генератор — это последовательность значений некоторого типа T, которые также создаются лениво (то есть когда вызывается функция begin(), чтобы получить итератор, или на итераторе вызывается оператор ++).

Давайте посмотрим на пример. Приведенная ниже функция produce_items() является корутиной, потому что она использует ключевое слово co_yield для возврата значения и имеет тип возвращаемого значения cppcoro::generator<std::string>, который удовлетворяет требованиям к корутине-генератору.

#include <cppcoro/generator.hpp>

cppcoro::generator<std::string> produce_items()
{
  while (true)
  {
     auto v = rand();
     using namespace std::string_literals;
     auto i = "item "s + std::to_string(v);
     print_time();
     std::cout << "produced " << i << '\n';
     co_yield i;
  }
}

ПРИМЕЧАНИЕ: функция rand() используется исключительно для простоты примера. Не используйте эту устаревшую функцию в реальном продакшн коде.

Эта функция реализует бесконечный цикл, выполнение которого приостанавливается всякий раз, когда мы достигаем оператора co_yield. Эта функция выдает случайное число при каждом возобновлении выполнения, что происходит при итерировании генератора. Пример показан ниже:

#include <cppcoro/task.hpp>

cppcoro::task<> consume_items(int const n)
{
  int i = 1;
  for(auto const& s : produce_items())
  {
     print_time();
     std::cout << "consumed " << s << '\n';
     if (++i > n) break;
  }

  co_return;
}

Функция consume_items также является корутиной. Она использует ключевое слово co_return для завершения выполнения, а ее возвращаемый тип — cppcodo::task<>, который также удовлетворяет требованиям для типа корутины. Эта функция запускает цикл n раз, используя for с диапазоном. Этот цикл вызывает функцию begin() класса cppcoro::generator<std::string>, чтобы получить итератор, который впоследствии инкрементируется с помощью оператора ++. produce_items() возобновляется при каждом из этих вызовов и возвращает новое (случайное) значение. Если возникает исключение, оно перебрасывается функции, вызывающей begin() или оператор ++. В produce_items() функция может быть возобновлена бесконечное количество раз, хотя потребляющий код нуждается лишь в конечном числе вызовов.

consume_items() может быть вызвана из main(). Однако, поскольку main() не может быть корутиной, она не может использовать оператор co_await для ожидания завершения ее выполнения. Чтобы помочь с этим, библиотека cppcoro предоставляет функцию с именем syncwait(), которая синхронно ожидает завершения указанной awaitable (которая ожидается в текущем потоке внутри вновь созданной корутины). Эта функция блокирует текущий поток до завершения операции и возвращает результат выражения coawait. В случае возникновения исключения оно перебрасывается вызывающей стороне.

Следующий фрагмент кода показывает, как мы можем вызывать и ожидать require_items() из main():

#include <cppcoro/sync_wait.hpp>
 
int main()
{
   cppcoro::sync_wait(consume_items(5));
}

Вывод этой программы выглядит следующим образом:

cppcoro::generator<T> генерирует значения ленивым, но синхронным образом. Это означает, что использование оператора co_await из корутины, возвращающей этот тип, невозможно. Однако в библиотеке cppcoro есть асинхронный генератор cppcoro::async_generator<T>, который делает это возможным.

Мы можем изменить предыдущий пример следующим образом: возвращать значение, для вычисления которого требуется некоторое время, будет новая корутина next_value(). Мы симулируем такое поведение, ожидая случайное количество секунд. В каждой итерации цикла корутина produce_items() будет ожидать новое значение, а затем возвращать новый элемент на основе этого значения. Тип возврата на этот раз — cppcoro::async_generator<T>.

#include <cppcoro/async_generator.hpp>

cppcoro::task<int> next_value()
{
  using namespace std::chrono_literals;
  co_await std::chrono::seconds(1 + rand() % 5);
  co_return rand();
}

cppcoro::async_generator<std::string> produce_items()
{
  while (true)
  {
     auto v = co_await next_value();
     using namespace std::string_literals;
     auto i = "item "s + std::to_string(v);
     print_time();
     std::cout << "produced " << i << '\n';
     co_yield i;
  }
}

Потребитель требует небольшого изменения, потому что он должен ожидать каждого нового значения. Это реализуется с помощью оператора co_await в цикле for следующим образом:

cppcoro::task<> consume_items(int const n)
{
  int i = 1;
  for co_await(auto const& s : produce_items())
  {
     print_time();
     std::cout << "consumed " << s << '\n';
     if (++i > n) break;
  }
}

Оператор co_return больше не присутствует в этой реализации, хотя его можно добавить. Поскольку co_await используется в цикле for, функция является корутиной. Вам не нужно добавлять пустые операторы co_return в конец корутины, возвращающей cppcoro::task<>, точно так же, как вам не нужны пустые операторы return в конце обычной функции, возвращающей void. Предыдущая реализация требовала этого оператора, потому что не было вызова co_await, следовательно, co_return был необходим, чтобы сделать функцию корутиной.

Никаких изменений в main() не требуется. Однако, когда мы в этот раз выполняем код, каждое значение генерируется через некоторый случайный интервал времени, как показано на следующем изображении:

Для полноты картины, функция print_time(), упомянутая в этих примерах, выглядит следующим образом:

void print_time()
{
   auto now = std::chrono::system_clock::now();
   std::time_t time = std::chrono::system_clock::to_time_t(now);   

   char mbstr[100];
   if (std::strftime(mbstr, sizeof(mbstr), "[%H:%M:%S] ", std::localtime(&time))) 
   {
      std::cout << mbstr;
   }
}

Еще одна важная деталь, на которую следует обратить внимание, — это то, что вызов co_await с заданной продолжительностью времени по умолчанию невозможен. Однако это стало возможным благодаря перегрузке оператора co_await. В Windows работает следующая реализация:

#include <windows.h>

auto operator co_await(std::chrono::system_clock::duration duration)
{
   class awaiter
   {
      static
         void CALLBACK TimerCallback(PTP_CALLBACK_INSTANCE,
            void* Context,
            PTP_TIMER)
      {
         stdco::coroutine_handle<>::from_address(Context).resume();
      }
      PTP_TIMER timer = nullptr;
      std::chrono::system_clock::duration duration;
   public:

      explicit awaiter(std::chrono::system_clock::duration d) 
         : duration(d)
      {}

      ~awaiter()
      {
         if (timer) CloseThreadpoolTimer(timer);
      }

      bool await_ready() const
      {
         return duration.count() <= 0;
      }

      bool await_suspend(stdco::coroutine_handle<> resume_cb)
      {
         int64_t relative_count = -duration.count();
         timer = CreateThreadpoolTimer(TimerCallback,
            resume_cb.address(),
            nullptr);
         bool success = timer != nullptr;
         SetThreadpoolTimer(timer, (PFILETIME)&relative_count, 0, 0);
         return success;
      }

      void await_resume() {}

   };
   return awaiter{ duration };
}

Эта реализация взята из статьи Coroutines in Visual Studio 2015 - Update 1.

АПДЕЙТ: код был изменен на основе отзывов. Смотрите комментарии.

Чтобы узнать больше о корутинах, смотрите:


Узнать подробнее о курсе "C++ Developer. Professional".

Записаться на открытый вебинар на тему "Backend на современном C++".

ЗАБРАТЬ СКИДКУ

OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Комментарии 7

    +2

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

      +1
      просто тренд сейчас в асинхронщине, а в С++ её не было, решили быть в тренде. Сколько сейчас страниц в последнем стандарте С++, кажись около 2000. Саттер уже подумывает о новом C++ NewLang на замену С++, я его понимаю.
        +1

        Откуда Вы почерпнули информацию, что "Саттер уже подумывает о новом C++ NewLang на замену C++"?

        +1

        А C# стал похожим на язык для пришельцев от добавления async/await, или асинхронный код написанный с async/await проще и понятнее, чем писали до этого (сколько там парадигм было 3 или 4, я не помню)?

          +2

          C# был спроектирован с учётом накопленного в C++ и Java опыта, и стартовал с новой страницы, без бремени совместимости, поэтому его синтаксис всё же полегче, мягко говоря. Одни лямбды чего стоят (в сравнении с плюсами). Жаль, что ниша C# — это скучный Enterprise, писать на нем — одно удовольствие (конечно, имхо, пробовал языков 5).

            +2

            С тем что синтаксис C# пока что полегче я не спорю, и лямбды отличный тому пример. Но синтаксис корутины не усложняют.

        0
                 bool success = timer != nullptr;
                 SetThreadpoolTimer(timer, (PFILETIME)&relative_count, 0, 0);
                 return success;
            }
        
            void await_resume() {}

        Не нравится мне что-то отсутствие обработки ошибок в этом коде. Если не удалось создать таймер — надо кинуть исключение, а не просто немедленно продолжить выполнение!


        Как-то так должно быть:


            void await_resume() {
                if (!await_ready()) return;
                if (!timer) throw …;
            }

        Ну или success надо делать полем, тогда будет проще:


            void await_resume() {
                if (!success) throw …;
            }

        Еще лучше будет сохранить в поле GetLastError()


        АПДЕЙТ: код был изменен на основе отзывов. Смотрите комментарии.

        Что-то я не вижу этих комментариев на Хабре. Вы уверены что эту строчку надо было переводить?

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

        Самое читаемое