Preparing for C ++ 20. Coroutines TS with a real example

    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 a single function. For example, we will try to realize and rewrite just such a “noodle” from Kolbek:


    voidFuncToDealWith(){
        InCurrentThread();
        writerQueue.PushTask([=]() {
            InWriterThread1();
            constauto 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 all the 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:

    voidCoroToDealWith(){
        InCurrentThread();
        // => перейти в writerQueue
        InWriterThread1();
        if (NeedNetwork()) {
            // => перейти в networkQueueauto v = InNetworkThread();
            if (v) {
                // => перейти в UIQueue
                InUIThread();
            }
        }
        // => перейти в writerQueue
        InWriterThread2();
        ShutdownAll();
    }

    Approximately this result and I want to achieve.

    However, there are limitations:

    • Queue interfaces can not be changed - they are used in other parts of the application by third-party developers. Breaking developer code or adding new queue instances is impossible.
    • You cannot change the way FuncToDealWith is used. You can only change her name, but you cannot make her return any objects that the user must store.
    • The resulting code must be as productive as the original (or even more efficient).

    Decision


    Rewrite the function FuncToDealWith


    In Coroutines TS, customization 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>structCoroTask {using promise_type = PromiseType;
    };


    promise_type is the data type we have to write. It contains logic describing:

    • what to do when leaving korutiny
    • 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?
    В более сложных случаях можно создавать такие CoroTask, которые будут вам позволять общаться с остановленной корутиной, передавать и получать из неё данные, пробуждать и уничтожать её.

    PromiseType


    Getting to the most interesting. Describe the behavior of corutin:

    classWorkQueue;// forward declarationclassPromiseType {public:
        // Когда выходим из корутины через `co_return;` или просто выходим из функции, то...voidreturn_void()const{ /* ... ничего не делаем :) */ }
        // Когда в самый первый раз заходим в функцию, возвращающую CoroTask, то...autoinitial_suspend()const{
            // ... говорим что останавливать выполнение корутины не нужно.returnstd::experimental::suspend_never{};
        }
        // Когда в корутина завершается и вот-вот уничтожится, то...autofinal_suspend()const{
            // ... говорим что останавливать выполнение корутины не нужно // и компилятор сам должен уничтожить корутину.returnstd::experimental::suspend_never{};
        }
        // Когда из корутины вылетает исключение, то...voidunhandled_exception()const{
            // ... прибиваем приложение (для простоты примера).std::terminate();
        }
        // Когда нужно создать CoroTask, для возврата из корутины, то...autoget_return_object()const{
            // ... создаём CoroTask.return CoroTask{};
        }
        // Когда в корутине вызвали co_yield, то...autoyield_value(WorkQueue& wq)const; // ... <смотрите описание ниже>
    };

    In the code above, you can see the data type std :: experimental :: suspend_never. This is a special data type that says that you do not need to stop the coruntine. There is also its opposite - the type std :: experimental :: suspend_always, which tells you to stop the quortenine. These types are the so-called Awaitables. If you are interested in their internal structure, then do not worry, we will soon write your 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 notIf we stop execution, the resources allocated for the compiler compiler will clean up the compiler for us. But if we stop the execution of coroutine in this function (for example, returning std :: experimental :: suspend_always {}), then we will have to manually release resources from outside: you 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) {
        // Получаем умный невладеющий указатель на нашу корутинуstd::experimental::coroutine_handle<> this_coro
            = std::experimental::coroutine_handle<>::from_promise(*this);
        // Отправляем его в очередь. У this_coro определён operator(), так что для// wq наша корутина будет казаться обычной функцией. Когда настанет время,// из очереди будет извлечена корутина, вызван operator(), который// возобновит выполнение сопрограммы.
        wq.PushTask(this_coro);
        // Говорим что сопрограмму надо остановить.returnstd::experimental::suspend_always{};
    }

    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 starts to run 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 name it schedule_for_execution:

    auto PromiseType::yield_value(WorkQueue& wq) {
        structschedule_for_execution {
            WorkQueue& wq;
            constexprboolawait_ready()constnoexcept{ returnfalse; }
            voidawait_suspend(std::experimental::coroutine_handle<> this_coro)const{
                wq.PushTask(this_coro);
            }
            constexprvoidawait_resume()constnoexcept{}
        };
        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 corutin is passed to it. await_resume is called when the execution of the coroutine is resumed.
    And what can you write in triangular scraps std :: experimental :: coroutine_handle <>?
    Можно указать там тип PromiseType, и пример будет работать абсолютно так же :)

    std::experimental::coroutine_handle<> (он же std::experimental::coroutine_handle<void>) является базовым типом для всех std::experimental::coroutine_handle<ТипДанных>, где ТипДанных должен быть promise_type текущей корутины. Если вам не нужно обращаться к внутреннему содержимому ТипДанных, то можно писать std::experimental::coroutine_handle<>. Это может быть полезно в тех местах, где вам хочется абстрагироваться от конкретного типа promise_type и использовать type erasure.

    Is done


    You can compile, run the sample online and experiment in every way .

    And if I don't like co_yield, can I replace it with something?
    Можно заменить на co_await. Для этого в PromiseType надо добавить вот такую функцию:

    autoawait_transform(WorkQueue& wq){ return yield_value(wq); }
    

    А а если мне и co_await не нравится?
    Дело плохо. Ничего не изменить.


    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) {
        structschedule_for_execution {constbool do_resume;
            WorkQueue& wq;
            constexprboolawait_ready()constnoexcept{ return do_resume; }
            voidawait_suspend(std::experimental::coroutine_handle<> this_coro)const{
                wq.PushTask(this_coro);
            }
            constexprvoidawait_resume()constnoexcept{}
        };
        constbool 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 paused 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 to a minimum. So on clang its size is equal to sizeof (void *). When constructing std :: function <void ()> from small objects, dynamic allocation does not occur.


    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 make it possible 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 a naive approach:
    It wasWith Corutin
    voidFuncToDealWith(){
      InCurrentThread();
      writerQueue.PushTask([=]() {
          InWriterThread1();
          constauto 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 into CoroTask
    • an example of the difference between Stackless and Stackful

    Other


    If you want to find out about other C ++ language innovations or to talk personally with colleagues about advantages, then 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.

    Only registered users can participate in the survey. Sign in , please.

    Want to hear more about the Korutin?


    Also popular now: