Preparándose para C ++ 20. Estudio de caso de Coroutines TS Real

En C ++ 20, la oportunidad de trabajar con corutinas fuera de la caja está a punto de aparecer. Este tema es cercano e interesante para nosotros en Yandex.Taxi (para nuestras propias necesidades, estamos desarrollando un marco asincrónico). Por lo tanto, hoy mostraremos a los lectores de Habr usando un ejemplo real de cómo trabajar con las rutinas sin pila de C ++.

Como ejemplo, tomemos algo simple: sin trabajar con interfaces de red asíncronas, temporizadores asíncronos, que consisten en una función. Por ejemplo, intentemos darnos cuenta y reescribir este "fideo" de las devoluciones de llamada:


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(); } }); } 


Introduccion


Las corutinas o corutinas son la capacidad de detener la ejecución de una función en una ubicación predeterminada; pasar a alguna parte el estado completo de la función detenida junto con las variables locales; ejecuta la función desde el mismo lugar donde la detuvimos.
Hay varios sabores de corutinas: sin pila y apiladas. Hablaremos de esto más tarde.

Declaración del problema.


Tenemos varias colas de tareas. Cada tarea contiene ciertas tareas: hay una cola para dibujar gráficos, hay una cola para interacciones de red y hay una cola para trabajar con un disco. Todas las colas son instancias de la clase WorkQueue que tienen el método void PushTask (std :: function <void ()> task);. Las colas viven más tiempo que todas las tareas colocadas en ellas (la situación en la que destruimos una cola cuando hay tareas pendientes no debería suceder).

La función FuncToDealWith () del ejemplo ejecuta cierta lógica en diferentes colas y, dependiendo de los resultados de la ejecución, coloca una nueva tarea en la cola.

Reescribimos los "fideos" de las devoluciones de llamada en forma de un seudocódigo lineal, marcando en qué cola se debe ejecutar el código subyacente:

 void CoroToDealWith() { InCurrentThread(); // =>   writerQueue InWriterThread1(); if (NeedNetwork()) { // =>   networkQueue auto v = InNetworkThread(); if (v) { // =>   UIQueue InUIThread(); } } // =>   writerQueue InWriterThread2(); ShutdownAll(); } 

Aproximadamente este resultado que quiero lograr.

Hay limitaciones:

  • Las interfaces de cola no se pueden cambiar: son utilizadas en otras partes de la aplicación por desarrolladores externos. No puede descifrar el código de desarrollador ni agregar nuevas instancias de cola.
  • No puede cambiar la forma en que utiliza la función FuncToDealWith. Solo puede cambiar su nombre, pero no puede hacer que devuelva ningún objeto que el usuario deba guardar en casa.
  • El código resultante debe ser tan productivo como el original (o incluso más productivo).

Solución


Reescribir la función FuncToDealWith


En Coroutines TS, el ajuste de la rutina se realiza configurando el tipo del valor de retorno de la función. Si el tipo cumple ciertos requisitos, entonces dentro del cuerpo de la función puede usar las nuevas palabras clave co_await / co_return / co_yield. En este ejemplo, para cambiar entre colas, usaremos 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(); } 

Resultó muy similar al pseudocódigo de la última sección. Toda la "magia" para trabajar con corutinas está oculta en la clase CoroTask.

CoroTask


En el caso más simple (en nuestro caso), los contenidos de la clase "sintonizador" de la rutina consisten en un solo alias:

 #include <experimental/coroutine> struct CoroTask { using promise_type = PromiseType; }; 


promise_type es un tipo de datos que debemos escribir nosotros mismos. Contiene lógica que describe:

  • qué hacer al salir de la rutina
  • qué hacer cuando ingresas por primera vez
  • quien libera recursos
  • qué hacer con las excepciones que salen de la rutina
  • Cómo crear un objeto CoroTask
  • qué hacer si dentro de corutinas se llama co_yield

Alias ​​promise_type debe llamarse de esa manera. Si cambia el nombre del alias a otra cosa, el compilador jurará y dirá que deletreó CoroTask incorrectamente. El nombre CoroTask se puede cambiar como desee.

