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()
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:
- O usuário envia um número arbitrário de comandos diretamente ao ator de comandos.
- 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
- O ator do comando envia uma lista de comandos para a cobra quando a mensagem correspondente vem do timer.
- O ator cobra, tendo atualizado seu estado de acordo com as duas mensagens superiores, envia o estado ao ator de campo.
- 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:
GrowUp
mensagens Tick
& GrowUp
.- Mantemos o ator cobra no ator de campo - ele agora armazenará o "tapla" desses estados.
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 //
Referências