Si su proyecto es "Teatro", use actores

Hay una historia sobre una experiencia de usar Actor Model en un proyecto interesante de desarrollar un sistema de control automático para un teatro. A continuación les contaré mis impresiones, no más que eso.


No hace mucho tiempo participé en una tarea emocionante: la modernización del sistema de control automático (ACS) para los polipastos, pero de hecho fue el desarrollo de un nuevo ACS.


Un teatro moderno (especialmente si es grande) es una organización muy compleja. Hay muchas personas, varios mecanismos y sistemas. Uno de estos sistemas es ACS para el manejo de levantar y establecer el paisaje. Actuaciones modernas, como óperas y ballets, utilizan cada vez más medios técnicos año tras año. El escenario es utilizado activamente por los directores del espectáculo e incluso juega su propio papel importante. Fue fascinante descubrir lo que sucede detrás de las cortinas porque los espectadores comunes solo pueden ver acciones en la escena.


Pero este es un artículo técnico, y quiero compartir mi experiencia de usar el Modelo de actor para escribir un sistema de control. Y comparta mis impresiones sobre el uso de uno de los marcos de actores para C ++: SObjectizer .


¿Por qué elegimos este marco? Lo hemos estado mirando durante mucho tiempo. Hay muchos artículos en ruso, y tiene una documentación maravillosa y muchos ejemplos. El proyecto parece maduro. Una breve mirada a los ejemplos ha demostrado que los desarrolladores de SObjectizer usan los mismos términos (estados, temporizadores, eventos, etc.) y no esperábamos grandes problemas para estudiarlo y usarlo. Y otro factor importante: el equipo de SObjectizer es útil y siempre está listo para ayudarnos. Entonces decidimos intentarlo.


Que estamos haciendo


Hablemos sobre el objetivo de nuestro proyecto. El sistema de polipastos de listones tiene 62 listones (tubos de metal). Cada listón es tan largo como toda la etapa. Se suspenden en cuerdas en paralelo con espacios de 30-40 cm, comenzando desde el borde frontal del escenario. Cada listón se puede subir o bajar. Algunos de ellos se utilizan en un espectáculo para decorar. El escenario se fija en el listón y se mueve hacia arriba / abajo durante la actuación. Los comandos de los operadores inician el movimiento. Un sistema de "contrapeso de la cuerda del motor" es similar a uno utilizado en ascensores en edificios residenciales. Los motores se colocan fuera del escenario, para que los espectadores no los vean. Todos los motores se dividen en 8 grupos, y cada grupo tiene 3 convertidores de frecuencia (FC). Como máximo se pueden usar tres motores al mismo tiempo en un grupo, cada uno de ellos está conectado a un FC separado. Entonces, tenemos un sistema de 62 motores y 24 FC, y tenemos que controlar este sistema.


Nuestra tarea era desarrollar una interfaz hombre-máquina (HMI) para controlar este sistema e implementar algoritmos de control. El sistema incluye tres estaciones de control. Dos de ellos se colocan justo por encima del escenario, y uno está en la sala de máquinas (un electricista de servicio utiliza esta estación). También hay bloques de control con controladores en la sala de máquinas. Estos controladores realizan comandos de control, modulan el ancho de pulso (PWM), encienden o apagan los motores, controlan la posición de los listones. Dos estaciones de control sobre el escenario tienen pantallas, unidades de sistema y trackballs como dispositivos señaladores. Las estaciones de control están conectadas a través de Ethernet. Cada estación de control está conectada con bloques de control por el canal RS485. Ambas estaciones sobre el escenario se pueden usar para controlar el sistema al mismo tiempo, pero solo una estación puede estar activa. La estación activa es seleccionada por un operador; la segunda estación será pasiva; la estación pasiva tiene su canal RS485 deshabilitado.


¿Por qué actores?


Desde el punto de vista de los algoritmos, el sistema está construido sobre eventos. Datos de sensores, acciones del operador, caducidad de temporizadores ... Todos estos son ejemplos de eventos. El modelo de actor funciona bien para tales algoritmos: los actores manejan los eventos entrantes y forman algunas acciones salientes dependiendo de su estado actual. Estas mecánicas están disponibles en SObjectizer recién listas para usar.


