A principios de noviembre, Minsk organizó la próxima conferencia C ++ C ++ CoreHard Otoño 2018 conferencia. Entregó un
informe del capitán "Actores vs CSP vs Tareas ..." , que habló sobre cómo las aplicaciones de nivel superior que "pueden verse en C ++" multihilo desnudo ”, modelos de programación competitivos. Bajo la versión cortada de este informe, transformado en un artículo. Peinado, recortado en lugares, complementado en lugares.
Me gustaría aprovechar esta oportunidad para agradecer a la comunidad
CoreHard por organizar la próxima gran conferencia en Minsk y por la oportunidad de hablar. Y también para la pronta publicación de
informes en video de informes en YouTube .
Entonces, pasemos al tema principal de la conversación. Es decir, qué enfoques podemos usar para simplificar la programación de subprocesos múltiples en C ++, cómo se verán algunos de estos enfoques en el código, qué características son inherentes a los enfoques específicos, qué es común entre ellos, etc.
Nota: se encontraron errores y errores tipográficos en la presentación original del informe, por lo que el artículo utilizará diapositivas de la versión actualizada y editada, que se pueden encontrar en
Google Slides o en
SlideShare .
¡El subprocesamiento múltiple desnudo es malo!
Debe comenzar con la banalidad repetida, que, sin embargo, sigue siendo relevante:
La programación de C ++ multiproceso a través de hilos desnudos, mutex y variables de condición es sudor , dolor y sangre .
Un buen ejemplo se describió recientemente aquí en este artículo aquí en Habré: "
Arquitectura del meta-servidor del tirador en línea móvil Tacticool ". En él, los chicos hablaron sobre cómo lograron recolectar, aparentemente, una gama completa de rastrillos relacionados con el desarrollo de código multiproceso en C y C ++. Hubo "pases de memoria" como resultado de las carreras y bajo rendimiento debido a la paralelización fallida.
Como resultado, todo terminó de forma bastante natural:
Después de pasar un par de semanas buscando y reparando los errores más críticos, decidimos que era más fácil reescribir todo desde cero que tratar de corregir todas las deficiencias de la solución actual.
Las personas comieron C / C ++ mientras trabajaban en la primera versión de su servidor y reescribieron el servidor en otro idioma.
Una gran demostración de cómo, en el mundo real, fuera de nuestra acogedora comunidad de C ++, los desarrolladores se niegan a usar C ++ incluso cuando el uso de C ++ sigue siendo apropiado y justificado.
Pero por que?
Pero, ¿por qué, si se dice repetidamente que "subprocesamiento múltiple desnudo" en C ++ es malo, las personas continúan usándolo con perseverancia digna de una mejor aplicación? ¿Cuál es la culpa?
- ignorancia?
- pereza?
- Síndrome de NIH?
Después de todo, hay mucho de un enfoque probado por el tiempo y muchos proyectos. En particular:
- actores
- Comunicar procesos secuenciales (CSP)
- tareas (asíncronas, promesas, futuros, ...)
- flujos de datos
- programación reactiva
- ...
Se espera que la razón principal sea la ignorancia. Es poco probable que esto se enseñe en las universidades. Entonces, los jóvenes profesionales que ingresan a la profesión usan lo poco que ya saben. Y si luego el almacén de conocimiento no se repone, la gente continúa usando hilos desnudos, mutexes y condición_variables.
Hoy hablaremos sobre los primeros tres enfoques de esta lista. Y hablaremos no de manera abstracta, sino sobre el ejemplo de una tarea simple. Intentemos mostrar cómo se verá el código que resuelve este problema usando Actor, procesos y canales CSP, así como también usando Task.
Desafío para experimentos
Se requiere implementar un servidor HTTP que:
- acepta la solicitud (identificación con foto, identificación del usuario);
- da una imagen con "marcas de agua" exclusivas de este usuario.
Por ejemplo, tal servidor puede ser requerido por algún servicio pago que distribuya contenido por suscripción. Si la imagen de este servicio "aparece" en algún lugar, entonces por las "marcas de agua" en ella será posible entender quién necesita "bloquear el oxígeno".
La tarea es abstracta, se formuló específicamente para este informe bajo la influencia de nuestro proyecto demo Shrimp (ya hablamos de ello:
No. 1 ,
No. 2 ,
No. 3 ).
Este nuestro servidor HTTP funcionará de la siguiente manera:
Habiendo recibido una solicitud de un cliente, pasamos a dos servicios externos:
- el primero nos devuelve la información del usuario. Incluyendo a partir de ahí obtenemos una imagen con "marcas de agua";
- el segundo nos devuelve la imagen original
Ambos servicios funcionan de forma independiente y podemos acceder a ambos simultáneamente.
Dado que el procesamiento de las solicitudes se puede realizar de forma independiente, e incluso algunas acciones al procesar una sola solicitud se pueden realizar en paralelo, el uso de la competitividad se sugiere. Lo más simple que viene a la mente es crear un hilo separado para cada solicitud entrante:
Pero el modelo one-request = one-workflow es demasiado costoso y no escala bien. No necesitamos esto.
Incluso si nos acercamos al número de flujos de trabajo de manera inútil, todavía necesitamos un pequeño número de ellos:
Aquí necesitamos una secuencia separada para recibir solicitudes HTTP entrantes, una secuencia separada para nuestras propias solicitudes HTTP salientes, una secuencia separada para coordinar el procesamiento de las solicitudes HTTP recibidas. Además de un conjunto de flujos de trabajo para realizar operaciones en imágenes (dado que las manipulaciones en imágenes son muy paralelas, el procesamiento de una imagen por varias secuencias a la vez reducirá su tiempo de procesamiento).
Por lo tanto, nuestro objetivo es manejar una gran cantidad de solicitudes entrantes concurrentes en una pequeña cantidad de subprocesos de trabajo. Veamos cómo logramos esto a través de varios enfoques.
Algunas renuncias importantes
Antes de pasar a la historia principal y los ejemplos de código de análisis, se deben tomar algunas notas.
En primer lugar, todos los siguientes ejemplos no están vinculados a ningún marco o biblioteca en particular. Las coincidencias en los nombres de las llamadas a la API son aleatorias y no intencionadas.
En segundo lugar, no hay manejo de errores en los ejemplos a continuación. Esto se hace deliberadamente, para que las diapositivas sean compactas y visibles. Y también para que el material encaje en el tiempo asignado para el informe.
En tercer lugar, los ejemplos usan una determinada entidad execute_context, que contiene información sobre qué más existe dentro del programa. Llenar esta entidad depende del enfoque. En el caso de los actores, execute_context tendrá enlaces a otros actores. En el caso de CSP, en el contexto de ejecución habrá canales CSP para la comunicación con otros procesos CSP. Etc.
Enfoque n. ° 1: actores
Modelo de actores en pocas palabras
Cuando se utiliza el Modelo de actores, la solución se construirá con objetos-actores separados, cada uno de los cuales tiene su propio estado privado y este estado es inaccesible para cualquier persona, excepto el propio actor.
Los actores interactúan entre sí a través de mensajes asincrónicos. Cada actor tiene su propio buzón único (cola de mensajes), en el que se guardan los mensajes enviados al actor y desde donde se recuperan para su posterior procesamiento.
Los actores trabajan sobre principios muy simples:
- un actor es una entidad con comportamiento;
- los actores responden a los mensajes entrantes;
- Una vez recibido el mensaje, el actor puede:
- enviar un número (final) de mensajes a otros actores;
- crear un número (final) de nuevos actores;
- Defina un nuevo comportamiento para procesar mensajes posteriores.
Dentro de una aplicación, los actores se pueden implementar de diferentes maneras:
- cada actor puede representarse como un flujo de sistema operativo separado (esto sucede, por ejemplo, en la biblioteca C :: Just :: Thread Pro Actor Edition);
- cada actor puede ser representado como una corutina apilada;
- cada actor puede representarse como un objeto en el que alguien llama a métodos de devolución de llamada.
En nuestra decisión, utilizaremos actores en forma de objetos con devoluciones de llamada, y dejaremos las rutinas para el enfoque CSP.
Esquema de decisión basado en el modelo de actores
Según los actores, el esquema general para resolver nuestro problema se verá así:
Tendremos actores que se crean al comienzo del servidor HTTP y existen todo el tiempo mientras el servidor HTTP está funcionando. Estos son actores como: HttpSrv, UserChecker, ImageDownloader, ImageMixer.
Al recibir una nueva solicitud HTTP entrante, creamos una nueva instancia del actor RequestHandler, que se destruirá después de emitir una respuesta a la solicitud HTTP entrante.
RequestHandler Actor Code
La implementación del actor request_handler, que coordina el procesamiento de una solicitud HTTP entrante, puede verse así:
class request_handler final : public some_basic_type { const execution_context context_; const request request_; optional<user_info> user_info_; optional<image_loaded> image_; void on_start(); void on_user_info(user_info info); void on_image_loaded(image_loaded image); void on_mixed_image(mixed_image image); void send_mix_images_request(); ...
Analicemos este código.
Tenemos una clase en los atributos de los cuales almacenamos o vamos a almacenar lo que necesitamos para procesar la solicitud. También en esta clase hay un conjunto de devoluciones de llamada que se llamarán en un momento u otro.
Primero, cuando se acaba de crear un actor, se llama a la devolución de llamada on_start (). En él, enviamos dos mensajes a otros actores. Primero, este es un mensaje check_user para verificar la ID del cliente. En segundo lugar, este es un mensaje download_image para descargar la imagen original.
En cada uno de los mensajes enviados, pasamos un enlace a nosotros mismos (una llamada al método self () devuelve un enlace al actor para el que se llamó self ()). Esto es necesario para que nuestro actor pueda enviar un mensaje en respuesta. Si no enviamos un enlace a nuestro actor, por ejemplo, en el mensaje check_user, el actor UserChecker no sabrá a quién enviar la información del usuario.
Cuando se nos envía un mensaje user_info con información del usuario en respuesta, se llama a la devolución de llamada on_user_info (). Y cuando se nos envía el mensaje image_loaded, la devolución de llamada on_image_loaded () se llama a nuestro actor. Y ahora, dentro de estas dos devoluciones de llamada, vemos una característica inherente al Modelo de actores: no sabemos exactamente en qué orden recibiremos los mensajes de respuesta. Por lo tanto, debemos escribir nuestro código para que no dependa del orden en que llegan los mensajes. Por lo tanto, en cada uno de los procesadores, primero almacenamos la información recibida en el atributo correspondiente y luego verificamos si ya hemos recopilado toda la información que necesitamos. Si es así, entonces podemos seguir adelante. Si no, entonces esperaremos más.
Es por eso que tenemos complementos en on_user_info () y on_image_loaded () si se llama a send_mix_images_request ().
En principio, en las implementaciones del Modelo de actores puede haber mecanismos como la recepción selectiva de Erlang o el escondite de Akka, a través del cual puede manipular el orden de procesamiento de los mensajes entrantes, pero no hablaremos de esto hoy, para no profundizar en la jungla de detalles de diversas implementaciones del Modelo Actores
Entonces, si se recibe toda la información que necesitamos de UserChecker e ImageDownloader, se llama al método send_mix_images_request (), en el que se envía el mensaje mix_images al actor ImageMixer. La devolución de llamada on_mixed_image () se llama cuando recibimos un mensaje de respuesta con la imagen resultante. Aquí enviamos esta imagen al actor HttpSrv y esperamos hasta que HttpSrv forme una respuesta HTTP y destruya el RequestHandler que se ha vuelto innecesario (aunque, en principio, nada impide que el actor RequestHandler se autodestruya en la devolución de llamada on_mixed_image ()).
Eso es todo
La implementación del actor RequestHandler resultó ser bastante voluminosa. Pero esto se debe al hecho de que necesitábamos describir una clase con atributos y devoluciones de llamada, y luego también implementar devoluciones de llamada. Pero la lógica del trabajo de RequestHandler es muy trivial, y comprenderlo, a pesar de la cantidad de código en la clase request_handler, es fácil.
Características inherentes a los actores.
Ahora podemos decir algunas palabras sobre las características del Modelo de actores.
Reactores
Como regla general, los actores solo responden a los mensajes entrantes. Hay mensajes: el actor los procesa. Sin mensajes: el actor no hace nada.
Esto es especialmente cierto para aquellas implementaciones del Modelo de actores en el que los actores se representan como objetos con devoluciones de llamada. El marco tira la devolución de llamada del actor y si el actor no devuelve el control de la devolución de llamada, el marco no puede servir a otros actores en el mismo contexto.
Los actores están sobrecargados
En el caso de los actores, podemos hacer que actor-productor genere mensajes para el consumidor-actor a un ritmo mucho más rápido de lo que el actor-consumidor podrá procesar.
Esto conducirá al hecho de que la cola de mensajes entrantes para el actor-consumidor crecerá constantemente. Crecimiento de la cola, es decir El mayor consumo de memoria en la aplicación reducirá la velocidad de la aplicación. Esto conducirá a un crecimiento aún más rápido de la cola y, como resultado, la aplicación puede degradarse para completar la inoperancia.
Todo esto es una consecuencia directa de la interacción asincrónica de los actores. Debido a que la operación de envío generalmente no es de bloqueo. Y bloquearlo no es fácil, porque Un actor puede enviarse a sí mismo. Y si la cola para el actor está llena, entonces, en el envío a sí mismo, el actor será bloqueado y esto detendrá su trabajo.
Entonces, cuando se trabaja con actores, se debe prestar mucha atención al problema de la sobrecarga.
Muchos actores no siempre son la solución.
Como regla general, los actores son entidades ligeras y existe la tentación de crearlos en su aplicación en grandes cantidades. Puedes crear diez mil actores, cien mil y un millón. E incluso cien millones de actores, si el hierro te lo permite.
Pero el problema es que el comportamiento de un gran número de actores es difícil de rastrear. Es decir Es posible que tenga algunos actores que claramente funcionan correctamente. Algunos actores que obviamente trabajan incorrectamente o no trabajan en absoluto, y usted lo sabe con certeza. Pero puede haber una gran cantidad de actores de los que no sabes nada: ¿funcionan en absoluto, funcionan de manera correcta o incorrecta? Y todo porque cuando tienes cien millones de entidades autónomas con tu propia lógica de comportamiento en tu programa, entonces monitorear esto es muy difícil para todos.
Por lo tanto, puede resultar que cuando creamos una gran cantidad de actores en la aplicación, no resolvemos nuestro problema aplicado, sino que obtenemos otro problema. Y, por lo tanto, puede ser beneficioso para nosotros abandonar actores simples que resuelven una sola tarea, a favor de actores más complejos y pesados que realizan varias tareas. Pero entonces habrá menos actores "pesados" en la aplicación y será más fácil para nosotros seguirlos.
¿Dónde mirar, qué llevar?
Si alguien quiere intentar trabajar con actores en C ++, entonces no tiene sentido construir sus propias bicicletas, hay varias soluciones preparadas, en particular:
Estas tres opciones son animadas, evolutivas, multiplataforma, documentadas. También puedes probarlos gratis. Además, se pueden encontrar varias opciones más de diversos grados de [no] frescura
en la lista de Wikipedia .
SObjectizer y CAF están diseñados para su uso en tareas de alto nivel donde se pueden aplicar excepciones y memoria dinámica. Y el marco QP / C ++ puede ser de interés para aquellos involucrados en el desarrollo integrado, como Es bajo este nicho donde está "encarcelado".
Enfoque n. ° 2: CSP (comunicación de procesos secuenciales)
CSP en dedos y sin matan
El modelo CSP es muy similar al modelo de actores. También construimos nuestra solución a partir de un conjunto de entidades autónomas, cada una de las cuales tiene su propio estado privado e interactúa con otras entidades solo a través de mensajes asincrónicos.
Solo estas entidades en el modelo CSP se denominan "procesos".
Los procesos en CSP son ligeros, sin ninguna paralelización de su trabajo en el interior. Si necesitamos paralelizar algo, entonces simplemente iniciamos varios procesos CSP, dentro de los cuales ya no hay paralelización.
Los procesos CSP interactúan entre sí a través de mensajes asincrónicos, pero los mensajes no se envían a los buzones, como en el Modelo de actores, sino a los canales. Los canales pueden considerarse colas de mensajes, generalmente de un tamaño fijo.
A diferencia del Modelo de actores, donde se crea automáticamente un buzón para cada actor, los canales en el CSP deben crearse explícitamente. Y si necesitamos que los dos procesos interactúen entre sí, entonces debemos crear el canal nosotros mismos, y luego decirle al primer proceso "escribirás aquí", y el segundo proceso debería decir: "leerás aquí desde aquí".
Al mismo tiempo, los canales tienen al menos dos operaciones que deben llamarse explícitamente. La primera es la operación de escritura (envío) para escribir un mensaje en el canal.
En segundo lugar, es una operación de lectura (recepción) leer un mensaje de un canal. Y la necesidad de llamar explícitamente a leer / recibir distingue a CSP del Modelo de actores, porque en el caso de los actores, la operación de lectura / recepción generalmente se puede ocultar al actor. Es decir Actor Framework puede recuperar mensajes de la cola del actor y llamar a un controlador (devolución de llamada) para el mensaje recuperado.
Mientras que el proceso CSP en sí mismo debe elegir el momento para la llamada de lectura / recepción, entonces el proceso CSP debe determinar qué mensaje recibió y procesar el mensaje extraído.
Dentro de nuestra aplicación "grande", los procesos CSP se pueden implementar de diferentes maneras:
- El proceso CSP-shny se puede implementar como un sistema operativo de hilo separado. Resulta una solución costosa, pero con multitarea preventiva;
- El proceso de CSP puede implementarse mediante corutina (rutina de pila, fibra, hilo verde, ...). Es mucho más barato, pero la multitarea solo es cooperativa.
Además, suponemos que los procesos de CSP se presentan en forma de rutinas apiladas (aunque el código que se muestra a continuación puede implementarse en subprocesos del sistema operativo).
Diagrama de soluciones basadas en CSP
El esquema de solución basado en el modelo CSP se parecerá mucho a un esquema similar para el Modelo de actores (y esto no es accidental):
También habrá entidades que se inicien cuando el servidor HTTP se inicie y trabaje todo el tiempo: estos son los procesos CSP HttpSrv, UserChecker, ImageDownloader e ImageMixer. Para cada nueva solicitud entrante, se creará un nuevo proceso CSP RequestHandler. Este proceso envía y recibe los mismos mensajes que cuando se usa el Modelo de actores.
RequestHandler CSP Process Code
Esto puede parecerse al código de una función que implementa el proceso tímido CSP de RequestHandler:
void request_handler(const execution_context ctx, const request req) { auto user_info_ch = make_chain<user_info>(); auto image_loaded_ch = make_chain<image_loaded>(); ctx.user_checker_ch().write(check_user{req.user_id(), user_info_ch}); ctx.image_downloader_ch().write(download_image{req.image_id(), image_loaded_ch}); auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); auto image_mix_ch = make_chain<mixed_image>(); ctx.image_mixer_ch().write( mix_image{user.watermark_image(), std::move(original_image), image_mix_ch}); auto result_image = image_mix_ch.read(); ctx.http_srv_ch().write(reply{..., std::move(result_image), ...}); }
Aquí todo es bastante trivial y repite regularmente el mismo patrón:
- Primero, creamos un canal para recibir mensajes de respuesta. Esto es necesario porque El proceso CSP no tiene su propio buzón predeterminado, como los actores. Por lo tanto, si el proceso CSP-shny quiere recibir algo, entonces debería estar desconcertado por la creación del canal donde se escribirá este "algo";
- luego enviamos nuestro mensaje al proceso maestro CSP. Y en este mensaje indicamos el canal para el mensaje de respuesta;
- luego realizamos la operación de lectura desde el canal donde se nos debe enviar un mensaje de respuesta.
Esto se ve muy claramente en el ejemplo de comunicación con el proceso de ImageSPixer CSP:
auto image_mix_ch = make_chain<mixed_image>();
Pero por separado vale la pena centrarse en este fragmento:
auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read();
Aquí vemos otra gran diferencia con el Modelo de actores. En el caso de CSP, podemos recibir mensajes de respuesta en el orden que más nos convenga.
¿Quieres esperar a user_info primero? No hay problema, vaya a dormir en lectura hasta que aparezca user_info. Si ya se nos ha enviado image_loaded en este momento, simplemente esperará en su canal hasta que lo leamos.
Eso, de hecho, es todo lo que puede acompañar al código que se muestra arriba. El código basado en CSP era más compacto que su homólogo basado en actores. Lo cual no es sorprendente ya que aquí no tuvimos que describir una clase separada con métodos de devolución de llamada. Y parte del estado de nuestro proceso tímido CSP RequestHandler está presente implícitamente en forma de argumentos ctx y req.
Características de CSP
Reactividad y proactividad de procesos CSP
A diferencia de los actores, los procesos de CSP pueden ser reactivos, proactivos o ambos. Digamos que el proceso CSP verificó sus mensajes entrantes; si hubo alguno, los procesó. Y luego, al ver que no había mensajes entrantes, se comprometió a multiplicar las matrices.
Después de un tiempo, el proceso CSP de la matriz estaba cansado de multiplicarse, y una vez más verificó los mensajes entrantes. No hay nuevos? Bueno, ok, multipliquemos las matrices aún más.
Y esta capacidad de los procesos de CSP para hacer un trabajo incluso en ausencia de mensajes entrantes hace que el modelo de CSP sea muy diferente del modelo de actores.
Mecanismos de protección de sobrecarga nativos
Dado que, como regla, los canales son colas de mensajes de un tamaño limitado y un intento de escribir un mensaje en un canal lleno detiene al remitente, entonces en CSP tenemos un mecanismo incorporado de protección contra sobrecarga.
De hecho, si tenemos un proceso de producción ágil y un proceso de consumo lento, entonces el proceso de producción llenará rápidamente el canal y se suspenderá para la próxima operación de envío. Y el proceso del productor se suspenderá hasta que el proceso del consumidor libere espacio en el canal para nuevos mensajes. Tan pronto como aparece el lugar, el proceso del productor se despierta y arroja nuevos mensajes al canal.
Por lo tanto, cuando usamos CSP, podemos preocuparnos menos por el problema de la sobrecarga que en el caso del Modelo de Actores. Es cierto, aquí hay una trampa, de la que hablaremos un poco más adelante.
¿Cómo se implementan los procesos CSP?
Debemos decidir cómo se implementarán nuestros procesos CSP.
Se puede hacer para que cada proceso de CSP-shny esté representado por un hilo separado del sistema operativo. Resulta una solución costosa y no escalable. Pero, por otro lado, obtenemos la multitarea preventiva: si nuestro proceso CSP comienza a multiplicar matrices o hace algún tipo de llamada de bloqueo, el sistema operativo eventualmente lo expulsará del núcleo computacional y hará posible que otros procesos CSP funcionen.
Es posible hacer que cada proceso de CSP esté representado por una corutina (rutina de pila). Esta es una solución mucho más barata y escalable. Pero aquí solo tendremos multitarea cooperativa. Por lo tanto, si de repente el proceso CSP toma la multiplicación de la matriz, el hilo de trabajo con este proceso CSP y otros procesos CSP que están unidos a él se bloqueará.
Puede haber otro truco. Supongamos que usamos una biblioteca de terceros, en cuyo interior no podemos influir. Y dentro de la biblioteca, se utilizan variables TLS (es decir, almacenamiento local de subprocesos). Hacemos una llamada a la función de biblioteca y la biblioteca establece el valor de alguna variable TLS. Entonces nuestra rutina "se mueve" a otro hilo de trabajo, y esto es posible, porque en principio, las corutinas pueden migrar de un hilo de trabajo a otro. Realizamos la siguiente llamada a la función de biblioteca y la biblioteca intenta leer el valor de la variable TLS. ¡Pero puede que ya haya un significado diferente! Y buscar ese error será muy difícil.
Por lo tanto, debe considerar cuidadosamente la elección del método para implementar procesos CSP-shnyh. Cada una de las opciones tiene sus propias fortalezas y debilidades.
Muchos procesos no siempre son la solución.
Al igual que con los actores, la capacidad de crear muchos procesos CSP en su programa no siempre es una solución a un problema aplicado, sino que crea problemas adicionales para usted.
Además, la poca visibilidad de lo que sucede dentro del programa es solo una parte del problema. Me gustaría centrarme en otra trampa.
El hecho es que en los canales CSP-shnyh puedes obtener fácilmente un análogo de punto muerto. El proceso A intenta escribir un mensaje en el canal C1 completo y el proceso A se detiene. Desde el canal C1, se debe leer el proceso B, que intentó escribir en el canal C2, que está lleno, y, por lo tanto, se suspendió el proceso B. Y desde el canal C2, el proceso A era leer. Eso es todo, tenemos un punto muerto.
Si solo tenemos dos procesos CSP, entonces podemos encontrar ese punto muerto durante la depuración o incluso con el procedimiento de revisión del código. Pero si tenemos millones de procesos en el programa, se comunican activamente entre ellos, entonces la probabilidad de tales puntos muertos aumenta significativamente.
¿Dónde mirar, qué llevar?
Si alguien quiere trabajar con CSP en C ++, entonces la elección aquí, desafortunadamente, no es tan grande como para los actores. Bueno, o no sé dónde mirar y cómo mirar. En este caso, espero que los comentarios compartan otros enlaces.
Pero, si queremos usar CSP, primero debemos mirar hacia
Boost.Fiber . Hay fibra (es decir, corutinas) y canales, e incluso primitivas de bajo nivel como mutex, condición_variable, barrera. Todo esto puede ser tomado y usado.
Si está satisfecho con los procesos CSP en forma de subprocesos, puede consultar
SObjectizer . También hay análogos de canales CSP y se pueden escribir complejas aplicaciones multiproceso en SObjectizer sin ningún actor.
Actores vs CSP
Los actores y los CSP son muy similares entre sí. Repetidamente me encontré con la afirmación de que estos dos modelos son equivalentes entre sí. Es decir lo que se puede hacer con los actores puede repetirse casi 1 en 1 en los procesos de CSP y viceversa. Dicen que incluso se demuestra matemáticamente. Pero aquí no entiendo nada, así que no puedo decir nada. Pero desde mis propios pensamientos en algún lugar al nivel del sentido común cotidiano, todo esto parece bastante plausible. En algunos casos, de hecho, los actores pueden ser reemplazados por procesos CSP, y los procesos CSP por actores.
Sin embargo, existen varias diferencias entre los actores y los CSP que pueden ayudar a determinar dónde cada uno de estos modelos es beneficioso o desventajoso.
Canales vs buzón
Un actor tiene un solo "canal" para recibir mensajes entrantes: este es su buzón, que se crea automáticamente para cada actor. Y el actor recupera los mensajes de allí secuencialmente, exactamente en el orden en que estaban en el buzón.
Y esta es una pregunta bastante seria. Digamos que hay tres mensajes en el buzón del actor: M1, M2 y M3. El actor actualmente solo está interesado en M3.
Pero antes de llegar a M3, el actor primero extraerá M1, luego M2. ¿Y qué hará él con ellos?Nuevamente, como parte de esta conversación, no tocaremos los mecanismos de recepción selectiva de Erlang y el escondite de Akka.
Mientras que el proceso CSP-shny tiene la capacidad de seleccionar el canal del que actualmente quiere leer los mensajes. Por lo tanto, un proceso CSP puede tener tres canales: C1, C2 y C3. Actualmente, el proceso CSP solo está interesado en los mensajes de C3. Es este canal el que lee el proceso. Y volverá a los contenidos de los canales C1 y C2 cuando esté interesado en esto.Reactividad y proactividad
Como regla general, los actores son reactivos y solo funcionan cuando tienen mensajes entrantes.Mientras que los procesos CSP pueden hacer algo de trabajo incluso en ausencia de mensajes entrantes. En algunos escenarios, esta diferencia puede jugar un papel importante.Máquinas de estado
De hecho, los actores son máquinas de estado finito (KA). Por lo tanto, si hay muchas máquinas de estado finito en su área temática, e incluso si se trata de máquinas de estado finito jerárquicas complejas, puede ser mucho más fácil implementarlas según el modelo de actor que agregar una implementación de nave espacial a un proceso CSP.En C ++, todavía no hay soporte nativo de CSP.
La experiencia del lenguaje Go muestra cuán fácil y conveniente es usar el modelo CSP cuando su soporte se implementa a nivel de un lenguaje de programación y su biblioteca estándar.En Go, es fácil crear "procesos CSP" (también conocidos como gorutinas), es fácil crear y trabajar con canales, hay una sintaxis incorporada para trabajar con varios canales a la vez (Go-shny select, que funciona no solo para leer sino también para escribir), la biblioteca estándar sabe sobre goroutins y puede cambiarlos cuando goroutin realiza una llamada de bloqueo desde stdlib.En C ++, hasta ahora no hay soporte para las rutinas apiladas (a nivel de lenguaje). Por lo tanto, trabajar con CSP en C ++ puede parecer, en algunos lugares, si no una muleta, entonces ... Eso ciertamente requiere mucha más atención para sí mismo que en el caso del mismo Go.Enfoque n. ° 3: Tareas (asíncrono, futuro, esperar_todos, ...)
Acerca del enfoque basado en tareas en las palabras más comunes
El significado del enfoque basado en tareas es que si tenemos una operación compleja, dividimos esta operación en pasos de tarea separados, donde cada tarea (es una tarea) realiza una sola sub-operación.Comenzamos estas tareas con la operación especial asíncrona. La operación asíncrona devuelve un objeto futuro en el que, una vez completada la tarea, se colocará el valor devuelto por la tarea.Después de que lanzamos N tareas y recibimos N objetos en el futuro, necesitamos de alguna manera unir todo esto en una cadena. Parece que cuando se completan las tareas No. 1 y No. 2, los valores devueltos por ellos deben caer en la tarea No. 3. Y cuando se completa la tarea No. 3, el valor devuelto debe transferirse a las tareas No. 4, No. 5 y No. 6. Etc., etc.Para tal "empate", se utilizan medios especiales. Como, por ejemplo, el método .then () de un objeto futuro, así como las funciones wait_all (), wait_any ().Tal explicación "en los dedos" puede no ser muy clara, así que pasemos al código. Tal vez en una conversación sobre un código específico la situación se aclare (pero no es un hecho).Código de request_handler para el enfoque basado en tareas
El código para procesar una solicitud HTTP entrante basada en tareas puede verse así: void handle_request(const execution_context & ctx, request req) { auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); when_all(user_info_ft, original_image_ft).then( [&ctx, req](tuple<future<user_info>, future<image_loaded>> data) { async(ctx.image_mixer_ctx(), [&ctx, req, d=std::move(data)] { return mix_image(get<0>(d).get().watermark_image(), get<1>(d).get()); }) .then([req](future<mixed_image> mixed) { async(ctx.http_srv_ctx(), [req, im=std::move(mixed)] { make_reply(...); }); }); }); }
Tratemos de descubrir qué está pasando aquí.Primero, creamos una tarea que debe iniciarse en el contexto de nuestro propio cliente HTTP y que solicita información sobre el usuario. El objeto futuro devuelto se almacena en la variable user_info_ft.A continuación, creamos una tarea similar, que también debería ejecutarse en el contexto de nuestro propio cliente HTTP y que carga la imagen original. El objeto futuro devuelto se almacena en la variable original_image_ft.A continuación, debemos esperar a que se completen las dos primeras tareas. Lo que escribimos directamente: when_all (user_info_ft, original_image_ft). Cuando ambos objetos futuros obtengan sus valores, ejecutaremos otra tarea. Esta tarea tomará el mapa de bits con la marca de agua y la imagen original y ejecutará otra tarea en el contexto de ImageMixer. Esta tarea mezclará imágenes y cuando se complete, se iniciará otra tarea en el contexto del servidor HTTP, lo que generará una respuesta HTTP.Quizás tal explicación de lo que está sucediendo en el código no está muy aclarada. Por lo tanto, enumeremos nuestras tareas:Y veamos las dependencias entre ellos (de donde fluye el orden de las tareas):Y si ahora superponemos esta imagen en nuestro código fuente, entonces espero que se aclare:Características del enfoque basado en tareas
Visibilidad
La primera característica que ya debería ser obvia es la visibilidad del código en la Tarea. No todo está bien con ella.Aquí puedes mencionar el infierno de devolución de llamada. Los programadores de Node.js están muy familiarizados con él. Pero los apodos de C ++ que trabajan en estrecha colaboración con Task también se sumergen en este infierno de devolución de llamadas.Manejo de errores
Otra característica interesante es el manejo de errores.Por un lado, en el caso de usar asíncrono y futuro con la entrega de información de error a la parte interesada, puede ser aún más fácil que en el caso de actores o CSP. Después de todo, si en el proceso CSP A envía una solicitud para procesar B y espera un mensaje de respuesta, entonces cuando B encuentra un error mientras ejecuta la solicitud, debemos decidir cómo entregar el error para procesar A:- o haremos un tipo de mensaje separado y un canal para recibirlo;
- o devolvemos el resultado con un solo mensaje, que será std :: variant para un resultado normal y erróneo.
Y en el caso del futuro, todo es más simple: extraemos del futuro un resultado normal o se nos lanza una excepción.Pero, por otro lado, podemos encontrarnos fácilmente con una cascada de errores. Por ejemplo, una excepción ocurrió en la tarea No. 1, esta excepción cayó en el objeto futuro, que se pasó a la tarea No. 2. En la tarea No. 2, tratamos de tomar el valor del futuro, pero recibimos una excepción. Y, muy probablemente, descartaremos la misma excepción. En consecuencia, caerá en el próximo futuro, que irá a la tarea No. 3. También habrá una excepción, que, posiblemente, también se lanzará. Etc.
Si se registran nuestras excepciones, entonces en el registro podemos ver la repetición repetida de la misma excepción, que va de una tarea en la cadena a otra tarea.Cancelar tareas y temporizadores / tiempos de espera
Y otra característica muy interesante de la campaña basada en tareas es la cancelación de tareas si algo sale mal. De hecho, digamos que creamos 150 tareas, completamos las 10 primeras y nos dimos cuenta de que no tenía sentido continuar el trabajo. ¿Cómo cancelamos los 140 restantes? Esta es una muy, muy buena pregunta :)Otra pregunta similar es cómo hacer que los amigos realicen tareas con temporizadores y tiempos de espera. Supongamos que estamos accediendo a un sistema externo y queremos limitar el tiempo de espera a 50 milisegundos. ¿Cómo podemos configurar el temporizador, cómo reaccionar al vencimiento del tiempo de espera, cómo interrumpir la cadena de tareas si el tiempo de espera ha expirado? Nuevamente, preguntar es más fácil que responder :)Hacer trampa
Bueno, y para hablar sobre las características del enfoque basado en tareas. En el ejemplo que se muestra, se aplicó un poco de trampa: auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); });
Aquí envié dos tareas al contexto de nuestro propio servidor HTTP, cada una de las cuales realiza una operación de bloqueo en su interior. De hecho, para poder procesar dos solicitudes a servicios de terceros en paralelo, aquí tenía que crear sus propias cadenas de tareas asincrónicas. Pero no hice esto para que la solución sea más o menos visible y se ajuste a la diapositiva de presentación.Actores / CSP vs Tareas
Examinamos tres enfoques y vimos que si los actores y los procesos de CSP son similares entre sí, entonces el enfoque basado en tareas no es como ninguno de ellos. Y podría parecer que los Actores / CSP deberían contrastarse con la Tarea.Pero personalmente, me gusta un punto de vista diferente.Cuando hablamos del Modelo de Actores y CSP, estamos hablando de la descomposición de nuestra tarea. En nuestra tarea, seleccionamos entidades independientes separadas y describimos las interfaces de estas entidades: qué mensajes envían, cuáles reciben, a través de qué canales van los mensajes.Es decir
Trabajando con actores y CSP estamos hablando de interfaces.Pero supongamos que dividimos la tarea en actores separados y procesos de CSP. ¿Cómo hacen exactamente su trabajo?Cuando adoptamos el enfoque basado en tareas, comenzamos a hablar sobre la implementación. Acerca de cómo se realiza un trabajo específico, qué operaciones secundarias se realizan, en qué orden, cómo se conectan estas operaciones secundarias según los datos, etc.Es decir
trabajando con Task estamos hablando de implementación.Por lo tanto, los actores / CSP y las tareas no son tan opuestos entre sí, sino que se complementan entre sí. Los actores / CSP se pueden utilizar para descomponer tareas y definir interfaces entre componentes. Y las tareas se pueden usar para implementar componentes específicos.Por ejemplo, cuando usamos Actor, tenemos una entidad como ImageMixer, que debe manipularse con imágenes en el grupo de subprocesos. En general, nada nos impide usar el actor ImageMixer para usar el enfoque basado en tareas.¿Dónde mirar, qué llevar?
Si desea trabajar con Tareas en C ++, puede mirar hacia la biblioteca estándar del próximo C ++ 20. Ya han agregado el método .then () al futuro, así como las funciones gratuitas wait_all () y wait_any. Ver cppreference para más detalles .También ya está lejos de una nueva biblioteca async ++ . En el que, en principio, hay todo lo que necesita, solo un poco con una salsa diferente.Y hay una biblioteca PPL de Microsoft aún más antigua . Lo que también te da todo lo que necesitas, pero con tu propia salsa.Adición separada sobre la biblioteca Intel TBB. No se mencionó en la historia sobre el enfoque basado en tareas porque, en mi opinión, los gráficos de tareas de TBB ya son un enfoque de flujo de datos. Y, si este informe continúa, la conversación sobre Intel TBB ciertamente vendrá, pero en el contexto de la historia sobre el flujo de datos.
Mas interesante
Recientemente aquí, en Habré, había un artículo de Anton Polukhin: "Nos estamos preparando para C ++ 20. Coroutines TS usando un ejemplo real ".Habla de combinar un enfoque basado en tareas con corutinas sin pila de C ++ 20. Y resultó que el código basado en la legibilidad de la tarea se acercaba a la legibilidad del código en los procesos CSP.Entonces, si alguien está interesado en el enfoque basado en tareas, entonces tiene sentido leer este artículo.Conclusión
Bueno, es hora de pasar a los resultados, ya que no hay tantos.Lo principal que quiero decir es que en el mundo moderno es posible que necesites múltiples subprocesos solo si estás desarrollando algún tipo de marco o resolviendo alguna tarea específica y de bajo nivel.Y si está escribiendo código de aplicación, entonces casi no necesita hilos desnudos, primitivas de sincronización de bajo nivel o algún tipo de algoritmo sin bloqueo junto con contenedores sin bloqueo. Durante mucho tiempo hay enfoques que han sido probados y han demostrado su eficacia:- actores
- Comunicar procesos secuenciales (CSP)
- tareas (asíncronas, promesas, futuros, ...)
- flujos de datos
- programación reactiva
- ...
Y lo más importante, hay herramientas listas para usar en C ++. No necesita realizar ningún ciclo, puede tomarlo, probarlo y, si lo desea, ponerlo en funcionamiento.Tan simple: tomar, probar y poner en funcionamiento.