Uma cobra na caixa de correio e o que faz F #

O que é isso tudo?


É tudo sobre a cobra. Todo mundo se lembra do que é uma cobra: uma cobra se move em um campo retangular. Encontra comida - cresce em comprimento, encontra-se ou à beira do campo - morre. E o usuário pode enviar apenas comandos: esquerda, direita, cima, baixo.
Decidi acrescentar alguma ação aqui e fazer a cobra fugir do pacman. E tudo isso nos atores!


Portanto, hoje usarei o exemplo de uma cobra para falar sobre como construir um modelo de ator usando o MailboxProcessor da biblioteca padrão, quais pontos procurar e quais armadilhas você pode esperar.


O código escrito aqui não é perfeito, pode violar alguns princípios e pode ser melhor escrito. Mas se você é iniciante e deseja lidar com caixas de correio - espero que este artigo o ajude.
Se você sabe tudo sobre caixas de correio sem mim, pode estar entediado aqui.


Por que atores?


Por uma questão de prática. Eu li sobre o modelo de atores, assisti ao vídeo, gostei de tudo, mas não tentei. Agora eu tentei.
Apesar de, de fato, ter escolhido a tecnologia em prol da tecnologia, o conceito recaiu com muito sucesso nessa tarefa.


Por que MailboxProcessor, e não, por exemplo, Akka.net?


Para minha tarefa, o MailboxProcessor é da estação orbital por pardais, o MailboxProcessor muito mais simples e faz parte da biblioteca padrão, portanto, você não precisa conectar nenhum pacote.


Sobre processadores de caixa de correio e clichê relacionado


O ponto é simples. A caixa de correio interna possui um loop de mensagens e algum estado. Seu loop de mensagens atualizará esse estado de acordo com a nova mensagem que chegar.


 let actor = MailboxProcessor.Start(fun inbox -> // ,    //   . inbox --    MailboxProcessor let rec messageLoop oldState = async { //   let! msg = inbox.Receive() //    let newState = updateState oldState msg //      return! messageLoop newState } //       .    --     messageLoop (0,0) ) 

Observe que messageLoop recursivo e, no final, deve ser chamado novamente, caso contrário, apenas uma mensagem será processada, após a qual esse ator morrerá. messageLoop também messageLoop assíncrono e cada iteração subsequente é executada quando uma nova mensagem é recebida: let! msg = inbox.Receive() let! msg = inbox.Receive() .
Assim, toda a carga lógica vai para a função updateState , o que significa que, para criar a caixa de correio do processador, podemos criar uma função construtora que aceite uma função de atualização de estado e um estado zero:


 //   applyMessage       //      (fun inbox -> ...) let buildActor applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() let newState = applyMessage state msg return! loop newState } loop zeroState ) 

Legal! Agora não precisamos monitorar constantemente para não esquecer o return! loop newState return! loop newState . Como você sabe, um ator armazena um estado, mas agora não está completamente claro como obter esse estado de fora. A caixa de correio do processador possui um método PostAndReply , que AsyncReplyChannel<'Reply> -> 'Msg a função AsyncReplyChannel<'Reply> -> 'Msg como entrada. No começo, ele me levou a um estupor - não é totalmente claro de onde obter essa função. Mas, na realidade, tudo ficou mais simples: todas as mensagens devem ser agrupadas em um invólucro de DU, já que agora temos duas operações em nosso ator: envie a própria mensagem e solicite o estado atual. Aqui está o que parece:


 //     . // Mail<_,_>   ,  Post & Get --  . // F#       , //   compare & equals . //         --   . //   [<Struct>] .       type Mail<'msg, 'state> = | Post of 'msg | Get of AsyncReplyChannel<'state> 

Nossa função construtora agora se parece com isso:


 let buildActor applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() //    ,     // .    -- ,     //     . //     --      //    .      ! match msg with | Post msg -> let newState = applyMessage state msg return! loop newState | Get channel -> channel.Reply state return! loop state } loop zeroState ) 

Agora, para trabalhar com a caixa de correio, precisamos Mail.Post todas as nossas mensagens neste Mail.Post . Para não escrever isso toda vez, é melhor envolvê-lo em um aplicativo pequeno:


 module Mailbox = let buildAgent applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() match msg with | Post msg -> let newState = applyMessage state msg return! loop newState | Get channel -> channel.Reply state return! loop state } loop zeroState ) let post (agent: MailboxProcessor<_>) msg = Post msg |> agent.Post let getState (agent: MailboxProcessor<_>) = agent.PostAndReply Get let getStateAsync (agent: MailboxProcessor<_>) = agent.PostAndAsyncReply Get //   Single Case Discriminated Union. // MailboxProcessor   API.      , //  -  ,  ,      //   .       ,     //        . type MailAgent<'msg, 'state> = MailAgent of address:string * mailbox:MailboxProcessor<Mail<'msg, 'state>> //     API with member this.Post msg = //            let (MailAgent (address,this)) = this Mailbox.post this msg member this.GetState() = let (MailAgent (address,this)) = this Mailbox.getState this member this.GetStateAsync() = let (MailAgent (address,this)) = this Mailbox.getStateAsync this member this.Address = let (MailAgent (address, _)) = this address member this.Dispose() = let (MailAgent (_, this)) = this (this:>IDisposable).Dispose() interface IDisposable with member this.Dispose() = this.Dispose() 

