Más acerca de Coroutines en C ++

Hola colegas.

Como parte del estudio del tema C ++ 20, en un momento nos encontramos con un artículo bastante antiguo (septiembre de 2018) del hublog de Yandex, que se llama " Preparación para C ++ 20. Coroutines TS con un ejemplo real ". Termina con el siguiente voto muy expresivo:



"¿Por qué no?", Decidimos y tradujimos un artículo de David Pilarski bajo el título "Introducción de Coroutines". El artículo fue publicado hace poco más de un año, pero espero que sea muy interesante de todos modos.

Entonces sucedió. Después de muchas dudas, controversia y preparación de esta característica, WG21 llegó a una opinión común sobre cómo deberían verse las corutinas en C ++, y es muy probable que se incluyan en C ++ 20. Dado que esta es una característica importante, creo que es hora de prepararla y estudiarla ya. ahora (como recordará, todavía hay más módulos, conceptos, rangos para aprender ...)

Muchos aún se oponen a la rutina. A menudo se quejan de la complejidad de su desarrollo, muchos puntos de personalización y, posiblemente, un rendimiento subóptimo debido, posiblemente, a una asignación de memoria dinámica poco optimizada (quizás;)).

Paralelamente al desarrollo de especificaciones técnicas (TS) aprobadas (publicadas oficialmente), incluso se han hecho intentos para desarrollar paralelamente otro mecanismo de corutina. Aquí hablaremos sobre las corutinas que se describen en TS ( especificación técnica ). Un enfoque alternativo, a su vez, pertenece a Google. Como resultado, resultó que el enfoque de Google sufre numerosos problemas, cuya solución a menudo requiere características extrañas de C ++ adicionales.

Al final, se decidió adoptar una versión de Corutin desarrollada por Microsoft (patrocinada por TS). Se trata de tales corutinas que se discutirán en este artículo. Entonces, comencemos con la cuestión de ...

¿Qué son las corutinas?


Las corutinas ya existen en muchos lenguajes de programación, por ejemplo, en Python o C #. Las rutinas son otra forma de crear código asincrónico. La forma en que difieren de los flujos, por qué las corutinas deben implementarse como una función de lenguaje dedicada y, finalmente, cuál es su uso se explicará en esta sección.

Existe un grave malentendido sobre lo que son las corutinas. Dependiendo del entorno en el que se usan, se les puede llamar:

  • Corutinas apiladas
  • Pila de corutinas
  • Corrientes verdes
  • Fibras
  • Gorutins

La buena noticia: las corutinas apiladas, las corrientes verdes, las fibras y las gorutinas son la misma cosa (pero a veces se usan de diferentes maneras). Hablaremos de ellos más adelante en este artículo y los llamaremos fibras o corutinas de pila. Pero la rutina sin pila tiene algunas características que deben discutirse por separado.

Para comprender las rutinas, incluso en un nivel intuitivo, conozcamos brevemente las funciones y (digámoslo así) "su API". La forma estándar de trabajar con ellos es llamar y esperar hasta que termine:

void foo(){
     return; //     
}	
foo(); //   / 

Después de llamar a la función, ya es imposible pausar o reanudar su trabajo. Solo puede realizar dos operaciones en las funciones: starty finish. Cuando se inicia la función, debe esperar hasta que se complete. Si se vuelve a llamar a la función, su ejecución se realizará desde el principio.

Con las corutinas, la situación es diferente. No solo puede iniciarlos y detenerlos, sino también pausarlos y reanudarlos. Todavía son diferentes de los flujos centrales, porque las corutinas en sí mismas no se están desplazando (por otro lado, las corutinas generalmente fluyen y el flujo se está desplazando). Para entender esto, considere un generador definido en Python. Que tal cosa se llame generador en Python, en C ++ se llamaría corutina. Se toma un ejemplo de este sitio :

def generate_nums():
     num = 0
     while True:
          yield num
          num = num + 1	

