Coroutines and C++20
Let’s discuss what coroutines are in general and how C++20 is introducing them
Table of Contents
1. Prerequisite Terminology
2. What are coroutines?
3. Coroutines vs Subroutines?
4. Coroutines vs Threads
5. Applications of coroutines
6. Example in Python
7. How to simulate coroutines in traditional C++
8. Coroutines in C++20
9. Restrictions
Prerequisite Terminology
- Cooperative Multitasking (a.k.a non-preemptive multitasking) — If multitasking participant process or thread voluntarily let go of control periodically or when idle or logically blocked. This type of multitasking is called “cooperative” because all programs must cooperate for the entire scheduling scheme to work.
- Subroutine — Any regular function that you write is a subroutine.
What are Coroutines?
Coroutines are stackless functions designed for enabling co-operative Multitasking, by allowing execution to be suspended and resumed.
Coroutines suspend execution by returning to the caller and the data that is required to resume execution is stored separately from the stack. This allows for sequential code that executes asynchronously (e.g. to handle non-blocking I/O without explicit callbacks), and also supports algorithms on lazy-computed infinite sequences and other uses.
This is why coroutines are well-suited for implementing familiar program components such as cooperative tasks, exceptions, event loops, state machines, and pipes.
Coroutines vs Subroutines?
- With subroutines, execution begins at the start and finished on exit.
- Subroutines are special cases of coroutines. Any subroutine can be translated to a coroutine which does not call ‘yield’ (relinquish control).
- Subroutines only return once and don't hold the complete state between invocations.
In contrast —
- Coroutines can exit by calling other coroutines, which may later return to the point where they were invoked in the original coroutine; from the coroutine’s point of view, it is actually not exiting but calling another coroutine.
- A coroutine instance holds state and varies between invocations.
Coroutines vs Threads
- Coroutines are designed to be performing as lightweight threads.
- Coroutines provide concurrency but not parallelism [Important!]
- Switching between coroutines need not involve any system/blocking calls so no need for synchronization primitives such as mutexes, semaphores.
Thus coroutines —
- provide asynchronicity and resource locking isn't needed.
- are useful in functional programming techniques.
- increase locality of reference.
Applications of Coroutines
- Actor Model: They are very useful to implement the actor model of concurrency. Each actor has its own procedures, but they give up control to the central scheduler, which executes them sequentially.
- Generators: It is useful to implement generators that are targeted for streams particularly input/output and for traversal of data structures.
- Reverse Communication: They are useful to implement reverse communication which is commonly used in mathematical software, wherein a procedure needs the using process to make a computation.
Example 1— To read a file and parse it while finding (matching) some meaningful data, you can either read step by step at each line, which is fine. You may also load the entire content in memory, which won’t be recommended for large text.
Coroutines are there to throw away the stack concept completely. Stop thinking of one process as the caller and the other as the callee, and start thinking of them as cooperating equals.
Example 2 — You have a consumer-producer relationship where one routine creates items and adds them to a queue and another removes items from the queue and uses them. For reasons of efficiency, you want to add and remove several items at once. The pseudo-code might look like this:
var q := new queuecoroutine produce
loop
while q is not full
create some new items
add the items to q
yield to consumecoroutine consume
loop
while q is not empty
remove some items from q
use the items
yield to produce
The queue is then completely filled or emptied before yielding control to the other coroutine using the yield command.
(This example is often used as an introduction to multithreading, two threads are not a must need for this).
An example in Python
If you have used Python, you may know that there is a keyword called yield
that allows loop back and forth between the caller and the called function until the caller is not done with function or the function terminates because of some logic it is given.
# A Python program to generate numbers in a range using yielddef rangeN(a, b):
i = a while (i < b):
yield i
i += 1 # Next execution resumes from this pointfor i in rangeN(1, 5):
print(i)// Output
1
2
3
4
5
How to simulate coroutines in traditional C++
To simulate coroutines in traditional C++ is challenging as for every response to a function call, there is a stack being initialized that keeps track of all its variables and constants and gets destroyed when the function call ends.
For the same range example, to simulate a simple switch coroutine suspend-resume we can do something like —
// A bad simulation of coroutine, no state saving
#include<iostream>int range(int a, int b)
{
static long long int i = a-1;
for (;i < b;)
{
return ++i;
}
return 0;
}int main()
{
int i; for (; i=range(1, 5);)
std::cout << i << '\n';
return 0;
}
However, this doesn’t hold good for coroutines criteria of saving/resuming from the saved state :(
// A better simulation of coroutine, state saving!!
#include<iostream>int range(int a, int b)
{
static long long int i;
static int state = 0; switch (state)
{
case 0: /* start of function */
state = 1; for (i = a; i < b; i++)
{
return i; /* Returns control */ case 1:; /* resume control straight after the return */ }
}
state = 0;
return 0;
}int main()
{
int i;for (; i=range(1, 5);)
std::cout << i << '\n';
return 0;
}
Coroutines in C++20
In c++20, coroutines are coming. A function is a coroutine if its definition does any of the following:
- uses the
co_await
operator to suspend execution until resumed. - uses the keyword
co_yield
to suspend execution returning a value. - uses the keyword
co_return
to complete execution.
Let’s take a similar example to get a range.
For the simplicity of this post, let’s assume a generator template is something that exists already and can be used to generate a range,
(This blog post from Microsoft https://docs.microsoft.com/en-us/archive/msdn-magazine/2017/october/c-from-algorithms-to-coroutines-in-c is amazing regarding the generator pattern details)
#include <iostream>
#include <vector>// Coroutine gets called on needgenerator<int> generateNumbers(int begin, int inc = 1) {
for (int i = begin;; i += inc) {
co_yield i;
}
}
int main() {
std::cout << std::endl;
const auto numbers= generateNumbers(-10);
for (int i= 1; i <= 20; ++i)
std::cout << numbers << " "; // Runs finite = 20 times
for (auto n: generateNumbers(0, 5)) // Runs infinite times
std::cout << n << " "; // (3)
std::cout << "\n\n";
}
We’ll cover more about coroutines later as it gets better documented and evolved.
Restrictions
Every coroutine in C++ has some restrictions noted below. So coroutines —
- Can’t return with variadic arguments
- Can’t return using plain “return”
- Can’t return placeholder (
auto
orConcept
) - Can’t be constexpr functions.
- Can’t be constructors or destructors.
- Can’t be the main function.
Thanks for reading this article! Feel free to leave your comments and let me know what you think. Please feel free to drop any comments to improve!!
Please check out my other articles and website, Have a great day!