Coroutines – A Deep Dive

Coroutines – A Deep Dive

By Quasar Chunawala

Overload, 33(190):12-16, December 2025


Coroutines are powerful but require some boilerplate code. Quasar Chunawala explains what you need to implement to get coroutines working.

Following on from the introduction in the editorial, let’s understand the basic mechanics needed to code up a simple coroutine, and I’ll show you how to yield from the coroutine and await results.

The simplest coroutine

The following code is the simplest implementation of a coroutine:

  #include <coroutine>
  void coro_func(){
    co_return;
  }
  int main(){
    coro_func();
  }

Our first coroutine will just return nothing. It will not do anything else. Sadly, the preceding code is too simple for a functional coroutine and it will not compile. When compiling with gcc 15.2, we get the error shown in Figure 1.

<source>: In function 'void coro_func()':
<source>:4:5: error: unable to find the promise type for this coroutine
    4 |     co_return;
      |     ^~~~~~~~~
Figure 1

Looking at C++ reference, we see that the return type of a coroutine must define a type named promise_type.

The promise_type

Why do we need a promise? The promise_type is the second important piece in the coroutine mechanism. We can draw an analogy from futures and promises which are essential blocks for achieving asynchronous programming in C++. The future is the thing, that the function that does the asynchronous computation, hands out back to the caller, that the caller can use to retrieve the result by invoking the get() member function. The future has the role of the return object. But, you need something that remains on the producer-side. The asynchronous function holds on to the promise, and that’s where it puts the results that will be given to the caller, when it calls get() on the future. The idea behind the promise_type for coroutines is similar. The promise is where the coroutine’s return value is stored, and it provides functions to control the coroutine’s behavior at startup and completion of the coroutine. The promise is the interface through which the caller interacts with the coroutine.

Following the reference advice, we can write a new version of our coroutine.

  #include <coroutine>
  struct Task
  {
    struct promise_type
    {
    };
  };
  Task coro_func(){
    co_return;
  }
  int main(){
    coro_func();
  }

Note that the return type of a coroutine can have any name (I call it Task, so that it makes intuitive sense). Compiling the preceding code again gives us errors. All errors in Figure 2 are about missing functions in the promise_type.

<source>: In function 'Task coro_func()':
<source>:11:5: error: no member named 'return_void' in
'std::__n4861::__coroutine_traits_impl<Task, void>::promise_type' {aka 'Task::promise_type'}
   11 |     co_return;
      |     ^~~~~~~~~