nums = generate_nums()
	
for x in nums:
     print(x)
	
     if x > 9:
          break

Así es como funciona este código: una llamada generate_numsa la función conduce a la creación de un objeto de rutina. En cada paso de enumerar un objeto de rutina, la misma reanuda el trabajo y lo detiene solo después de una palabra clave yielden el código; entonces se devuelve el siguiente entero de la secuencia (el bucle for es azúcar sintáctico para llamar a una función next()que reanuda la rutina). El código finaliza el ciclo al encontrar una declaración de interrupción. En este caso, la corutina nunca termina, pero es fácil imaginar una situación en la que la corutina llega al final y termina. Como podemos ver, a una korutine operaciones aplicables start, suspend, resumey, finalmente,finish. [Nota: C ++ también proporciona operaciones de creación y destrucción, pero no son importantes en el contexto de una comprensión intuitiva de la rutina].

Coroutines como una biblioteca


Entonces, ahora está aproximadamente claro qué son las corutinas. Puede saber que hay bibliotecas para crear objetos de fibra. La pregunta es, ¿por qué necesitamos corutinas en forma de una función de lenguaje dedicada, y no solo una biblioteca que funcione con las rutinas?

Aquí estamos tratando de responder a esta pregunta y demostrar la diferencia entre las rutinas apiladas y sin pila. Esta diferencia es clave para entender la corutina como parte del lenguaje.

Pila de corutinas


Entonces, primero analicemos qué son las corutinas de la pila, cómo funcionan y por qué pueden implementarse como una biblioteca. Explicarlos es relativamente simple, porque se parecen a las secuencias en términos de diseño.

La fibra o pila corutina tiene una pila separada que se puede usar para manejar llamadas a funciones. Para comprender exactamente cómo funcionan las rutinas de este tipo, examinamos brevemente los marcos de funciones y las llamadas a funciones desde un punto de vista de bajo nivel. Pero primero, hablemos de las propiedades de las fibras.

  • Tienen su propia pila,
  • La vida útil de las fibras no depende del código que las llama (generalmente tienen un planificador definido por el usuario),
  • Las fibras se pueden separar de un hilo y unir a otro,
  • Planificación cooperativa (la fibra debe decidir cambiar a otra fibra / programador),
  • No se puede trabajar simultáneamente en el mismo hilo.

Los siguientes efectos resultan de las propiedades anteriores:

  • El usuario debe cambiar el contexto de las fibras y no el sistema operativo (además, el sistema operativo puede liberar la fibra y liberar el hilo en el que funciona).
  • No hay una carrera real de datos entre las dos fibras, ya que en un momento dado solo una de ellas puede estar activa,
  • El diseñador de fibra debe poder elegir el lugar y la hora correctos, dónde y cuándo es apropiado devolver la potencia informática a un posible programador o llamante.
  • Las operaciones de entrada / salida en la fibra deben ser asíncronas, para que otras fibras puedan realizar sus tareas sin bloquearse entre sí.

Ahora echemos un vistazo más de cerca al funcionamiento de las fibras y primero expliquemos cómo la pila participa en las llamadas a funciones.

Entonces, la pila es un bloque continuo de memoria necesaria para almacenar variables locales y argumentos de función. Pero, lo que es más importante, después de cada llamada a la función (con algunas excepciones), se inserta información adicional en la pila que le dice a la función llamada cómo regresar al llamador y restaurar los registros del procesador.

Algunos de estos registros tienen asignaciones especiales y, al llamar a funciones, se almacenan en la pila. Estos son los registros (en el caso de la arquitectura ARM):

SP - puntero de pila
LR -
PC de registro de comunicación - puntero de pila de contador de programa

(SP) es un registro que contiene la dirección del comienzo de la pila relacionada con la llamada a la función actual. Gracias al valor existente, puede referirse fácilmente a argumentos y variables locales almacenadas en la pila.

El registro de comunicación (LR) es muy importante cuando se llaman funciones. Almacena la dirección de retorno (la dirección de la parte llamante), donde el código se ejecutará después de que se complete la ejecución de la función actual. Cuando se llama a la función, la PC se guarda en LR. Cuando la función regresa, la PC se restaura usando LR.

Program Counter (PC) es la dirección de la instrucción que se está ejecutando actualmente.
Cada vez que se llama a una función, se guarda la lista de enlaces, de modo que la función sepa a dónde debe regresar el programa una vez que finalice.



El comportamiento de los registros de PC y LR al llamar y devolver una función

Al ejecutar la rutina de pila, las funciones llamadas usan la pila previamente asignada para almacenar sus argumentos y variables locales. Dado que toda la información sobre cada función llamada en la pila de corutina se almacena en la pila, la fibra puede suspender cualquier función dentro de esta corutina.



Veamos qué pasa en esta imagen. En primer lugar, cada fibra e hilo tiene su propia pila separada. El color verde indica números de serie que indican la secuencia de acciones.

  1. Una llamada de función regular dentro de un hilo. La memoria se asigna en la pila.
  2. . . , . . , .
  3. .
  4. . .
  5. .
  6. .
  7. . , , , .
  8. .
  9. .
  10. – , .
  11. , .
  12. . .
  13. . : , . , ( ) .
  14. , .
  15. .
  16. . . . , .
  17. .
  18. , , .

Cuando se trabaja con rutinas de pila, no hay necesidad de una función de lenguaje dedicada que garantice su uso. El conjunto completo de korutiny puede implementarse utilizando bibliotecas, y ya existen bibliotecas diseñadas específicamente para este propósito:

swtch.com/libtask
code.google.com/archive/p/libconcurrency
www.boost.org Boost.Fiber
www.boost.org the Boost .Coroutine

De todas estas bibliotecas, solo Boost es C ++, y el resto son C.
Para obtener una descripción detallada de cómo funcionan estas bibliotecas, consulte la documentación. Pero, en general, todas estas bibliotecas le permiten crear una pila separada para fibra y brindan la oportunidad de reanudar la rutina (a iniciativa de la persona que llama) y pausarla (desde adentro).

Considere un ejemplo Boost.Fiber:

#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
	
#include <boost/intrusive_ptr.hpp>
	
#include <boost/fiber/all.hpp>
	
inline
void fn( std::string const& str, int n) {
     for ( int i = 0; i < n; ++i) {
          std::cout << i << ": " << str << std::endl;
               boost::this_fiber::yield();
     }
}
	
int main() {
     try {
          boost::fibers::fiber f1( fn, "abc", 5);
          std::cerr << "f1 : " << f1.get_id() << std::endl;
          f1.join();
          std::cout << "done." << std::endl;
	
          return EXIT_SUCCESS;
     } catch ( std::exception const& e) {
          std::cerr << "exception: " << e.what() << std::endl;
     } catch (...) {
          std::cerr << "unhandled exception" << std::endl;
     }
     return EXIT_FAILURE;
}

En el caso de Boost.Fiber , la biblioteca tiene un programador incorporado para la rutina. Todas las fibras corren en el mismo hilo. Dado que la planificación de corutina es cooperativa, la fibra primero debe decidir cuándo devolver el control al planificador. En este ejemplo, esto sucede cuando se llama a la función de rendimiento, que detiene la corutina.

Como no hay otra fibra, el planificador de fibra siempre decide reanudar la rutina.

Corutinas apiladas


Las corutinas apiladas difieren ligeramente en propiedades de las apiladas. Sin embargo, tienen las mismas características básicas, ya que las corutinas no apiladas también pueden iniciarse, y después de reanudar su suspensión. Coroutinas de este tipo es probable que encontremos en C ++ 20.

Si hablamos de las propiedades similares de la corutina, las corutinas pueden:

  • Corutin está estrechamente relacionada con su interlocutor: cuando se llama a una rutina, la ejecución se transfiere a ella, y el resultado de la rutina se transfiere nuevamente a la persona que llama.
  • La vida útil de una pila de corutina es igual a la vida de su pila. La vida útil de una corutina sin pila es igual a la vida de su objeto.

Sin embargo, en el caso de las rutinas sin pila, no hay necesidad de asignar una pila completa. Consumen mucha menos memoria que las de pila, pero esto se debe precisamente a algunas de sus limitaciones.

Para empezar, si no asignan memoria para la pila, ¿cómo funcionan? Donde, en su caso, van todos los datos que deben almacenarse en la pila cuando se trabaja con las rutinas de la pila. Respuesta: en la pila de la persona que llama.

El secreto de las rutinas apiladas es que solo pueden suspenderse de la función superior. Para todas las demás funciones, sus datos se encuentran en la pila del lado llamado, por lo que todas las funciones llamadas desde corutina deben completarse antes de suspender el trabajo de la corutina. Todos los datos que necesita la rutina para mantener su estado se asignan dinámicamente en el montón. Esto generalmente requiere un par de variables y argumentos locales, que son mucho más compactos que una pila completa que debería asignarse por adelantado.

Eche un vistazo a cómo funcionan las corutinas sin pila:



desafiando una corutina sin pila

Como puede ver, ahora solo hay una pila: esta es la pila principal del hilo. Echemos un vistazo paso a paso a lo que se muestra en esta imagen (el marco de activación de rutina aquí es de dos colores: el negro muestra lo que está almacenado en la pila y el azul, lo que está almacenado en el montón).

  1. Una llamada a función regular cuyo marco está almacenado en la pila
  2. La función crea una corutina . Es decir, asigna un marco de activación para él en algún lugar del montón.
  3. Llamada de función normal.
  4. Llama a Corutin . El cuerpo de Corutin se destaca en una pila regular. El programa se ejecuta de la misma manera que en el caso de una función regular.
  5. Una llamada de función regular desde la rutina. Una vez más, todo sigue sucediendo en la pila [Nota: no puede pausar la rutina desde este punto, ya que esta no es la función más importante en la rutina]
  6. [: .]
  7. – , , .
  8. – , + .
  9. 5.
  10. 6.
  11. . .

Por lo tanto, es obvio que en el segundo caso es necesario recordar muchos menos datos para todas las operaciones de pausa y reanudación del trabajo de corutina, sin embargo, la rutina puede reanudarse y pausarse solo a sí misma, y ​​solo desde la función más alta. Todas las llamadas a funciones y la rutina ocurren de la misma manera, sin embargo, entre llamadas se requiere guardar algunos datos adicionales, y la función debe poder saltar al punto de suspensión y restaurar el estado de las variables locales. No hay otras diferencias entre el marco de la rutina y el marco de la función.

La corutina también puede causar otras corutinas (no se muestran en este ejemplo). En el caso de las rutinas sin pila, cada llamada da como resultado la asignación de un nuevo espacio para nuevos datos de la rutina (con una llamada repetida de la rutina, la memoria dinámica también se puede asignar varias veces).

La razón por la cual las corutinas necesitan proporcionar una función de lenguaje dedicada es porque el compilador necesita decidir qué variables describen el estado de la corutina y crear un código estereotipado para saltar a los puntos de suspensión.

Uso práctico de la corutina.


Las rutinas en C ++ se pueden usar de la misma manera que en otros lenguajes. Las rutinas simplificarán la ortografía:

  • generadores
  • código de entrada / salida asíncrono
  • computación perezosa
  • aplicaciones conducidas por eventos

Resumen


Espero que al leer este artículo descubras:

  • por qué en C ++ necesitas implementar corutinas como una característica de lenguaje dedicada
  • ¿Cuál es la diferencia entre corutinas apiladas y apiladas?
  • por qué se necesitan corutinas

All Articles