Los principios básicos para tales sistemas son: los actores interactúan a través de mensajes asincrónicos, los actores tienen estados y cambian de un estado a otro, solo se manejan los mensajes que son significativos para el estado actual.


Es interesante que los actores estén desconectados de los hilos de trabajo en SObjectizer. Significa que puede implementar y depurar a sus actores primero y solo luego decidir qué hilo de trabajo se usará para cada actor. Hay "Despachadores" que implementan varias políticas relacionadas con hilos. Por ejemplo, hay un despachador que proporciona un hilo de trabajo separado para cada actor; hay un despachador de grupo de subprocesos que proporciona un grupo de subprocesos de trabajo de tamaño fijo; Hay un despachador que ejecuta todos los actores en el mismo hilo.


La presencia de despachadores proporciona una forma muy flexible de ajustar un sistema de actores para nuestras necesidades. Podemos agrupar algunos actores para trabajar en el mismo contexto. Podemos cambiar el tipo de despachador con solo una línea de código. Los desarrolladores de SObjectizer dicen que escribir un despachador personalizado no es una tarea compleja. Pero no había necesidad de escribir nuestro propio despachador en este proyecto; todo lo que necesitábamos se encontró en SObjectizer.


Otra característica interesante son las cooperaciones de actores. Una cooperación es un grupo de actores que puede existir si y solo si todos los actores han comenzado con éxito. La cooperación no puede iniciarse si al menos uno de sus actores no ha podido comenzar. Parece que hay una analogía entre las cooperaciones de SObjectizer y las vainas de Kubernetes, pero también parece que las cooperaciones de SObjectizer han aparecido antes ...


Cuando se crea un actor, se agrega a la cooperación (la cooperación puede contener solo un actor) y está vinculado a algún despachador. Es fácil crear cooperaciones y actores dinámicamente y los desarrolladores de SObjectizer dicen que es una operación bastante barata.


Todos los actores interactúan entre sí a través de "cuadros de mensaje" (mbox). Es otro concepto de SObjectizer interesante y poderoso. Proporciona una forma flexible de procesamiento de mensajes.


Al principio, puede haber más de un receptor de mensajes detrás de un mbox. Es bastante útil. Por ejemplo, puede haber un mbox que utilizan los sensores para publicar nuevos datos. Los actores pueden crear suscripciones para ese mbox, y los actores suscritos recibirán los datos que deseen. Esto permite trabajar de manera "Publicar / Suscribir".


En segundo lugar, los desarrolladores de SObjectizer han previsto la posibilidad de crear mbox personalizados. Es relativamente fácil crear un mbox personalizado con procesamiento especial de mensajes entrantes (como filtrar o distribuir entre varios suscriptores en función del contenido del mensaje).


También hay un mbox personal para cada actor y los actores pueden pasar una referencia a ese mbox en mensajes a otros actores (que permite responder directamente a un actor específico).


En nuestro proyecto dividimos todos los objetos controlados en ocho grupos (un grupo para cada cuadro de control). Se crearon tres subprocesos de trabajo para cada grupo (se debe a que solo tres motores pueden funcionar al mismo tiempo). Nos permitió tener independencia entre grupos de motores. También permitió trabajar de forma asíncrona con motores dentro de cada grupo.


Es necesario mencionar que SObjectizer-5 no tiene mecanismos para la interacción entre procesos o redes. Esta es una decisión consciente de los desarrolladores de SObjectizer; querían hacer SObjectizer lo más ligero posible. Además, el soporte transparente para redes había existido en algunas versiones anteriores de SObjectizer pero fue eliminado. No nos molestó porque un mecanismo para la red depende en gran medida de una tarea, los protocolos utilizados y otras condiciones. No existe una solución universal única para todos los casos.


En nuestro caso, utilizamos nuestra antigua biblioteca libuniset2 para comunicaciones de red e interproceso. Como resultado, libuniset2 admite comunicaciones con sensores y bloques de control, y SObjectizer admite actores e interacciones entre actores dentro de un solo proceso.


Como dije anteriormente, hay 62 motores. Cada motor se puede conectar a un FC (convertidor de frecuencia); se puede especificar una coordenada de destino para el listón correspondiente; También se puede especificar la velocidad del movimiento del listón. Y además de eso, cada motor tiene los siguientes estados:


  • listo para trabajar
  • conectado
  • trabajando
  • mal funcionamiento
  • conexión (un estado de transición);
  • desconexión (un estado de transición);

