In C ++ 20, it is about to be possible to work with the cortices out of the box. This topic is close and interesting to us at Yandex.Taxi (we develop an asynchronous framework for our own needs). Therefore, today we are using a real example to show Habr's readers how to work with C ++ stackless cortutins.
As an example, let's take something simple: without working with asynchronous network interfaces, asynchronous timers, consisting of one function. For example, we will try to realize and rewrite just such a “noodle” from Kolbeks:

void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto finally = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetworkThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(finally); }); } else { writerQueue.PushTask(finally); } }); } else { finally(); } }); }
Introduction
Qorutin or coroutine is the ability to stop the function in a predetermined place; pass somewhere the entire state of the stopped function along with local variables; start the function from the same place where we stopped it.
There are several varieties of coroutines: stackless and stackful. We will talk about this later.
')
Formulation of the problem
We have several task queues. Certain tasks are placed in each queue: there is a queue for drawing graphics, there is a queue for network interactions, there is a queue for working with the disk. All queues are WorkQueue instances that have a void PushTask method (std :: function <void ()> task) ;. Queues live longer than all tasks placed in them (the situation that we have destroyed a queue when there are unfulfilled tasks in it should not occur).
The FuncToDealWith () function from the example executes some logic in different queues and, depending on the execution results, puts a new task in the queue.
Let's rewrite the "noodles" of callbacks in the form of a linear pseudo-code, marking in which queue the underlying code should be executed:
void CoroToDealWith() { InCurrentThread();
Approximately this result and I want to achieve.
However, there are limitations:
- Queue interfaces cannot be changed - they are used in other parts of the application by third-party developers. Breaking developer code or adding new queue instances is not possible.
- You cannot change the way FuncToDealWith is used. You can only change her name, but you can not make her return any objects that the user must store.
- The resulting code must be as productive as the original (or even more productive).
Decision
Rewrite the function FuncToDealWith
In Coroutines TS, the adjustment of the coroutine is made by setting the type of the return value of the function. If the type meets certain requirements, then inside the function body, you can use the new keywords co_await / co_return / co_yield. In this example, to switch between queues we will use co_yield:
CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetworkThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); }
It turned out very similar to the pseudocode from the previous section. All the "magic" of working with Coroutines is hidden in the CoroTask class.
CoroTask
In the simplest (in our) case, the contents of the coroutine's “customizer” class consist of only one alias:
#include <experimental/coroutine> struct CoroTask { using promise_type = PromiseType; };
promise_type is the data type we have to write. It contains logic describing:
- what to do when exiting cortina
- what to do when you first enter the korutina
- who frees up resources
- how to deal with exceptions departing from korutiny
- how to create a CoroTask object
- what to do if inside the cortina called co_yield
Alias ​​promise_type must be called that way. If you change the name of the alias to something else, the compiler will swear and say that you have incorrectly written CoroTask. The name CoroTask can be changed as you like.
And why even this CoroTask, if everything is described in promise_type?In more complex cases, you can create such CoroTask, which will allow you to communicate with the stopped cororne, transmit and receive data from it, awaken and destroy it.
PromiseType
Getting to the most interesting. We describe the behavior of corutin:
class WorkQueue;
In the code above, you can see the data type std :: experimental :: suspend_never. This is a special data type that says you don’t need to stop corutin. There is also its opposite - the type std :: experimental :: suspend_always, which tells you to stop the quortenna. These types are the so-called Awaitables. If you are interested in their internal structure, then do not worry, we will soon write our Awaitables.
The most non-trivial place in the above code is final_suspend (). The function has unexpected effects. So, if in this function we
do not stop execution, then the resources allocated for the compiler compiler will clean up the compiler for us. But if in this function we stop the execution of the coroutine (for example, by returning std :: experimental :: suspend_always {}), then the release of resources will have to be done manually from somewhere outside: you will have to save a smart pointer to coroutine somewhere and explicitly call it destroy (). Fortunately, for our example it is not necessary.
INCORRECT PromiseType :: yield_value
It seems that writing PromiseType :: yield_value is quite simple. We have a queue; Korutina, which must be suspended and put in this turn:
auto PromiseType::yield_value(WorkQueue& wq) {
And here we are waiting for a very large and difficult to detect problem. The fact is that we first put the quail in a queue and only then suspend. It may happen that the quorutine is removed from the queue and will start to be executed before we suspend it in the current thread. This will lead to a race condition, unspecified behavior and absolutely insane runtime errors.
Correct PromiseType :: yield_value
So, we need to first stop the quartz and then add it to the queue. To do this, we will write our Awaitable and call it schedule_for_execution:
auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { WorkQueue& wq; constexpr bool await_ready() const noexcept { return false; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; return schedule_for_execution{wq}; }
The classes std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution and other Awaitables must contain 3 functions. await_ready is called to check if the coroutine should be stopped. await_suspend is called after the program is stopped, and the handle of the stopped korutina is transferred to it. await_resume is called when the execution of the corortina is resumed.
And what can you write in triangular scraps std :: experimental :: coroutine_handle <>?You can specify the type of PromiseType there, and the example will work exactly the same :)
std :: experimental :: coroutine_handle <> (also known as std :: experimental :: coroutine_handle <void>) is the base type for all std :: experimental :: coroutine_handle <Data Type>, where Data Type must be the promise_type of the current coroutine. If you don’t need to access the internal data type, you can write std :: experimental :: coroutine_handle <>. This can be useful in places where you want to abstract from a specific type of promise_type and use type erasure.
Is done
You can
compile, run the example online and experiment in every way .
And if I don’t like co_yield, can I replace it with something?Can be replaced by co_await. To do this, you need to add the following function to PromiseType:
auto await_transform(WorkQueue& wq) { return yield_value(wq); }
And if I don’t like co_await?The case is bad. Nothing can be changed.
Crib
CoroTask is a class that customizes the behavior of cortina. In more complex cases, it allows you to communicate with the stopped corutine and collect any data from it.
CoroTask :: promise_type describes how and when to stop Coroutin, how to free resources and how to construct CoroTask.
Awaitables (std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution, etc.) tell the compiler what to do with corutine at a particular point (do you need to stop corutin, what to do with stopped corutina and what to do when corutin is awakened) .
Optimization
Our PromiseType has a flaw. Even if we are currently running in the correct task queue, the call to co_yield will still suspend the quortenine and re-place it in the same task queue. It would be far better not to stop the execution of the coroutine, but immediately continue the execution.
Let's fix this flaw. To do this, add a private field to PromiseType:
WorkQueue* current_queue_ = nullptr;
In it, we will hold a pointer to the queue in which we are currently running.
Further we will correct PromiseType :: yield_value:
auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { const bool do_resume; WorkQueue& wq; constexpr bool await_ready() const noexcept { return do_resume; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; const bool do_not_suspend = (current_queue_ == &wq); current_queue_ = &wq; return schedule_for_execution{do_not_suspend, wq}; }
Here we have fixed schedule_for_execution :: await_ready (). Now this function informs the compiler that the coruntine does not need to be suspended if the current task queue is the same as the one on which we are trying to run.
Is done. You can
experiment in every way .
Pro performance
In the original example, each time we called WorkQueue :: PushTask (std :: function <void ()> f), we created an instance of the class std :: function <void ()> from the lambda. In real code, these lambdas are often quite large in size, which is why std :: function <void ()> is forced to dynamically allocate memory for storing lambdas.
In the corortine example, we create instances of std :: function <void ()> from std :: experimental :: coroutine_handle <>. The size of std :: experimental :: coroutine_handle <> depends on the implementation, but most implementations try to keep its size as small as possible. So on clang its size is equal to sizeof (void *). When constructing std :: function <void ()> from small objects, dynamic allocation does not occur.
Total - with korutinami we got rid of a few extra dynamic allocations.
But! The compiler often cannot simply keep all of the quandic on the stack. Because of this, one additional dynamic allocation is possible when entering CoroToDealWith.
Stackless vs Stackful
We have just worked with Stackless Korutin, which requires support from the compiler to work with. There are also Stackful Cortinas that can be implemented entirely at the library level.
The first ones allow you to allocate memory more economically, potentially they are better optimized by the compiler. The latter are easier to implement in existing projects, as they require fewer code modifications. However, in this example, the difference is not felt, the examples are more difficult.
Results
We looked at the basic example and got the universal class CoroTask, which can be used to create other coroutines.
The code with it becomes more readable and slightly more productive than with the naive approach:
It was | With korutinami |
---|
void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto fin = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(fin); }); } else { writerQueue.PushTask(fin); } }); } else { fin(); } }); } | CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } |
Moments left behind:
- how to call another korutina from korutina and wait for its completion
- that useful can be crammed in CoroTask
- an example of the difference between Stackless and Stackful
Other
If you want to learn about other C ++ language innovations or to talk personally with colleagues about advantages, take a look at the C ++ Russia conference. The nearest will be held
on October 6 in Nizhny Novgorod .
If you have pain associated with C ++, and you want to improve something in the language, or just want to discuss possible innovations, then welcome to
https://stdcpp.ru/ .
Well, if you are surprised that Yandex.Taxi has a huge number of tasks not related to graphs, then I hope that this turned out to be a pleasant surprise for you :) Come
visit us on October 11, let's talk about C ++ and not only.