Vou lhe dizer qual address:string pouco mais tarde, mas, por enquanto, nosso padrão está pronto.


Na verdade, a cobra


Na cobra há uma cobra, um usuário com seus comandos, um campo e uma transição regular para o próximo quadro.
Aqui está tudo isso junto e precisa ser manchado por nossos atores.
Meu layout inicial era o seguinte:


  • Ator com um temporizador. Aceita iniciar / parar / pausar mensagens. A cada n milissegundos, envia uma mensagem Flush ao ator de Flush . Armazena System.Timers.Timer como um estado
  • Equipes de atores. Recebe mensagens do usuário Move Up/Down/Left/Right , AddPerk Speed/Attack (sim, minha cobra pode rastejar e atacar vilões rapidamente) e Flush o timer. Ele armazena uma lista de comandos como um estado e, com um flush, essa lista é redefinida.
  • O ator é uma cobra. Ele armazena o estado da cobra - vantagens, comprimento, direção, curvas e coordenadas.
    Ele aceita uma lista de mensagens do ator dos comandos, uma mensagem Tick (para mover a célula cobra 1 para frente) e uma mensagem GrowUp do ator do campo quando encontra comida.
  • Ator de campo. Ele armazena um mapa de células, pega o estado de uma cobra em uma mensagem e desenha coordenadas em uma imagem existente. Ele também envia o GrowUp ator snake e o comando Stop ao cronômetro, se o jogo terminar.

Como você pode ver, mesmo com um número tão pequeno de entidades, o mapa de mensagens já não é trivial. E já nesse estágio surgiram dificuldades: o fato é que, por padrão, o F # não permite dependências cíclicas. Na linha de código atual, você pode usar apenas o código escrito acima, e o mesmo se aplica aos arquivos no projeto. Isso não é um bug, mas um recurso, e eu amo muito isso, porque ajuda a manter o código limpo, mas o que fazer quando os links cíclicos são necessários pelo design? Claro, você pode usar o rec namespace - e, em um arquivo, você pode se referir a tudo o que está nesse arquivo, que eu usei.
Espera-se que o código atrapalhe, mas parecia a única opção. E funcionou.


O problema do mundo exterior


Tudo funcionou desde que todo o sistema de atores estivesse isolado do mundo exterior, e eu apenas estraguei e exibi linhas no console. Quando chegou a hora de implementar a dependência na forma da função updateUI , que deveria redesenhar para cada marca, não consegui resolver esse problema na implementação atual. Nem feio nem bonito - de jeito nenhum. E então me lembrei de akku - afinal, lá você pode gerar atores ao longo do caminho, e eu tenho todos os meus atores descritos na fase de compilação.
A solução é óbvia - use akku! Não, é claro que Akka ainda é um exagero, mas decidi lamber alguns pontos a partir daí - a saber, criar um sistema de atores no qual você possa adicionar dinamicamente novos atores e consultar os atores existentes no endereço.
Como os atores agora são adicionados e excluídos em tempo de execução, mas obtidos pelo endereço e não pelo link direto, é necessário fornecer um cenário em que o endereço não pareça estar em lugar nenhum e o ator não esteja lá. Seguindo o exemplo da mesma acca, adicionei uma caixa para cartas mortas e a projetei nas minhas DUs favoritas:


 // Agent<_,_> --  ,   ,     //     ,      . //      ,    --    Box (mailagent), // ,       ,   ,   , //       Deadbox.      MailAgent,  . //           . //    --    . type Agent<'message,'state> = | Box of MailAgent<'message,'state> | DeadBox of string * MailAgent<string * obj, Map<string,obj list>> with member this.Post msg = match this with | Box box -> box.Post msg | DeadBox (address, deadbox) -> (address, box msg) |> deadbox.Post interface IDisposable with member this.Dispose() = match this with | Box agent -> agent.Dispose() | DeadBox (_,agent) -> agent.Dispose() 

E o próprio sistema se parece com isso:


 //    .     --    . type MailboxNetwork() as this = //      .   ! [<DefaultValue>] val mutable agentRegister: ConcurrentDictionary<string, obj> //       do this.agentRegister <- ConcurrentDictionary<string, obj>() //      , //    Map --     let deadLettersFn deadLetters (address:string, msg:obj) = printfn "Deadletter: %s-%A" address msg match Map.tryFind address deadLetters with //   | None -> Map.add address [msg] deadLetters //   --   | Some letters -> //  --      Map.remove address deadLetters |> Map.add address (msg::letters) let deadLettersAgent() = ("deadLetters", Map.empty |> Mailbox.buildAgent deadLettersFn) |> MailAgent member this.DeadLetters = deadLettersAgent() // -     ,      member this.Box<'message,'state>(address) = match this.agentRegister.TryGetValue address with | (true, agent) when (agent :? MailAgent<'message,'state>) -> // ,       ,   let agent = agent :?> MailAgent<'message, 'state> Box agent | _ -> DeadBox (address, this.DeadLetters) //  --    member this.KillBox address = this.agentRegister.TryRemove(address) |> ignore member this.RespawnBox (agent: MailAgent<'a,'b>) = this.KillBox agent.Address this.agentRegister.TryAdd (agent.Address, agent) |> ignore interface IDisposable with member this.Dispose() = for agent in this.agentRegister.Values do match agent with | :? IDisposable as agent -> agent.Dispose() | _ -> () 

É aqui que o mesmo address:string , sobre o qual escrevi acima, foi útil. E, novamente, funcionou, agora era fácil chegar à dependência externa de onde você precisa. As funções construtoras dos atores agora aceitaram o sistema de atores como argumentos e obtiveram os endereços necessários a partir daí:


  //    - (  )   - let gameAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<Command list, GameState>(gameAddress) //    message loop           let commandAgentFn (mailboxNetwork: MailboxNetwork) commands msg = let gameAgent = gameAgent mailboxNetwork match msg with | Cmd cmd -> cmd::commands | Flush -> commands |> gameAgent.Post [] 

Lentamente


Por razões óbvias, durante a depuração, configurei o jogo para uma velocidade baixa: o atraso entre os ticks era superior a 500 milissegundos. Se você reduzir o atraso para 200, as mensagens começarão a chegar tarde, e as equipes do usuário trabalharão com um atraso, o que estragará todo o jogo. Uma mosca adicional na pomada foi o fato de o temporizador receber o comando de parada em caso de perda várias vezes. Para o usuário, isso não apareceu de forma alguma, mas, no entanto, houve algum tipo de bug.
A verdade desagradável era que os atores são, obviamente, ótimos, mas as chamadas de método diretas são muito mais rápidas. Portanto, apesar do fato de que armazenar a cobra em um ator separado era conveniente do ponto de vista da organização do código, tive que abandonar essa ideia em nome da velocidade, porque, para 1 relógio do jogo, as mensagens eram muito intensas:


  1. O usuário envia um número arbitrário de comandos diretamente ao ator de comandos.
  2. O cronômetro envia um tiquetaque para o ator da equipe e, em uma implementação inicial, também para o ator da cobra, para que ele a mova para a próxima célula
  3. O ator do comando envia uma lista de comandos para a cobra quando a mensagem correspondente vem do timer.
  4. O ator cobra, tendo atualizado seu estado de acordo com as duas mensagens superiores, envia o estado ao ator de campo.
  5. O ator de campo redesenha tudo. Se a cobra encontrou comida, envia uma mensagem de GrowUp ao ator, após o qual ele envia o novo estado de volta ao ator de campo.

