C++20: cppcoro coroutines

The cppcoro library from Lewis Baker gives us something that C++20 doesn’t give us – a C++ coroutine abstraction library based on Coroutines TS.

Maybe my last two posts”C++20: infinite data stream with coroutines” and “C++20: thread synchronization with coroutines” were quite difficult to understand. But the following posts about coroutines should be easier to digest. I generously provided them with examples of existing coroutines cppcoro.

To simplify my argument a bit, I want to start with a few words about coroutines and the coroutine framework.

cppcoro

The cppcoro library by Lewis Baker is based on Coroutine TS. TS stands for technical specifications (technical specs) and is a preview of the coroutine framework we got in C++20. Lewis is porting the cppcoro library from the Coroutines TS framework to the Coroutines framework we got in C++20.

I believe there is one reason why porting a library is very important: in C++20, we didn’t get the coroutines themselves, but only the coroutine framework. This nuance means that if you want to use coroutines in C++20, then you are on your own. You need to create your own coroutines based on the C++20 coroutine framework. Most likely, we will get some specific coroutines only in C++23. To be honest, I think this is extremely important, because the implementation of coroutines is quite complex and therefore error prone. This gap is exactly what cppcoro fills. It provides abstractions for coroutines, awaitable types, functions, cancellation, schedulers, networking, metafunctions, and defines several concepts.

Using cppcoro

cppcoro is currently based on the Coroutines TS framework and can be used on Windows (Visual Studio 2017) or Linux (Clang 5.0/6.0 and libc++). For all the examples in our experiments, I will use the configuration you can see on the command line below:

  • -std=c++17: C++17 support

  • -fcoroutines-ts: Coroutines TS C++ support

  • -Iinclude: cppcoro headers

  • -stdlib=libc++: LLVM standard library implementation

  • libcppcoro.a: cppcoro library

As I mentioned earlier: when cppcoro is updated to C++20 in the future, you will be able to use it with every compiler that supports C++20. And now, with the help of it, we can get some idea about the specific implementations of coroutines that we can see in C++23.

I found cppcoro to be easy to learn and I want to show you some examples of how to use it. To demonstrate the various features of cppcoro, I’m using snippets from existing code and tests. Let’s start with coroutine types.

Coroutine types

cppcoro has different kinds of tasks (task) and generators (generator).

task

What is a task? Here is a definition taken straight from the documentation:

  • task is an asynchronous computation that is performed lazily, since the execution of the coroutine does not begin until the task becomes awaited.

A task is a coroutine. In the following program, the function main expects a function first, first expects seconda second expecting a third.

// cppcoroTask.cpp

#include <chrono>
#include <iostream>
#include <string>
#include <thread>

#include <cppcoro/sync_wait.hpp>
#include <cppcoro/task.hpp>

using std::chrono::high_resolution_clock;
using std::chrono::time_point;
using std::chrono::duration;

using namespace std::chrono_literals; // 1s
   
auto getTimeSince(const time_point<high_resolution_clock>& start) {
    
    auto end = high_resolution_clock::now();
    duration<double> elapsed = end - start;
    return elapsed.count();
    
}

cppcoro::task<> third(const time_point<high_resolution_clock>& start) {
    
    std::this_thread::sleep_for(1s);
    std::cout << "Third waited " << getTimeSince(start) << " seconds." << std::endl;

    co_return;                                                     // (4)
        
}

cppcoro::task<> second(const time_point<high_resolution_clock>& start) {
    
    auto thi = third(start);                                       // (2)
    std::this_thread::sleep_for(1s);
    co_await thi;                                                  // (3)
    
    std::cout << "Second waited " <<  getTimeSince(start) << " seconds." << std::endl;
    
}

cppcoro::task<> first(const time_point<high_resolution_clock>& start) {
    
    auto sec = second(start);                                       // (2)
    std::this_thread::sleep_for(1s);
    co_await sec;                                                   // (3)
    
    std::cout << "First waited " <<  getTimeSince(start)  << " seconds." << std::endl;
    
}

int main() {
    
    std::cout << std::endl;
    
    auto start = high_resolution_clock::now();
    cppcoro::sync_wait(first(start));                              // (1)
    
    std::cout << "Main waited " <<  getTimeSince(start) << " seconds." << std::endl;
    
    std::cout << std::endl;

}

As you can see, this program doesn’t really do much other than serve as a demonstration of a coroutine slave.

