Pull to refresh
900.93
OTUS
Цифровые навыки от ведущих экспертов

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

Reading time6 min
Views9.9K
Original author: 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++".

Tags:
Hubs:
Total votes 11: ↑9 and ↓2+7
Comments7

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS