Há uma história sobre uma experiência do uso do modelo de ator em um projeto interessante de desenvolvimento de um sistema de controle automático para um teatro. Abaixo vou contar minhas impressões, não mais do que isso.
Há pouco tempo, participei de uma tarefa emocionante: modernização do sistema de controle automático (ACS) para guinchos de sarrafo, mas, na verdade, era o desenvolvimento de um novo ACS.
Um teatro moderno (especialmente se for grande) é uma organização muito complexa. Existem muitas pessoas, vários mecanismos e sistemas. Um desses sistemas é o ACS para o manuseio de elevação e configuração do cenário. Apresentações modernas, como óperas e balés, usam cada vez mais meios técnicos ano após ano. O cenário é usado ativamente pelos diretores do programa e até desempenha seu próprio papel importante. Foi fascinante descobrir o que está acontecendo por trás das cortinas, porque os espectadores comuns podem ver apenas ações em cena.
Mas este é um artigo técnico, e quero compartilhar minha experiência de usar o Modelo de Ator para escrever um sistema de controle. E compartilhe minhas impressões sobre o uso de uma das estruturas de ator para C ++: SObjectizer .
Por que escolhemos essa estrutura? Estamos analisando isso há muito tempo. Existem muitos artigos em russo, e tem uma documentação maravilhosa e muitos exemplos. O projeto parece maduro. Uma breve olhada nos exemplos mostrou que os desenvolvedores do SObjectizer usam os mesmos termos (estados, temporizadores, eventos etc.) e não esperávamos grandes problemas ao estudá-lo e usá-lo. E ainda outro fator importante: a equipe do SObjectizer é útil e sempre pronta para nos ajudar. Então decidimos tentar.
O que estamos fazendo?
Vamos falar sobre o objetivo do nosso projeto. O sistema de talhas de sarrafo possui 62 sarrafos (tubos de metal). Cada sarrafo tem o comprimento de todo o estágio. Eles são suspensos em cordas em paralelo com intervalos de 30 a 40 cm, a partir da borda frontal do palco. Cada sarrafo pode ser elevado ou abaixado. Alguns deles são usados em um show para cenário. O cenário é fixo no sarrafo e é movido para cima / para baixo durante a apresentação. Os comandos dos operadores iniciam o movimento. Um sistema de "contrapeso de corda de motor" é semelhante ao usado em elevadores em edifícios residenciais. Os motores são colocados fora do palco, para que os espectadores não os vejam. Todos os motores são divididos em 8 grupos, e cada grupo possui 3 conversores de frequência (FC). No máximo três motores podem ser usados ao mesmo tempo em um grupo, cada um deles está conectado a um FC separado. Portanto, temos um sistema de 62 motores e 24 FCs, e temos que controlar esse sistema.
Nossa tarefa era desenvolver uma interface homem-máquina (HMI) para controlar esse sistema e implementar algoritmos de controle. O sistema inclui três estações de controle. Dois deles são colocados logo acima do palco, e um está na sala de máquinas (esta estação é usada por um eletricista de plantão). Também existem blocos de controle com controladores na sala de máquinas. Esses controladores executam comandos de controle, modulam a largura de pulso (PWM), ligam ou desligam os motores, controlam a posição das ripas. Duas estações de controle acima do palco têm monitores, unidades do sistema e trackballs como dispositivos apontadores. As estações de controle são conectadas via Ethernet. Cada estação de controle é conectada com blocos de controle pelo canal RS485. Ambas as estações acima do palco podem ser usadas para controlar o sistema ao mesmo tempo, mas apenas uma estação pode estar ativa. A estação ativa é selecionada por um operador; a segunda estação será passiva; a estação passiva tem seu canal RS485 desativado.
Por que atores?
Do ponto de vista dos algoritmos, o sistema é construído sobre os eventos. Dados de sensores, ações do operador, expiração de temporizadores ... Todos esses são exemplos de eventos. O modelo de ator funciona bem para esses algoritmos: os atores lidam com eventos recebidos e formam algumas ações de saída, dependendo do estado atual. Essas mecânicas estão disponíveis no SObjectizer imediatamente.
Os princípios básicos para esses sistemas são: atores interagem via mensagens assíncronas, atores têm estados e alternam de um estado para outro, apenas as mensagens que são significativas para o estado atual são tratadas.
É interessante que os atores sejam dissociados dos threads de trabalho no SObjectizer. Isso significa que você pode implementar e depurar seus atores primeiro e só depois decidir qual encadeamento de trabalho será usado para cada ator. Existem "Dispatchers" que implementam várias políticas relacionadas ao encadeamento. Por exemplo, há um expedidor que fornece um thread de trabalho separado para cada ator; existe um distribuidor de conjunto de encadeamentos que fornece um conjunto de segmentos de trabalho de tamanho fixo; existe um expedidor que executa todos os atores no mesmo encadeamento.
A presença de despachantes fornece uma maneira muito flexível de ajustar um sistema de ator para nossas necessidades. Podemos agrupar alguns atores para trabalhar no mesmo contexto. Podemos alterar o tipo de expedidor apenas com uma única linha de código. Os desenvolvedores do SObjectizer dizem que escrever um despachante personalizado não é uma tarefa complexa. Mas não havia necessidade de escrever nosso próprio despachante neste projeto; tudo o que precisávamos foi encontrado no SObjectizer.
Outra característica interessante são as cooperações de atores. Uma cooperação é um grupo de atores que pode existir se e somente se todos os atores foram iniciados com êxito. A cooperação não pode ser iniciada se pelo menos um de seus atores falhar ao iniciar. Parece que existe uma analogia entre as cooperações do SObjectizer e os pods da Kubernetes, mas também parece que as cooperações do SObjectizer apareceram anteriormente ...
Quando um ator é criado, ele é adicionado à cooperação (a cooperação pode conter apenas um ator) e é vinculado a algum despachante. É fácil criar cooperações e atores dinamicamente e os desenvolvedores do SObjectizer dizem que é uma operação bastante barata.
Todos os atores interagem entre si através de "caixas de mensagem" (mbox). É mais um conceito interessante e poderoso do SObjectizer. Ele fornece uma maneira flexível de processamento de mensagens.
A princípio, pode haver mais de um receptor de mensagens atrás de uma mbox. É bastante útil. Por exemplo, pode haver uma mbox usada pelos sensores para publicar novos dados. Os atores podem criar assinaturas para essa mbox e os atores assinados receberão os dados que desejarem. Isso permite trabalhar da maneira "Publicar / Assinar".
Em um segundo momento, os desenvolvedores do SObjectizer consideraram a possibilidade de criação de mbox personalizada. É relativamente fácil criar uma mbox personalizada com processamento especial de mensagens recebidas (como filtrar ou espalhar entre vários assinantes com base no conteúdo da mensagem).
Há também uma mbox pessoal para cada ator, e os atores podem passar uma referência a essa mbox em mensagens para outros atores (que permitem responder diretamente a um ator específico).
Em nosso projeto, dividimos todos os objetos controlados em oito grupos (um grupo para cada caixa de controle). Três threads de trabalho foram criados para cada grupo (é porque apenas três mecanismos podem funcionar ao mesmo tempo). Isso nos permitiu ter independência entre grupos de motores. Também permitiu trabalhar de forma assíncrona com os mecanismos dentro de cada grupo.
É necessário mencionar que o SObjectizer-5 não possui mecanismos para interprocesso ou interação de rede. Esta é uma decisão consciente dos desenvolvedores do SObjectizer; eles queriam tornar o SObjectizer o mais leve possível. Além disso, o suporte transparente à rede já existia em algumas versões anteriores do SObjectizer, mas foi removido. Isso não nos incomodou porque um mecanismo para a rede depende muito de uma tarefa, protocolos usados e outras condições. Não existe uma solução universal única para todos os casos.
No nosso caso, usamos nossa antiga biblioteca libuniset2 para comunicações de rede e interprocessos. Como resultado, o libuniset2 suporta comunicações com sensores e blocos de controle, e o SObjectizer suporta atores e interações entre atores dentro de um único processo.
Como eu disse anteriormente, existem 62 motores. Todo mecanismo pode ser conectado a um FC (conversor de frequência); uma coordenada de destino pode ser especificada para o sarrafo correspondente; a velocidade do movimento da barra também pode ser especificada. Além disso, todo mecanismo tem os seguintes estados:
- pronto para trabalhar;
- conectado;
- trabalhando;
- mau funcionamento;
- conectando (um estado de transição);
- desconectar (um estado de transição);
Cada mecanismo é representado no sistema por um ator que implementa a transição entre estados, manipulando dados de sensores e emitindo comandos. Não é difícil criar um ator no SObjectizer: apenas herde sua classe do tipo so_5::agent_t
. O primeiro argumento do construtor do ator deve ser do tipo context_t
, todos os outros argumentos podem ser definidos como o desenvolvedor desejar.
class Drive_A: public so_5::agent_t { public: Drive_A( context_t ctx, ... ); ... }
Não mostrarei a descrição detalhada de classes e métodos porque não é um tutorial. Eu só quero mostrar o quão fácil tudo isso pode ser feito no SObjectizer (em algumas linhas, literalmente). Deixe-me lembrá-lo de que o SObjectizer possui uma excelente documentação e muitos exemplos.
Qual é o "estado" de um ator? Do que estamos falando?
O uso de estados e a transição entre eles é um "tópico nativo" para sistemas de controle. Este conceito é muito bom para manipulação de eventos. Esse conceito é suportado no SObjectizer no nível da API. Os estados são declarados dentro da classe do ator:
class Drive_A final: public so_5::agent_t { public: Drive_A( context_t ctx, ... ); virtual ~Drive_A();
e, em seguida, os manipuladores de eventos são definidos para cada estado. Às vezes é necessário fazer algo ao entrar ou sair de um estado. Isso também é suportado no SObjectizer através dos manipuladores on_enter / on_exit. Parece que os desenvolvedores do SObjectizer têm experiência no desenvolvimento de sistemas de controle.
Manipuladores de eventos
Um manipulador de eventos é um local onde a lógica do aplicativo é implementada. Como eu disse anteriormente, uma assinatura é criada para uma mbox específica e um estado específico. Se um ator não tiver estados especificados explicitamente, ele estará em um "estado_ padrão" especial.
Manipuladores diferentes podem ser definidos para o mesmo evento em diferentes estados. Se você não definir um manipulador para algum evento, esse evento será ignorado (um ator não saberá sobre isso).
Existe uma sintaxe simples para definir manipuladores de eventos. Você especifica um método e não há necessidade de especificar tipos ou parâmetros de modelo adicionais. Por exemplo:
so_subscribe(drv->so_mbox()) .in(st_base) .event( &Drive_A::on_get_info ) .event( &Drive_A::on_control ) .event( &Drive_A::off_control );
É um exemplo de inscrição em eventos de uma mbox específica no estado st_base. Vale ressaltar que st_base é um estado base para alguns outros estados e que a assinatura será herdada pelos estados derivados. Essa abordagem permite livrar-se de copiar e colar para manipuladores de eventos semelhantes em diferentes estados. Mas o manipulador de eventos herdado pode ser redefinido para um estado específico ou um evento pode ser completamente desativado ("suprimido").
Outra maneira de definir manipuladores de eventos é usar funções lambda. É uma maneira muito conveniente, pois os manipuladores de eventos geralmente contêm apenas uma ou duas linhas de código: um envio de algo para algum lugar ou uma alteração 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(); });
Essa sintaxe parece complexa no começo, mas se torna familiar logo após alguns dias de codificação ativa e você começa a gostar. É porque toda a lógica de algum ator pode ser concisa e colocada em uma tela. No exemplo mostrado acima, há transições de st_disconnected para st_off ou st_protection. Este código é fácil de ler.
BTW, para casos diretos, onde apenas uma transição de estado é necessária, há uma sintaxe 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);
O controle
Como o controle é organizado? Como mencionado acima, existem duas estações de controle para controlar o movimento das ripas. Cada estação de controle possui uma tela, um dispositivo apontador (trackball) e um regulador de velocidade (e não contamos um computador dentro da estação e alguns acessórios adicionais).
Existem dois modos de controle: manual e "modo de cenário". O "modo de cenário" será discutido mais tarde, e agora vamos falar sobre o modo manual. Nesse modo, um operador seleciona uma barra, prepara-a para o movimento (conecta o motor a um FC), define a marca alvo para a barra e, quando a velocidade é definida acima de zero, a barra começa a se mover.
O regulador de velocidade é um acessório físico na forma de um "potenciômetro com alça", mas também há um virtual mostrado no visor da estação. Quanto mais ele gira, maior é a velocidade de movimento. A velocidade máxima é limitada a 1,5 metros por segundo. O regulador de velocidade é um para todas as ripas. Isso significa que todas as ripas selecionadas se movem na mesma velocidade. As ripas podem se mover em direções opostas (depende da seleção do operador). É aparente que é difícil para um humano controlar mais do que algumas ripas. Por esse motivo, apenas pequenos grupos de ripas são manuseados no modo manual. Os operadores podem controlar ripas de duas estações de controle ao mesmo tempo. Portanto, há um regulador de velocidade separado para cada estação.
Do ponto de vista da implementação, não há lógica específica no modo manual. Um comando "conectar mecanismo" sai da interface gráfica, é transformado em uma mensagem correspondente a um ator e, em seguida, está sendo tratado por esse ator. O ator passa do estado "desligado" para "conectando" e depois para o estado "conectado". Coisas semelhantes acontecem com os comandos para posicionar uma barra e definir a velocidade do movimento. Todos esses comandos são passados para um ator na forma de mensagens. Mas vale ressaltar que "interface gráfica" e "processo de controle" são processos separados e o libuniset2 é usado para o IPC.
O modo Cenário (existem atores de novo?)
Na prática, o modo manual é usado apenas para casos muito simples ou durante os ensaios. O modo de controle principal é "modo de cenário". Nesse modo, cada barra é movida para uma posição específica com velocidade específica, de acordo com as configurações do cenário. Dois comandos simples estão disponíveis para um operador nesse modo:
- prepare (um grupo de motores está sendo conectado ao FC);
- ir (o movimento do grupo começa).
Todo o cenário é dividido em "agendas". Uma "agenda" descreve um único movimento de um grupo de sarrafos. Isso significa que uma "agenda" inclui alguns sarrafos e contém destinos e velocidades desejados para eles. Na realidade, um cenário consiste em atos, atos consistem em imagens, imagem consiste em agendas e agenda consiste em alvos para sarrafos. Mas, do ponto de vista do controle, isso não importa, porque apenas as agendas contêm os parâmetros precisos do movimento da barra.
O modelo do ator se encaixa perfeitamente nesse caso. Nós desenvolvemos um "player de cenário" que gera um grupo de atores especiais e os inicia. Desenvolvemos dois tipos de atores: atores executores (eles controlam o movimento das ripas) e atores coordenadores (eles distribuem tarefas entre os executores). Executores são criados sob demanda: quando não há executores livres, um novo executor será criado. O coordenador gerencia o pool de executores disponíveis. Como resultado, o controle é mais ou menos assim:
- um operador carrega um cenário;
- "rola" até a agenda necessária;
- pressiona o botão "preparar" no momento apropriado. Nesse momento, uma mensagem é enviada para um coordenador. Esta mensagem contém dados para todos os sarrafos da agenda;
- o coordenador analisa seu conjunto de executores e distribui tarefas entre executores livres (novos executores são criados, se necessário);
- cada executor recebe uma tarefa e executa ações de preparação (conecta um mecanismo a um FC e aguarda o comando "go");
- o operador pressiona o botão "go" no momento apropriado;
- o comando "go" vai para o coordenador e distribui o comando entre todos os executores atualmente em uso.
Existem alguns parâmetros adicionais nas agendas. Como "iniciar o movimento somente após um atraso de N segundos" ou "iniciar o movimento somente após um comando adicional de um operador". Por esse motivo, a lista de estados de um executor é bastante longa: "pronto para o próximo comando", "pronto para movimento", "atraso do movimento", "aguardando comando do operador", "movendo", "concluído", "falha".
Quando uma barra atinge com sucesso a marca de destino (ou há uma falha), o executor informa o coordenador sobre a conclusão da tarefa. O coordenador responde com um comando para desligar o mecanismo (se o sarrafo não participa mais da agenda) ou envia uma nova tarefa ao executor. O executor desliga o mecanismo e passa para o estado "em espera" ou inicia o processamento do novo comando.
Como o SObjectizer possui uma API bastante atenciosa e conveniente para trabalhar com estados, o código de implementação acabou sendo bastante conciso. Por exemplo, um atraso antes do movimento é descrito apenas por uma linha de código:
st_delay.time_limit( std::chrono::milliseconds{target->delay()}, st_moving ); st_delay.activate(); ...
O método time_limit
especifica a quantidade de tempo para permanecer no estado e qual estado deve ser ativado em seguida ( st_moving
nesse exemplo).
Atores de proteção
Certamente, podem ocorrer falhas. Existem requisitos para lidar corretamente com essas falhas. Os atores também são usados para essas tarefas. Vejamos alguns exemplos:
- proteção contra sobrecorrente;
- proteção contra mau funcionamento do sensor;
- proteção contra movimento na direção oposta (isso pode acontecer se houver algo errado com sensores ou atuadores);
- proteção contra movimento espontâneo (sem comando);
- controle de execução de comando (o movimento de uma barra deve ser verificado).
Podemos ver que todos esses casos são auto-suficientes, mas devem ser controlados juntos, ao mesmo tempo. Isso significa que qualquer falha pode acontecer. Mas toda verificação tem sua lógica: às vezes é necessário verificar um tempo limite, às vezes é necessário analisar alguns valores anteriores de um sensor. Por esse motivo, a proteção é implementada na forma de pequenos atores. Esses atores são adicionados à cooperação com o ator principal que implementa a lógica de controle. Essa abordagem permite a adição fácil de novos casos de proteção: basta adicionar mais um ator protetor à cooperação. O código desse ator geralmente é conciso e fácil de entender, porque implementa apenas uma função.
Os atores protetores também têm vários estados. Geralmente, eles são ligados quando um motor é ligado ou quando um sarrafo inicia seu movimento. Quando um protetor detecta uma falha / mau funcionamento, ele publica uma notificação (com código de proteção e alguns detalhes adicionais). O ator principal reage a essa notificação e executa as ações necessárias (como desligar o mecanismo e mudar para o estado protegido).
Como conclusão ...
... este artigo não é um avanço, é claro. O modelo de ator está sendo usado em vários sistemas diferentes por um longo tempo. Mas foi minha primeira experiência no uso do modelo de ator para construir um sistema de controle automático em um projeto bastante pequeno. E essa experiência acabou sendo bastante bem-sucedida. Espero ter demonstrado que os atores se encaixam bem nos algoritmos de controle: existem lugares para atores literalmente em todo lugar.
Implementamos algo semelhante em projetos anteriores (refiro-me a estados, troca de mensagens, gerenciamento de threads de trabalho etc.), mas não era uma abordagem unificada. Usando o SObjectizer, obtemos uma ferramenta pequena e leve que resolve muitos problemas. Não precisamos mais (explicitamente) usar mecanismos de sincronização de baixo nível (como mutexes), não há gerenciamento manual de threads, nem mais statecharts manuscritos. Tudo isso é fornecido pela estrutura, logicamente conectado e expresso na forma de API conveniente, mas você não perde o controle sobre os detalhes. Então foi uma experiência emocionante. Se você ainda estiver em dúvida, recomendo que você dê uma olhada no Modelo do ator e no SObjectizer em particular. Deixa emoções positivas.
O modelo do ator realmente funciona! Especialmente no teatro.
Artigo original em russo