Cada motor está representado en el sistema por un actor que implementa la transición entre estados, manejando datos de sensores y emitiendo comandos. No es difícil crear un actor en SObjectizer: simplemente herede su clase del tipo so_5::agent_t . El primer argumento del constructor del actor debe ser de tipo context_t , todos los demás argumentos pueden definirse como lo desee un desarrollador.


 class Drive_A: public so_5::agent_t { public: Drive_A( context_t ctx, ... ); ... } 

No mostraré la descripción detallada de clases y métodos porque no es un tutorial. Solo quiero mostrar lo fácil que se puede hacer todo en SObjectizer (literalmente en unas pocas líneas). Permítame recordarle que SObjectizer tiene una excelente documentación y muchos ejemplos.


¿Cuál es el "estado" de un actor? De que estamos hablando


El uso de estados y la transición entre ellos es un "tema nativo" para los sistemas de control. Este concepto es muy bueno para el manejo de eventos. Este concepto es compatible con SObjectizer a nivel de API. Los estados se declaran dentro de la clase del actor:


 class Drive_A final: public so_5::agent_t { public: Drive_A( context_t ctx, ... ); virtual ~Drive_A(); //  state_t st_base {this}; state_t st_disabled{ initial_substate_of{st_base}, "disabled" }; state_t st_preinit{ substate_of{st_base}, "preinit" }; state_t st_off{ substate_of{st_base}, "off" }; state_t st_connecting{ substate_of{st_base}, "connecting" }; state_t st_disconnecting{ substate_of{st_base}, "disconnecting" }; state_t st_connected{ substate_of{st_base}, "connected" }; ... } 

y luego los controladores de eventos se definen para cada estado. A veces es necesario hacer algo al entrar o salir de un estado. Esto también es compatible con SObjectizer a través de controladores on_enter / on_exit. Parece que los desarrolladores de SObjectizer tienen experiencia en el desarrollo de sistemas de control.


Controladores de eventos


Un controlador de eventos es un lugar donde se implementa la lógica de su aplicación. Como dije anteriormente, se crea una suscripción para un mbox particular y un estado específico. Si un actor no tiene estados especificados explícitamente, se encuentra en un "estado_de_determinado" especial.


Se pueden definir diferentes controladores para el mismo evento en diferentes estados. Si no define un controlador para algún evento, este evento será ignorado (un actor no lo sabrá).


Hay una sintaxis simple para definir controladores de eventos. Usted especifica un método y no es necesario especificar tipos adicionales o parámetros de plantilla. Por ejemplo:


 so_subscribe(drv->so_mbox()) .in(st_base) .event( &Drive_A::on_get_info ) .event( &Drive_A::on_control ) .event( &Drive_A::off_control ); 

Es un ejemplo de suscripción en eventos desde un mbox específico en el estado st_base. Vale la pena mencionar que st_base es un estado base para algunos otros estados y que la suscripción será heredada por los estados derivados. Este enfoque permite deshacerse de copiar y pegar para controladores de eventos similares en diferentes estados. Pero el controlador de eventos heredado puede redefinirse para un estado particular o un evento puede deshabilitarse por completo ("suprimirse").


Otra forma de definir manejadores de eventos es usar funciones lambda. Es una forma muy conveniente porque los controladores de eventos a menudo contienen solo una o dos líneas de código: un envío de algo a algún lugar o un cambio de estado:


 so_subscribe(drv->so_mbox()) .in(st_disconnecting) .event([this](const msg_disconnected_t& m) { ... st_off.activate(); }) .event([this]( const msg_failure_t& m ) { ... st_protection.activate(); }); 

Esa sintaxis parece compleja al principio, pero se vuelve familiar justo después de un par de días de codificación activa e incluso comienza a gustarle. Es porque toda la lógica de algún actor puede ser concisa y ubicarse en una sola pantalla. En el ejemplo que se muestra arriba, hay transiciones de st_disconnected a st_off o st_protection. Este código es fácil de leer.


Por cierto, para casos sencillos, donde solo es necesaria una transición de estado, hay una sintaxis especial:


 auto mbox = drv->so_mbox(); st_off .just_switch_to<msg_connected_t>(mbox, st_connected) .just_switch_to<msg_failure_t>(mbox, st_protection) .just_switch_to<msg_on_limit_t>(mbox, st_protection) .just_switch_to<msg_on_t>(mbox, st_on); 

El control


¿Cómo se organiza el control? Como se mencionó anteriormente, hay dos estaciones de control para controlar el movimiento de los listones. Cada estación de control tiene una pantalla, un dispositivo señalador (trackball) y un programador de velocidad (y no contamos con una computadora dentro de la estación y algunos accesorios adicionales).


Hay dos modos de control: manual y "modo de escenario". El "modo de escenario" se discutirá más adelante, y ahora hablemos del modo manual. En este modo, un operador selecciona un listón, lo prepara para el movimiento (conecta el motor a un FC), establece la marca de objetivo para el listón y cuando la velocidad se establece por encima de cero, el listón comienza a moverse.


El ajustador de velocidad es un accesorio físico en forma de "potenciómetro con mango", pero también se muestra uno virtual en la pantalla de la estación. Cuanto más se gira, mayor es la velocidad de movimiento. La velocidad máxima está limitada a 1,5 metros por segundo. El setter de velocidad es uno para todos los listones. Significa que todos los listones seleccionados se mueven a la misma velocidad. Los listones pueden moverse en direcciones opuestas (depende de la selección del operador). Es evidente que es difícil para un humano controlar más que unos pocos listones. Debido a eso, solo pequeños grupos de listones se manejan en el modo manual. Los operadores pueden controlar listones desde dos estaciones de control al mismo tiempo. Por lo tanto, hay un configurador de velocidad separado para cada estación.


Desde el punto de vista de la implementación, no existe una lógica específica en el modo manual. Un comando "conectar motor" va desde la interfaz gráfica, se transforma en un mensaje correspondiente a un actor y luego ese actor lo maneja. El actor pasa del estado "apagado" a "conectado", y luego al estado "conectado". Suceden cosas similares con los comandos para colocar un listón y establecer la velocidad de movimiento. Todos estos comandos se pasan a un actor en forma de mensajes. Pero vale la pena mencionar que "interfaz gráfica" y "proceso de control" son procesos separados y libuniset2 se utiliza para IPC.


El modo Escenario (¿hay actores nuevamente?)


En la práctica, el modo manual se usa solo para casos muy simples o durante los ensayos. El modo de control principal es el "modo de escenario". En ese modo, cada listón se mueve a una posición específica con una velocidad particular de acuerdo con la configuración del escenario. Dos comandos simples están disponibles para un operador en ese modo:


  • prepare (se está conectando un grupo de motores a FC);
  • ir (comienza el movimiento del grupo).

Todo el escenario se divide en "agendas". Una "agenda" describe un movimiento único de un grupo de listones. Significa que una "agenda" incluye algunos listones y contiene destinos de destino y velocidades para ellos. En realidad, un escenario consiste en actos, los actos consisten en imágenes, la imagen consiste en agendas y la agenda consiste en objetivos para listones. Pero desde el punto de vista del control, eso no importa, porque solo las agendas contienen los parámetros precisos del movimiento del listón.


El modelo de actor se adapta perfectamente a ese caso. Hemos desarrollado un "jugador de escenario" que genera un grupo de actores especiales y los inicia. Hemos desarrollado dos tipos de actores: actores ejecutores (controlan el movimiento de los listones) y actores coordinadores (distribuyen tareas entre los ejecutores). Los ejecutores se crean a pedido: cuando no hay ejecutores libres, se creará un nuevo ejecutor. El coordinador gestiona el grupo de ejecutores disponibles. Como resultado, el control se ve más o menos así:


  • un operador carga un escenario;
  • "lo desplaza" hasta la agenda requerida;
  • presiona el botón "preparar" en el momento apropiado. En ese momento se envía un mensaje a un coordinador. Este mensaje contiene datos para cada listón de la agenda;
  • el coordinador revisa su grupo de ejecutores y distribuye tareas entre los ejecutores libres (se crean nuevos ejecutores, si es necesario);
  • cada ejecutor recibe una tarea y realiza acciones de preparación (conecta un motor a un FC, luego espera el comando "ir");
  • el operador presiona el botón "ir" en el momento apropiado;
  • el comando "ir" va al coordinador y distribuye el comando entre todos los ejecutores que están actualmente en uso.

Hay algunos parámetros adicionales en las agendas. Como "iniciar el movimiento solo después de N segundos de retraso" o "iniciar el movimiento solo después de un comando adicional de un operador". Debido a eso, la lista de estados para un ejecutor es bastante larga: "listo para el próximo comando", "listo para el movimiento", "demora del movimiento", "en espera del comando del operador", "en movimiento", "completado", "fracaso".


Cuando un listón ha alcanzado con éxito la marca de destino (o hay una falla), el ejecutor informa al coordinador sobre la finalización de la tarea. El coordinador responde con un comando para apagar el motor (si el listón ya no participa en la agenda) o envía una nueva tarea al ejecutor. El ejecutor apaga el motor y cambia al estado de "espera" o comienza a procesar el nuevo comando.


Debido a que SObjectizer tiene una API bastante reflexiva y conveniente para trabajar con estados, el código de implementación resultó ser bastante conciso. Por ejemplo, un retraso antes del movimiento se describe con solo una línea de código:


 st_delay.time_limit( std::chrono::milliseconds{target->delay()}, st_moving ); st_delay.activate(); ... 

El método time_limit especifica la cantidad de tiempo para permanecer en el estado y qué estado debe activarse en ese momento ( st_moving en ese ejemplo).


Actores de protección


Ciertamente, pueden ocurrir fallas. Hay requisitos para manejar correctamente estas fallas. Los actores también se usan para tales tareas. Veamos algunos ejemplos:


  • protección contra sobrecorriente;
  • protección contra el mal funcionamiento del sensor;
  • protección contra el movimiento en la dirección opuesta (puede suceder si hay algo mal con los sensores o actuadores);
  • protección contra movimientos espontáneos (sin comando);
  • control de ejecución del comando (se debe verificar el movimiento de un listón).

Podemos ver que todos esos casos son autosuficientes, pero deben controlarse juntos, al mismo tiempo. Significa que cualquier falla puede suceder. Pero cada verificación tiene su lógica: a veces es necesario verificar un tiempo de espera, a veces es necesario analizar algunos valores anteriores de un sensor. Por eso, la protección se implementa en forma de pequeños actores. Estos actores se agregan a la cooperación con el actor principal que implementa la lógica de control. Este enfoque permite agregar fácilmente nuevos casos de protección: simplemente agregue otro actor protector a la cooperación. El código de dicho actor suele ser conciso y fácil de entender, ya que implementa una sola función.


Los actores protectores también tienen varios estados. Por lo general, se encienden cuando se enciende un motor o cuando un listón comienza su movimiento. Cuando un protector detecta una falla / mal funcionamiento, publica una notificación (con el código de protección y algunos detalles adicionales dentro). El actor principal reacciona a esa notificación y realiza las acciones necesarias (como apagar el motor y cambiar al estado protegido).


Como conclusión ...


... este artículo no es un gran avance, por supuesto. El modelo de actor se está utilizando en múltiples sistemas diferentes durante mucho tiempo. Pero fue mi primera experiencia de usar el modelo de actor para construir un sistema de control automático en un proyecto bastante pequeño. Y esta experiencia resultó ser bastante exitosa. Espero haber demostrado que los actores se ajustan bien a los algoritmos de control: hay lugares para actores literalmente en todas partes.


Habíamos implementado algo similar en proyectos anteriores (me refiero a estados, intercambio de mensajes, gestión de hilos de trabajo, etc.), pero no era un enfoque unificado. Al usar SObjectizer obtuvimos una herramienta pequeña y liviana que resuelve muchos problemas. Ya no necesitamos usar (explícitamente) mecanismos de sincronización de bajo nivel (como mutexes), no hay administración de subprocesos manual, no hay más gráficos de estado escritos a mano. Todo esto lo proporciona el marco, conectado lógicamente y expresado en forma de API conveniente, pero no pierde el control de los detalles. Fue una experiencia emocionante. Si aún tiene dudas, le recomiendo que eche un vistazo al Modelo de actor y SObjectizer en particular. Deja emociones positivas.


¡El modelo de actor realmente funciona! Especialmente en el teatro.


Artículo original en ruso

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


All Articles