E por tudo isso, há 1 ciclo de relógio, o que não é suficiente, levando em consideração a sincronização nas entranhas do MailboxProcessor . Além disso, na implementação atual, o timer envia a próxima mensagem a cada n milissegundo, independentemente de qualquer coisa; portanto, se não entrarmos na medida 1 vez, as mensagens começam a se acumular e a situação piora. Seria muito melhor “esticar” essa medida específica, processar tudo o que acumulou e seguir em frente.


Versão final


Obviamente, o esquema de mensagens precisa ser simplificado, embora seja muito desejável deixar o código o mais simples e acessível possível - relativamente falando, eu não quero colocar tudo em um ator divino, e então não há muito sentido nos atores.
Portanto, olhando para a minha lista de atores, percebi que é melhor sacrificar um ator-cobra primeiro. Um temporizador é necessário, um buffer de comandos do usuário também é necessário para acumulá-los em tempo real, mas despeje-o apenas uma vez por batida, e não há necessidade objetiva de manter a cobra em um ator separado, isso foi feito apenas por conveniência. Além disso, mantendo-o com o ator de campo, será possível processar o script GrowUp sem demora. Tick mensagem de Tick para a cobra também não faz muito sentido, porque quando recebemos uma mensagem do ator da equipe, isso já significa que uma nova batida aconteceu. Adicionando a isso o alongamento da batida em caso de atraso, temos as seguintes alterações:


  1. GrowUp mensagens Tick & GrowUp .
  2. Mantemos o ator cobra no ator de campo - ele agora armazenará o "tapla" desses estados.
  3. System.Timers.Timer do ator de timer. Em vez disso, o esquema de trabalho será o seguinte: após receber o comando Start , ele envia Flush ator de comando. Ele envia uma lista de comandos para o ator field + snake, o último ator processa tudo isso e envia uma mensagem Next para o timer, solicitando um novo tick dele. O cronômetro, que recebeu Next espera por Thread.Sleep(delay) e inicia o círculo inteiro novamente. Tudo é simples.

Para resumir.


  • Na implementação anterior, 500 ms era o atraso mínimo permitido. No atraso atual, você pode removê-lo completamente - o ator de campo exigirá uma nova batida quando estiver pronto. A coleta de mensagens brutas de medidas anteriores não é mais possível.
  • O mapa de mensagens é bastante simplificado - em vez de um gráfico complexo, temos o loop mais simples.
  • Esta simplificação resolveu o erro quando o cronômetro Stop várias vezes em caso de perda.
  • A lista de mensagens foi reduzida. Menos código - menos mal!

É assim:


  let [<Literal>] commandAddress = "command" let [<Literal>] timerAddress = "timer" let [<Literal>] gameAddress = "game" // -     let commandAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<CommandMessage, Command list>(commandAddress) let timerAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<TimerCommand, TimerState>(timerAddress) let gameAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<Command list, GameState>(gameAddress) // message loop   let gameAgentFn (mailboxNetwork: MailboxNetwork) updateUi gameState cmd = let timerAgent = timerAgent mailboxNetwork //    match gameState.gameFrame with //     | Frame field -> //       let gameState = Game.updateGameState gameState cmd timerAgent.Post Next //    updateUi gameState //     gameState // ! | End (Win _) -> timerAgent.Post PauseOrResume Game.updateGameState gameState cmd //        | _ -> timerAgent.Post Stop //     gameState // message loop   let commandAgentFn (mailboxNetwork: MailboxNetwork) commands msg = let gameAgent = gameAgent mailboxNetwork match msg with | Cmd cmd -> cmd::commands //       | Flush -> commands |> gameAgent.Post //     [] // message loop   let timerAgentFn (mailboxNetwork: MailboxNetwork) (state: TimerState) cmd = let commandAgent = commandAgent mailboxNetwork match cmd with | Start -> commandAgent.Post Flush; {state with active = true} | Next -> if state.active then //    ,     Threading.Thread.Sleep(state.delay) commandAgent.Post Flush; state | Stop -> printfn "Stop received"; { state with active = false } | PauseOrResume -> if not state.active then //     --   commandAgent.Post Flush { state with active = not state.active } | SetDelay delay -> Threading.Thread.Sleep(delay) if state.active then commandAgent.Post Flush {state with delay = delay} 

Referências


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


All Articles