In this post, we will explore coroutines, their applications, how to implement them, provide illustrations, and examine the results.
What are Coroutines?
Coroutines in C++ represent a form of control mechanism that enables the flow of control to switch between routines seamlessly. The introduction of the C++ coroutine feature in the C++20 version brings forth a functionality where a method can pause its execution and later resume it. These coroutines find application at different stages of development, resembling the sequential execution of code without concurrency. Due to the stackless nature of C++ coroutines, the function returns the result to the invoking entity, maintaining the coroutine's state within the pre-allocated isolated region or stack.
Why are C++ Coroutines used?
You have the option to process a record line by line or extract essential details for analysis. Another approach is to handle extensive data by loading it all into memory. Nevertheless, this method is not recommended for software applications that regularly work with substantial text files like Microsoft Word and text editors.
Donald Knuth proposed a resolution to this problem in software development to tackle it. As per Knuth's suggestion, we can eliminate the stack concept entirely. There is no requirement for the caller or callee to navigate through any procedures. Instead, consider them as collaborating peers.
Coroutine implementation in C++
The implementation of C++ coroutines requires satisfying the following two criteria:
- Continuing execution from where it left off
- Generating lasting data through function calls
Static variables offer a solution to the problem highlighted earlier. However, it is important to note that the state also reverts to its previous execution state, including the lines of code after the return or loop. In such cases, GOTO statements come into play. Below, we will examine the following code snippet.
int run(void)
{
int i;
for (i = 0; i< 10; i++)
return i;
}
Let's consider an example to illustrate the utilization of coroutines in C++.
Code:
#include <iostream>
using namespace std;
int range(int a, int b)
{
static long long int i;
static int state = 0;
switch (state) {
case 0:
state = 1;
for (i = a; i< b; i++) {
return i;
case 1:
cout<< "control at range"
<<endl;
}
}
state = 0;
return 0;
}
int main()
{
int i;
for (; i = range(1, 5);)
cout<< "control at main :" <<i<<endl;
return 0;
}
Output:
control at main :1
control at range
control at main :2
control at range
control at main :3
control at range
control at main :4
control at range
Explanation:
In this instance, various return scenarios are specified within the for loop. Each iteration transitions to a distinct state, running the program based on its configuration. This approach emulates the behavior of the range function in Python, which is built on the underlying C++ coroutine concept.
How do Coroutines operate in C++?
Coroutines in C++ operate as detailed below.
When the initial execution of a C++ coroutine is completed, it remains possible to resume it at a later time. The primary invocation typically occurs via C++ coroutines, following which the relevant data is stored in a different location. Coroutines essentially serve as functions, requiring an association with their respective data types to operate effectively.
C++ coroutines are highly effective due to their integration with variable arguments and return statements. However, there are restrictions that prevent flawless coroutine creation within main functions, constant expression functions, constructors, and destructors.
Execution paradigm using a coroutine:
Two components are associated with a C++ coroutine. The initial element is the pledge object, while the coroutine object is the subsequent one. A pledge object serves as a synchronization marker and holds a value accessible by any future entity (typically another thread). Moreover, a heap is employed for managing the coroutine's state efficiently once it becomes accessible. The following attributes pertain to the state entity:
Options for parameters.
- The local variables and the temporary variables have a limited time stamp and scope up until the current suspension point.
- Promise manipulable items therein.
- We represent the appropriate state and value of the local variables to know where to resume and continue the execution.
Handles for coroutines:
We utilize coroutine handles when we want to develop and run code outside of the coroutine's context. The handlers for non-usage of coroutines are used to restart the coroutine's operation and remove the C++ coroutines from the context. A C++ coroutine handle is similar to a C pointer, allowing easy copying, but lacks a destructor to release the allocated memory. Hence, we need to use the coroutine_handle::destroy method to end the coroutine and prevent any memory leaks. When the coroutine is destroyed, the coroutine handler pointing to it is also eliminated. Consequently, if the coroutine handler is invoked after the coroutine has been deleted, it will no longer work as expected.
standard return object:
The C++ coroutine yields an object with an embedded type::promisetype as its output. It should contain a function named getreturnobject that produces an external object of type r within r::promisetype. The coroutine function is derived from the getreturnobject operation.
The promise object:
A promise type object is present within the coroutine's state. To pass values from the coroutine to the main function, we introduce a new field named "value". The coroutine handle remains as std::coroutinehandleReturnObject3::promisetype> and is not changed to std::coroutine_handle>.
The operator co_yield:
The co_yield keyword sends back the yielded value from the expression to the invoking function. This feature is widely used in resumable generator functions as it pauses the execution of the coroutine.
The operator co_return:
A coroutine comes to a close when the co_return operator is used. Signalling can be done in one of three ways.
- The value e can be returned using Co_return e .
- The coroutine can utilise the coreturn operator to indicate the end of a coroutine without a final value.
- Similar to the preceding point, we may use the co_return operator to fall off the end of the function.
Things to Keep in Mind When Using Coroutines:
We should never forget the following are some key considerations for using C++ coroutines.
- The sole operator available to C++ coroutines is co_return ; return is not supported.
- Both the use of varargs and the use of a constexpr are prohibited.
- Neither a constructor nor a destructor are allowed.
- The main function cannot also contain the coroutines.
- We should use parameters by value when utilising C++ coroutines to be on the safe side and prevent dangling references.
Reference parameters and Coroutines:
Utilizing C++ coroutines ensures that only local variables are saved when the coroutine is paused, rather than the entire call stack. Stackful coroutines represent a specific approach to implementing coroutines, whereas stackless coroutines handle the saving and restoration of the complete call stack. Like any other asynchronous parameter, a reference parameter for a coroutine mandates that the calling object must provide a reference parameter. This process guarantees the longevity of the associated object throughout the object's lifecycle.
Code:
#include <iostream>
#include <vector>
std::vector<int>get_the_greedy_Num(int start, int last, int inc = 1) {
std::vector<int>greedy_nums;
for (int j_0 = start; j_0 < last; j_0 += inc) {
greedy_nums.push_back(j_0);
}
return greedy_nums;
}
int main() {
std::cout<< std::endl;
const auto greedy_nums = get_the_greedy_Num(-4, 15);
for (auto n :greedy_nums)
std::cout<< n << " ";
std::cout<< "\n\n";
for (auto n :get_the_greedy_Num(1, 123, 5))
std::cout<< n << " ";
std::cout<< "\n\n";
return 0;
}
Output:
-4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
1 6 11 16 21 26 31 36 41 46 51 56 61 66 71 76 81 86 91 96 101 106 111 116 121
Example 2:
In this instance, the results obtained from the output were captured utilizing C++ coroutines.
Code:
#include <coroutine>
#include <iostream>
#include <memory>
template<typename T_0>
struct date_future {
std::shared_ptr<T_0> value;
date_future(std::shared_ptr<T_0> p_1) : value(p_1) {}
~date_future() {}
T_0 get() {
return *value;
}
struct promise_type {
std::shared_ptr<T_0>ptr = std::make_shared<T_0>();
~promise_type() {}
date_future<T_0>get_return_object() {
return ptr;
}
void return_value(T_0 k) {
*ptr = k;
}
std::suspend_neverinitial_suspend() {
return {};
}
std::suspend_neverfinal_suspend() noexcept {
return {};
}
void unhandled_exception() {
std::exit(1);
}
};
};
date_future<int> createFuture_0(int year) {
co_return year;
}
int main() {
std::cout<< '\n';
auto fut_1 = createFuture_0(2022);
std::cout<< "fut_1.get(): " << fut_1.get() << '\n';
std::cout<< '\n';
return 0;
}
Output:
fut_1.get(): 2022
Conclusion
- Coroutines in C++ are a type of control structure where the control flow is transmitted from one routine to another without stopping.
- After that, the C++ coroutine was introduced starting, with C++11.
- The stack-based operation used by the C++coroutine.
- Even after the primary execution is finished, the C++ coroutines are functions that can be continued later.
- The promise object and the coroutine object are two things connected to C++ coroutines.
- The coroutine handle is used to halt or pause the coroutine that is running right now.