First, the function main cannot be a coroutine. cppcoro::sync_wait (line (1)) often serves as a top-level start task and waits for the task to complete (not just in this example). Coroutine firstlike all other coroutines, receives as an argument start (work start time) and displays the time of its execution. What happens in this coroutine? She runs a coroutine second (line (2)) which immediately pauses, sleeps for a second, and resumes the coroutine with a handle sec in line (3). Coroutine second does the same with thirdbut third a different behavior. third – a coroutine that does not return anything and does not expect another coroutine. When third finishes execution, all other coroutines are executed along the chain. Ultimately, the work of each of the coroutines takes three seconds.

Let’s change the program a little. What happens if coroutines go to sleep after being called co_await?

// cppcoroTask2.cpp

#include <chrono>
#include <iostream>
#include <string>
#include <thread>

#include <cppcoro/sync_wait.hpp>
#include <cppcoro/task.hpp>

using std::chrono::high_resolution_clock;
using std::chrono::time_point;
using std::chrono::duration;

using namespace std::chrono_literals;

auto getTimeSince(const time_point<::high_resolution_clock>& start) {
    
    auto end = high_resolution_clock::now();
    duration<double> elapsed = end - start;
    return elapsed.count();
    
}

cppcoro::task<> third(const time_point<high_resolution_clock>& start) {
    
    std::cout << "Third waited " << getTimeSince(start) << " seconds." << std::endl;
    std::this_thread::sleep_for(1s);
    co_return;
        
}


cppcoro::task<> second(const time_point<high_resolution_clock>& start) {
    
    auto thi = third(start);
    co_await thi;
    
    std::cout << "Second waited " <<  getTimeSince(start) << " seconds." << std::endl;
    std::this_thread::sleep_for(1s);
    
}

cppcoro::task<> first(const time_point<high_resolution_clock>& start) {
    
    auto sec = second(start);
    co_await sec;
    
    std::cout << "First waited " <<  getTimeSince(start)  << " seconds." << std::endl;
    std::this_thread::sleep_for(1s);
    
}

int main() {
    
    std::cout << std::endl;
 
    auto start = ::high_resolution_clock::now();
    
    cppcoro::sync_wait(first(start));
    
    std::cout << "Main waited " <<  getTimeSince(start) << " seconds." << std::endl;
    
    std::cout << std::endl;

}

Perhaps you have already guessed. Function main waits for three seconds, but each subsequent call to the coroutine reduces the wait by one second.

In future posts, I’m going to look at tasks in combination with threads and signals.

generator

Here is the definition from cppcoro:

  • generator represents a coroutine type that creates a sequence of type values Twhere values ​​are created lazily and synchronously.

No extra introductions to the program cppcoroGenerator.cppwhich demonstrates the operation of two generators:

// cppcoroGenerator.cpp

#include <iostream>
#include <cppcoro/generator.hpp>

cppcoro::generator<char> hello() {
    co_yield 'h';                  
    co_yield 'e';                   
    co_yield 'l';                   
    co_yield 'l';                   
    co_yield 'o';                   
}

cppcoro::generator<const long long> fibonacci() {
    long long a = 0;
    long long b = 1;
    while (true) {
        co_yield b;                 // (2)
        auto tmp = a;
        a = b;
        b += tmp;
    }
}

int main() {

    std::cout << std::endl;
    
    for (auto c: hello()) std::cout << c; 
    
    std::cout << "\n\n";
    
    for (auto i: fibonacci()) {  // (1)
        if (i > 1'000'000) break;
        std::cout << i << " ";
    }
    
    std::cout << "\n\n";
    
}

First coroutine hello returns the next character on request; coroutine fibonacci the next number in the Fibonacci series. fibonacci creates an endless stream of data. What happens in line (1)? Cycle for with a range starts coroutine execution. The first iteration runs coroutines, returns a value co_yield b and pauses. Subsequent calls to this loop resume the coroutine fibonacci and return the next number from the Fibonacci series.

And finally, I would like to give an intuitive understanding of the difference between co_await (for the task) and co_yield (for generator): co_await waiting inside, co_yield waiting outside. For example, coroutine first waiting for a coroutine to be called second (cppcoroTask.cpp), and the coroutine fibonacci (cppcoroGenerator.cpp) is triggered by the outer loop for with range.

What’s next?

In my next post about cppcoro, we will delve into the tasks. I’ll cover their use with threads, signals, or thread pools.


The translation of the article was prepared on the eve of the start of the course “C++ Developer. Professional”. Pass entrance testingif you are curious to know your level of knowledge for admission.

Similar Posts

Leave a Reply