<source>:10:6: error: no member named 'unhandled_exception' in
'std::__n4861::__coroutine_traits_impl<Task, void>::promise_type' {aka 'Task::promise_type'}
   10 | Task coro_func(){
      |      ^~~~~~~~~
<source>:10:6: error: no member named 'get_return_object' in
'std::__n4861::__coroutine_traits_impl<Task, void>::promise_type' {aka 'Task::promise_type'}
Figure 2

One of the important functions of the promise_type is that it determines what happens at certain key points in the coroutine’s life. It determines, what happens at the startup and completion of execution of the coroutine.

Implementing the promise_type

The first thing that the compiler expects from us is the get_return_object() function. The return type of this function is the same as the return type of the coroutine. (See Listing 1, and the output is in Figure 3.)

#include <coroutine>
#include <print>
struct Task{
  struct promise_type{
    Task get_return_object(){
      std::println("get_return_object()");
      return Task{ *this };
    }
    void return_void() noexcept {
      std::println("return_void()");
    }
    void unhandled_exception() noexcept {
      std::println("unhandled_exception()");
    }
    std::suspend_always initial_suspend() 
    noexcept {
      std::println("initial_suspend()");
      return {};
    }
    std::suspend_always final_suspend() noexcept{
      std::println("final_suspend()");
      return {};
    }
  };
  explicit Task(promise_type&){
    std::println("Task(promise_type&)");
  }
  ~Task() noexcept{
    std::println("~Task()");
  }
};
Task coro_func(){
  co_return;
}
int main(){
  coro_func();
}
Listing 1
get_return_object()
Task(promise_type&)
initial_suspend()
~Task()
Figure 3

The get_return_object() method is implicitly called when the coroutine starts executing. Its up to the promise_type to provide an implementation of this method that constructs the return object that will be handed back to the caller. The Task object is stored on the heap. You don’t see this in the source code anywhere. When the coroutine reaches its first suspension point, and control flow is returned back to the caller, then the caller will receive this object.

The return_void() function is a customization point for handling what happens when we reach the co_return statement in the function body. There is also a corresponding return_value(), if you don’t have an empty co_return statement, but we’ll look at this at length later on.

The unhandled_exception() is similar to the return_void(), this function is a customization point for handling, what happens when the coroutine throws an exception. We leave it empty for now.

We need to implement two more functions initial_suspend() and final_suspend(). These are basically the customization points that allow us to execute some code, both when the coroutine first starts executing and shortly before the coroutine ends execution. Here, we are returning std::suspend_always which basically means that at these points, I want to go into suspension.

In a typical implementation, you either return std::suspend_always, which means you pause execution at this point and hand control back to the caller always, or you return std::suspend_never, which basically means you just go on and continue executing the coroutine.

Note that, final_suspend() is not printed in the output, because the coroutine is paused at initial_suspend() and since I never resumed it, I don’t see the output on the console.

A yielding coroutine

Let’s implement another coroutine that can send data back to the caller. In this second example, we implement a coroutine that produces a message. It will be the hello world of coroutines. The coroutine will say hello and the caller function will print the message received from the coroutine.

To implement this functionality, we need to establish a communication channel from the coroutine to the caller. This channel is the mechanism that allows the coroutine to pass values to the caller and receive information from it. This channel is established through the coroutine’s promise_type and the coroutine handle.

The coroutine handle is a type that gives access to the coroutine frame(the coroutine’s internal state) and allows the caller to resume or destroy the coroutine. The handle is what the caller can use to resume the coroutine after it has been suspended (for example after co_await or co_yield). The handle can also be used to check whether the coroutine is done or to clean up its resources.

The code in Listing 2 is the new version of both the caller function and the coroutine.

Task coro_func(){
  co_yield "Hello world from the coroutine";
  co_return;
}
int main(){
  auto task = coro_func();
  std::print("task.get() = {}", task.get());
  return 0;
}
Listing 2

The coroutine yields and sends some data to the caller. The caller reads that data and prints it. When the compiler reads the co_yield expression, it will generate a call to the yield_value function defined in the promise_type. Thus, we add the code in Listing 3 to the promise_type .

struct Task{
  struct promise_type{
    std::string output_data;
    /* ... */
    std::suspend_always yield_value(
         std::string msg) noexcept{
      output_data = std::move(msg);
    }
  };
  explicit Task(promise_type&){
    std::println("Task(promise_type&)");
  }
  ~Task() noexcept{
    std::println("~Task()");
  }
};
Listing 3

The function gets a std::string object and moves it to the output_data member variable of the promise type. But, this just keeps the data inside the promise_type. We still need a mechanism to get that data out of the coroutine.

The coroutine handle

Once we acquire a communication channel to and from a coroutine, we need a way to refer to a suspended or executing coroutine. The mechanism to refer to the coroutine object is through a pointer or handle called a coroutine handle. The C++ library header file <coroutine> defines a type std::coroutine_handle to work with coroutine handles.

Two functions are of interest to us in the std::coroutine_handle interface : resume() and destroy().

  struct coroutine_handle<promise_type>{
    /* ... */
    void resume() const;
    void destroy() const;
    promise_type& promise() const;
    static coroutine_handle from_promise(
      promise_type&);
  }

What resume() does is simple, it resumes the suspended coroutine. It continues execution.

If we think of this coroutine frame or object living somewhere on the heap, where all of the state of execution is stored, one way to destroy this state is to let the coroutine run to completion. But, far more commonly, we would like to manage the lifetime externally and we can then just call the destroy() function which will get rid of the coroutine state.

Note that, the coroutine_handle is not a smart pointer type. So, you have to call the destroy() explicitly.

There are two more functions: .promise() and .from_promise(). These are used to convert from a coroutine to a promise object and vice versa.

We add the functionality in Listing 4 to our return type to manage the coroutine handle.

struct Task{
  struct promise_type{
    std::string output_data;
    /* ... */
    std::suspend_always yield_value(
         std::string msg) noexcept{
      output_data = std::move(msg);
    }
  };
  // Coroutine handle member-variable
  std::coroutine_handle<promise_type> handle{};
  explicit Task(promise_type& promise)
  : handle { std::coroutine_handle<promise_type>
             ::from_promise(promise) }
  {
    std::println("Task(promise_type&)");
  }
  // Destructor
  ~Task() noexcept{
    std::println("~Task()");
    if(handle)
      handle.destroy();
  }
};
Listing 4

The preceding code declares a coroutine handle of type std::coroutine_handle<promise_type> and creates the handle in the return type constructor. The handle is destroyed in the return type destructor.

Now, back to our yielding coroutine. The only missing bit is a get() function for the caller to be able to extract the resultant string out of the promise.

  std::string get(){
    if(!handle.done()){
        handle.resume();
    }
    return std::move(handle.promise().output_data);
  }

The get() function resumes the coroutine if it has not terminated and returns the result stored in the output_data member variable of the promise. The full source code listing is shown in Listing 5, and the output in Figure 4.

#include <coroutine>
#include <print>
#include <iostream>
#include <string>
using namespace std::string_literals;
struct Task{
  struct promise_type{
    std::string output_data{};
    Task get_return_object(){
      std::println("get_return_object()");
      return Task{ *this };
    }
    void return_void() noexcept {
      std::println("return_void()");
    }
    void unhandled_exception() noexcept {
      std::println("unhandled_exception()");
    }
    std::suspend_always initial_suspend()
         noexcept{
      std::println("initial_suspend()");
      return {};
    }
    std::suspend_always final_suspend() noexcept{
      std::println("final_suspend()");
      return {};
    }
    std::suspend_always yield_value(
         std::string msg) noexcept{
      std::println("yield_value(std::string)");
      output_data = std::move(msg);
      return {};
    }
  };
  // Coroutine handle member-variable
  std::coroutine_handle<promise_type> handle{};
  explicit Task(promise_type& promise)
  : handle { std::coroutine_handle<promise_type>
    ::from_promise(promise) }
  {
    std::println("Task(promise_type&)");
  }
  ~Task() noexcept{
    std::println("~Task()");
    if(handle)
      handle.destroy();
  }
  std::string get(){
    std::println("get()");
    if(!handle.done())
      handle.resume();
    return std::move(handle.promise()
      .output_data);
  }
};
Task coro_func(){
  co_yield "Hello world from the coroutine";
  co_return;
}
int main(){
  auto task = coro_func();
  std::cout << task.get() << std::endl;
}
Listing 5
get_return_object()
Task(promise_type&)
initial_suspend()
get()
yield_value(std::string)
Hello world from the coroutine
~Task()
Figure 4

The output shows what is happening during the coroutine execution. The Task object is created after a call to get_return_object. The coroutine is initially suspended. The caller wants to get the message from the coroutine so get() is called, which resumes the coroutine. When the compiler sees the co_yield statement in the coroutine, it generates an implicit called to yield_value(std::string). yield_value is called and the message is copied to the resultant member variable output_data in the promise. Finally, the message is printed by the caller function, and the coroutine returns.

A waiting coroutine

We are now going to implement a coroutine that can wait for the input data sent by the caller. In our example, the coroutine will wait until it gets a std::string object and then print it. We say that the coroutine waits, we mean it is suspended (that is, not executed) until the data is received.

We start with changes to both the coroutine and the caller function:

  Task coro_func(){
    std::cout << co_await std::string{};
    co_return;
  }
  int main(){
    auto task = coro_func();
    task.put("To boldly go where no man has gone" 
      "before");
    return 0;
  }

In the preceding code, the caller function calls the put() function(a method in the return type structure) and the coroutine calls co_await to wait for a std::string object from the caller.

The changes to the return type are simple, that is, just adding the put() function.

  void put(std::string msg){
    handle.promise().input_data = std::move(msg);
    if(!handle.done()){
      handle.resume();
    }
  }

We need to add the input_data variable to the promise structure. But, just with those changes to our first example and the coroutine handle from the previous example, the code cannot be compiled. (See Listing 6.)

#include <print>
#include <string>
#include <coroutine>
#include <iostream>
struct Task{
  struct promise_type{
    std::string input_data{};
    Task get_return_object() noexcept{
      std::println("get_return_object");
      return Task{ *this };
    } 
    void return_void() noexcept{
      std::println("return_void");
    }
    std::suspend_always initial_suspend() 
        noexcept{
      std::println("initial_suspend");
      return {};
    }
    std::suspend_always final_suspend() noexcept{
      std::println("final_suspend");
      return {};
    }
    void unhandled_exception() noexcept{
        std::println("unhandled_exception");
    }
    std::suspend_always yield_value(
        std::string msg) noexcept{
      std::println("yield_value");
      return {};
    }
  };
  std::coroutine_handle<promise_type> handle{};
  explicit Task(promise_type& promise)
  : handle{ std::coroutine_handle<promise_type>
    ::from_promise(promise)}
  {
    std::println("Task(promise_type&) ctor");
  }
  ~Task() noexcept{
    if(handle)
      handle.destroy();
    std::println("~Task()");
  }
  void put(std::string msg){
    handle.promise().input_data = std::move(msg);
    if(!handle.done())
      handle.resume();
  }
};
Task coro_func(){
  std::cout << co_await std::string{};
  co_return;
}
int main(){
  auto task = coro_func();
  task.put("To boldly go where no man has gone "
           "before");
  return 0;
}
Listing 6

The compiler gives us the error in Figure 5.

<source>: In function 'Task coro_func()':
<source>:62:18: error: no member named 'await_ready' 
in 'std::string' {aka 'std::__cxx11::basic_string<char>'}
   62 |     std::cout << co_await std::string{};
      |                  ^~~~~~~~
Figure 5

Let’s explore more about what this error message means.

What is an awaitable?

An awaitable is any object, I can call co_await on. You can think of co_await like an operator, and its argument as an awaitable. The way to think about the operator co_await is that these are opportunities for suspension. These are the points where the coroutine can be paused. Similar to, how the promise_type provides hooks to control what happens at startup or when you return from the coroutine, the awaitable provides these hooks for what happens when we go into suspension.

  struct Awaitable{
    bool await_ready();
    void await_suspend(
      std::coroutine_handle<promise_type>);
    void await_resume(
      std::coroutine_handle<promise_type>);
  };

The first function is .await_ready() which returns a bool. This determines whether we do actually go into suspension or we just say, yeah, we are ready, and we don’t want to go into suspension, we want to continue execution and in that case we just return true.

The next function is .await_suspend() and that is the customization point that will get executed shortly before the coroutine function goes to sleep.

The next function is .await_resume() and that is the customization point that will get executed just after the coroutine is resumed.

The code in Listing 7 is our implementation of the await_transform function and the Awaitable struct:

auto await_transform(std::string) noexcept{
  struct Awaitable{
    promise_type& promise;
    bool await_ready() const noexcept{
      return true; // Says, yeah we are ready, we 
              // don’t need to sleep. Just go on.
    }
    std::string await_resume() const noexcept{
      return std::move(promise.input_data);
    }
    void await_suspend(
      std::coroutine_handle<promise_type>) const
      noexcept{}
  };
  return Awaitable(*this);
}
Listing 7

Listing 8 is the code for the full example of the waiting coroutine and Figure 6 is the output.

#include <print>
#include <string>
#include <coroutine>
#include <iostream>
struct Task{
  struct promise_type{
    std::string input_data{};
    Task get_return_object() noexcept{
      std::println("get_return_object");
      return Task{ *this };
    } 
    void return_void() noexcept{
      std::println("return_void");
    }
    std::suspend_always initial_suspend()
    noexcept{
      std::println("initial_suspend");
      return {};
    }
    std::suspend_always final_suspend() noexcept{
      std::println("final_suspend");
      return {};
    }
    void unhandled_exception() noexcept{
      std::println("unhandled_exception");
    }
    std::suspend_always yield_value(
         std::string msg) noexcept{
      std::println("yield_value");
      //output_data = std::move(msg);
      return {};
    }
    auto await_transform(std::string) noexcept{
      struct Awaitable{
        promise_type& promise;
        bool await_ready() const noexcept{
          return true; // Says, yeah we are ready
           // we don’t need to sleep. Just go on.
        }
        std::string await_resume() const
        noexcept{
          return std::move(promise.input_data);
        }
        void await_suspend(std::coroutine_handle<
          promise_type>) const noexcept{}
      };
      return Awaitable(*this);
    }
  };
  std::coroutine_handle<promise_type> handle{};
  explicit Task(promise_type& promise)
  : handle{ std::coroutine_handle<promise_type>
    ::from_promise(promise)}
  {
    std::println("Task(promise_type&) ctor");
  }
  ~Task() noexcept{
    if(handle)
      handle.destroy();
    std::println("~Task()");
  }
  void put(std::string msg){
    handle.promise().input_data = std::move(msg);
    if(!handle.done())
      handle.resume();
  }
};
Task coro_func(){
  std::cout << co_await std::string{};
  co_return;
}
int main(){
  auto task = coro_func();
  task.put("To boldly go where no man has gone "
           "before");
  return 0;
}
Listing 8
get_return_object
Task(promise_type&) ctor
initial_suspend
To boldly go where no man has gone before
return_void
final_suspend
~Task()
Figure 6

Coroutine generators

A generator is a coroutine that generates a sequence of elements by repeatedly resuming itself from the point that it was suspended.

A generator can be seen as an infinite list, because it can generate an arbitrary number of elements.

Implementing even the most basic coroutine in C++ requires a certain amount of code. C++23 introduced the std::generator template class. I present – in Listing 9 – the source code for a simple FibonacciGenerator.

#include <print>
#include <generator>
std::generator<int> makeFibonacciGenerator(){
  int i1{0};
  int i2{1};
  while(true){
    co_yield i1;
    i1 = std::exchange(i2, i1 + i2);
  }
  co_return;
}
int main(){
  auto fibo_gen = makeFibonacciGenerator();
  std::println("The first 10 numbers the "
               "Fibonacci sequence are : ");
    int i{0};
    for(auto f = fibo_gen.begin(); 
        f!=fibo_gen.end();++f){
      if(i  == 10)
        break;
      std::println("F[{}] = {}", i, *f);
      ++i;
  }
  return 0;
}
Listing 9

Conclusion

We have implemented a few simple coroutines to explain the basic mechanics of low-level C++ coroutines. We learned how to implement generators. I think coroutines are important because they allow better resource utilization, reduce waiting time, and improve the scalability of applications.

Quasar Chunawala has a bachelor’s degree in Computer Science. He is a software programmer turned quant engineer, enjoys building things ground-up and is deeply passionate about programming in C++, Rust and concurrency and performance-related topics. He is a long-distance hiker and his favorite trekking routes are the Goechala route in Sikkim, India and the Tour-Du-Mont Blanc (TMB) circuit in the French Alps.






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.