C++20 Coroutines by Example

Lecture



One of the most important new features of C++20 is coroutines. A coroutine is a function that can be suspended and later resumed. A function becomes a coroutine if it uses any of the following:

  • the co_await operator, to suspend execution until it is resumed

  • the keyword co_return, to complete execution and (optionally) return a value

  • the keyword co_yield, to suspend execution and return a value

In addition, the return type of a coroutine must satisfy certain conditions. However, the C++20 standard defines only a framework for executing coroutines, and does not define any coroutine types that satisfy the stated requirements. This means that we either need to write our own or rely on third-party libraries. In this article I will show how to write a few simple examples using the cppcoro library.

The cppcoro library contains abstractions for C++20 coroutines, including the task (task), the generator (generator) and async_generator. A task represents an asynchronous computation that is executed lazily (that is, only when the coroutine is awaited), and a generator is a sequence of values of some type T that are also produced lazily (that is, when the begin() function is called to obtain an iterator, or the ++ operator is called on the iterator).

Let's look at an example. The produce_items() function below is a coroutine because it uses the co_yield keyword to return a value and has the return type cppcoro::generator<std::string>, which satisfies the requirements for a generator coroutine.

#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;
  }
}

NOTE: the rand() function is used purely for simplicity of the example. Do not use this outdated function in real production code.

This function implements an infinite loop whose execution is suspended each time we reach the co_yield statement. The function produces a random number each time execution is resumed, which happens when the generator is iterated. An example is shown below:

#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;
}

The consume_items function is also a coroutine. It uses the co_return keyword to complete execution, and its return type is cppcodo::task<>, which also satisfies the requirements for a coroutine type. This function runs a loop n times using a range-based for. The loop calls the begin() function of the cppcoro::generator<std::string> class to obtain an iterator, which is subsequently incremented with the ++ operator. produce_items() is resumed on each of these calls and returns a new (random) value. If an exception occurs, it is rethrown to the function that called begin() or the ++ operator. In produce_items() the function could be resumed an infinite number of times, although the consuming code needs only a finite number of calls.

consume_items() can be called from main(). However, since main() cannot be a coroutine, it cannot use the co_await operator to wait for its completion. To help with this, the cppcoro library provides a function named syncwait(), which synchronously waits until the specified awaitable completes (the awaitable is awaited on the current thread inside a newly created coroutine). This function blocks the current thread until the operation completes and returns the result of the coawait expression. If an exception occurs, it is rethrown to the caller.

The following code snippet shows how we can call and await require_items() from main():

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

The output of this program looks as follows:

C++20 Coroutines by Example

cppcoro::generator<T> generates values in a lazy but synchronous manner. This means that using the co_await operator from a coroutine that returns this type is not possible. However, the cppcoro library has an asynchronous generator, cppcoro::async_generator<T>, which makes this possible.

We can modify the previous example as follows: a new coroutine, next_value(), will return a value that takes some time to compute. We simulate this behavior by waiting a random number of seconds. In each iteration of the loop, the produce_items() coroutine will await a new value and then return a new item based on that value. The return type this time is 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;
  }
}

The consumer requires a small change, because it must await each new value. This is done with the co_await operator in the for loop as follows:

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;
  }
}

The co_return statement is no longer present in this implementation, although it can be added. Because co_await is used in the for loop, the function is a coroutine. You do not need to add empty co_return statements at the end of a coroutine returning cppcoro::task<>, just as you do not need empty return statements at the end of an ordinary function returning void. The previous implementation required this statement because there was no co_await call, so co_return was needed to make the function a coroutine.

No changes to main() are required. However, when we run the code this time, each value is generated after some random interval of time, as shown in the following image:

C++20 Coroutines by Example

For completeness, the print_time() function mentioned in these examples looks as follows:

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;
   }
}

Another important detail to note is that calling co_await with a given time duration is not possible by default. However, it becomes possible by overloading the co_await operator. The following implementation works on 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 };
}

Comments

To leave a comment

If you have any suggestion, idea, thanks or comment, feel free to write. We really value feedback and are glad to hear your opinion.
To reply

Lectures and tutorial on "Algorithmization and programming. Structural programming. C language"

Terms: Algorithmization and programming. Structural programming. C language