Pero, ¿por qué es necesario este CoroTask si todo se describe en promise_type?
En casos más complejos, puede crear CoroTask que le permitirá comunicarse con una rutina interrumpida, enviar y recibir datos de ella, despertarla y destruirla.

PromiseType


Llegando a la parte divertida. Describimos el comportamiento de la corutina:

 class WorkQueue; // forward declaration class PromiseType { public: //      `co_return;`     , ... void return_void() const { /* ...    :) */ } //        ,  CoroTask, ... auto initial_suspend() const { // ...       . return std::experimental::suspend_never{}; } //      - , ... auto final_suspend() const { // ...        //      . return std::experimental::suspend_never{}; } //     , ... void unhandled_exception() const { // ...   (  ). std::terminate(); } //    CoroTask,    , ... auto get_return_object() const { // ...  CoroTask. return CoroTask{}; } //     co_yield, ... auto yield_value(WorkQueue& wq) const; // ... <  > }; 

En el código anterior, puede observar el tipo de datos std :: experimental :: suspend_never. Este es un tipo de datos especial que dice que no es necesario detener la corutina. También existe su opuesto: el tipo std :: experimental :: suspend_always, que le indica que pare la corutina. Estos tipos son los llamados esperables. Si está interesado en su estructura interna, no se preocupe, escribiremos nuestros Awaitables pronto.

El lugar más trivial en el código anterior es final_suspend (). La función tiene efectos inesperados. Entonces, si no detenemos la ejecución en esta función, los recursos asignados a la rutina por el compilador nos limpiarán el compilador. Pero si en esta función detenemos la ejecución de la rutina (por ejemplo, devolviendo std :: experimental :: suspend_always {}), entonces deberá liberar manualmente los recursos de algún lugar externo: deberá guardar un puntero inteligente para la rutina en alguna parte y llamarlo explícitamente destruir (). Afortunadamente, esto no es necesario para nuestro ejemplo.

PromiseType INCORRECTO :: yield_value


Parece que escribir PromiseType :: yield_value es bastante simple. Tenemos una linea; corutina, que debe suspenderse y en este turno poner:

 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); //     . return std::experimental::suspend_always{}; } 

Y aquí nos enfrentamos a un problema muy grande y difícil de detectar. El hecho es que primero colocamos la rutina en la cola y solo luego la suspendemos. Puede suceder que la rutina se elimine de la cola y comience a ejecutarse incluso antes de suspenderla en el hilo actual. Esto conducirá a una condición de carrera, comportamiento indefinido y errores de tiempo de ejecución completamente locos.

PromiseType correcto :: yield_value


Entonces, primero debemos detener la corutina y solo luego agregarla a la cola. Para hacer esto, escribiremos nuestro Awaitable y lo llamaremos 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}; } 

Las clases std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution y otros Awaitables deben contener 3 funciones. Se llama a await_ready para verificar si se debe detener la rutina. await_suspend se llama después de que se detiene el programa, se le pasa el identificador de la rutina detenida. Se llama a await_resume cuando se reanuda la ejecución de rutina.
¿Y qué se puede escribir en skrabs triangulares std :: experimental :: coroutine_handle <>?
Puede especificar el tipo PromiseType allí, y el ejemplo funcionará exactamente igual :)

std :: experimental :: coroutine_handle <> (también conocido como std :: experimental :: coroutine_handle <void>) es el tipo base para todo std :: experimental :: coroutine_handle <DataType>, donde DataType debe ser el tipo de promesa de la rutina actual. Si no necesita acceder al contenido interno de DataType, puede escribir std :: experimental :: coroutine_handle <>. Esto puede ser útil en lugares donde desea abstraerse de un tipo particular de promise_type y usar el borrado de tipo.

Hecho


Puede compilar, ejecutar el ejemplo en línea y experimentar de todas las formas .

Y si no me gusta el co_yield, ¿puedo reemplazarlo por algo?
Se puede reemplazar con co_await. Para hacer esto, agregue la siguiente función a PromiseType:

 auto await_transform(WorkQueue& wq) { return yield_value(wq); } 

¿Pero qué pasa si no me gusta co_await?
La cosa es mala. Nada que cambiar


Hoja de trucos


CoroTask es una clase que ajusta el comportamiento de una rutina. En casos más complejos, le permite comunicarse con una corutina detenida y tomar datos de ella.

CoroTask :: promise_type describe cómo y cuándo se detienen las rutinas, cómo liberar recursos y cómo construir CoroTask.

Awaitables (std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution y otros) le dicen al compilador qué hacer con la rutina en un punto específico (si detener la corutina, qué hacer con la corutina parada y qué hacer cuando la corutina se despierta) .

Optimizaciones


Hay un defecto en nuestro PromiseType. Incluso si actualmente estamos ejecutando la cola de tareas correcta, llamar a co_yield suspenderá la rutina y la reubicará en la misma cola de tareas. Sería mucho más óptimo no detener la ejecución de la rutina, sino continuar de inmediato.

Arreglemos este defecto. Para hacer esto, agregue un campo privado a PromiseType:

 WorkQueue* current_queue_ = nullptr; 

En él, mantendremos un puntero a la cola en la que estamos ejecutando actualmente.

A continuación, modifique 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}; } 

Aquí modificamos schedule_for_execution :: await_ready (). Ahora esta función le dice al compilador que no es necesario suspender la rutina si la cola de tareas actual coincide con la que estamos tratando de comenzar.

Listo Puedes experimentar en todos los sentidos .

Sobre el rendimiento


En el ejemplo original, con cada llamada a WorkQueue :: PushTask (std :: function <void ()> f), creamos una instancia de la clase std :: function <void ()> desde la lambda. En el código real, estas lambdas suelen ser bastante grandes, por lo que std :: function <void ()> se ve obligado a asignar dinámicamente memoria para almacenar lambdas.

En el ejemplo de la rutina, creamos instancias de std :: function <void ()> desde std :: experimental :: coroutine_handle <>. El tamaño de std :: experimental :: coroutine_handle <> depende de la implementación, pero la mayoría de las implementaciones intentan mantener su tamaño al mínimo. Entonces, al sonar su tamaño es igual a sizeof (nulo *). Al construir std :: function <void ()>, la asignación dinámica no se produce a partir de objetos pequeños.
Total: con Coroutines nos deshicimos de varias asignaciones dinámicas innecesarias.

Pero! El compilador a menudo no puede simplemente guardar toda la rutina en la pila. Debido a esto, es posible una asignación dinámica adicional al ingresar CoroToDealWith.

Stackless vs stackful


Acabamos de trabajar con las rutinas apiladas, que requieren el soporte del compilador para trabajar. También hay Coroutines apilables que se pueden implementar completamente a nivel de biblioteca.

Los primeros permiten una asignación más económica de la memoria, potencialmente están mejor optimizados por el compilador. Los segundos son más fáciles de implementar en proyectos existentes, ya que requieren menos modificaciones de código. Sin embargo, en este ejemplo no puede sentir la diferencia, se necesitan ejemplos más complicados.

Resumen


Examinamos el ejemplo básico y obtuvimos una clase universal CoroTask, que se puede usar para crear otras rutinas.

El código con él se vuelve más legible y ligeramente más productivo que con el enfoque ingenuo:
EraCon corutinas
 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(); } 

Por la borda hubo momentos:

  • cómo llamar a otra corutina desde la corutina y esperar a que se complete
  • qué cosas útiles puedes meter en CoroTask
  • Un ejemplo que marca la diferencia entre Stackless y Stackful

Otros


Si desea conocer otras novedades del lenguaje C ++ o comunicarse personalmente con sus colegas sobre las ventajas, mire la conferencia C ++ Rusia. El próximo se llevará a cabo el 6 de octubre en Nizhny Novgorod .

Si tiene dolor asociado con C ++ y quiere mejorar algo en el lenguaje o simplemente quiere discutir posibles innovaciones, bienvenido a https://stdcpp.ru/ .

Bueno, si te sorprende que Yandex.Taxi tenga una gran cantidad de tareas que no están relacionadas con los gráficos, entonces espero que esto sea una sorpresa agradable para ti :) Ven a visitarnos el 11 de octubre , hablaremos sobre C ++ y más.

Source: https://habr.com/ru/post/es420861/


All Articles