¿De qué se trata todo esto?
Se trata de la serpiente. Todos recuerdan lo que es una serpiente: una serpiente se mueve en un campo rectangular. Encuentra comida - crece en longitud, se encuentra a sí mismo o al borde del campo - muere. Y el usuario solo puede enviar comandos: izquierda, derecha, arriba, abajo.
Decidí agregar algo de acción aquí y hacer que la serpiente huyera del pacman. ¡Y todo esto sobre los actores!
Por lo tanto, hoy usaré el ejemplo de una serpiente para hablar sobre cómo construir un modelo de actor usando MailboxProcessor
de la biblioteca estándar, qué puntos buscar y qué dificultades puede esperar.
El código escrito aquí no es perfecto, puede violar algunos principios y puede estar mejor escrito. Pero si eres un principiante y quieres lidiar con los buzones, espero que este artículo te ayude.
Si sabes todo sobre los buzones sin mí, es posible que estés aburrido aquí.
¿Por qué actores?
Por el bien de la práctica. Leí sobre el modelo de actores, vi el video, me gustó todo, pero no lo probé yo mismo. Ahora lo probé.
A pesar de que, de hecho, elegí la tecnología por el bien de la tecnología, el concepto recayó con éxito en esta tarea.
¿Por qué MailboxProcessor, y no, por ejemplo, Akka.net?
Para mi tarea, el MailboxProcessor
es de la estación orbital por gorriones, MailboxProcessor
mucho más simple y es parte de la biblioteca estándar, por lo que no necesita conectar ningún paquete.
Acerca de los procesadores de buzones y repeticiones relacionadas
El punto es simple. El buzón interior tiene un bucle de mensajes y algún estado. Su bucle de mensajes actualizará este estado de acuerdo con el nuevo mensaje que llegue.
let actor = MailboxProcessor.Start(fun inbox -> // , // . inbox -- MailboxProcessor let rec messageLoop oldState = async { // let! msg = inbox.Receive()
Tenga en cuenta que messageLoop
recursivo y, al final, debe volver a llamarse; de lo contrario, solo se procesará un mensaje, después del cual este actor morirá. messageLoop
también messageLoop
asíncrono, y cada iteración posterior se realiza cuando se recibe un nuevo mensaje: let! msg = inbox.Receive()
let! msg = inbox.Receive()
.
Por lo tanto, toda la carga lógica va a la función updateState
, lo que significa que para crear el buzón del procesador, podemos hacer una función de constructor que acepte una función de actualización de estado y un estado cero:
// 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 )
Genial! ¡Ahora no necesitamos monitorear constantemente para no olvidar el return! loop newState
return! loop newState
. Como saben, un actor almacena un estado, pero ahora no está completamente claro cómo obtener este estado desde el exterior. El buzón del procesador tiene un método PostAndReply
, que toma la función AsyncReplyChannel<'Reply> -> 'Msg
como entrada. Al principio me llevó a un estupor: no está completamente claro de dónde obtener esta función. Pero en realidad, todo resultó ser más simple: todos los mensajes deben estar envueltos en un contenedor DU, ya que ahora tenemos 2 operaciones en nuestro actor: enviar el mensaje en sí y preguntar por el estado actual. Así es como se ve:
// . // Mail<_,_> , Post & Get -- . // F# , // compare & equals . // -- . // [<Struct>] . type Mail<'msg, 'state> = | Post of 'msg | Get of AsyncReplyChannel<'state>
Nuestra función de constructor ahora se ve así:
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 )
Ahora, para trabajar con el buzón, necesitamos envolver todos nuestros mensajes en este Mail.Post
. Para no escribir esto cada vez, es mejor envolverlo en una pequeña aplicación:
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()
Te diré qué address:string
poco más tarde, pero por ahora nuestra plantilla está lista.
En realidad la serpiente
En la serpiente hay una serpiente, un usuario con sus comandos, un campo y una transición regular al siguiente cuadro.
Aquí está todo esto junto y necesita ser manchado por nuestros actores.
Mi diseño inicial fue el siguiente:
- Actor con temporizador. Acepta mensajes de inicio / detención / pausa. Cada n milisegundos, envía un mensaje
Flush
al actor del Flush
. Almacena System.Timers.Timer
como un estado - Equipos de actores. Recibe mensajes del usuario
Move Up/Down/Left/Right
, AddPerk Speed/Attack
(sí, mi serpiente puede arrastrarse rápidamente y atacar a los villanos) y Flush
desde el temporizador. Almacena una lista de comandos como un estado, y con una descarga, esta lista se restablece. - El actor es una serpiente. Almacena el estado de la serpiente: ventajas, longitud, dirección, curvas y coordenadas.
Acepta una lista de mensajes del actor de los comandos, un mensaje Tick
(para mover la serpiente 1 hacia adelante) y un mensaje GrowUp
del actor del campo cuando encuentra comida. - Actor del campo. Almacena un mapa de celdas, toma el estado de una serpiente en un mensaje y dibuja coordenadas en una imagen existente. También envía
GrowUp
actor serpiente y el comando Stop
al temporizador si el juego ha terminado.
Como puede ver, incluso con un número tan pequeño de entidades, el mapa de mensajes ya no es trivial. Y ya en esta etapa surgieron dificultades: el hecho es que, por defecto, F # no permite dependencias cíclicas. En la línea de código actual, solo puede usar el código escrito anteriormente, y lo mismo se aplica a los archivos del proyecto. Esto no es un error, sino una característica, y me encanta porque ayuda a mantener limpio el código, pero ¿qué hacer cuando el diseño necesita enlaces cíclicos? Por supuesto, puede usar el rec namespace
, y luego dentro de un archivo puede hacer referencia a todo lo que está en este archivo, que usé.
Se espera que el código se estropee, pero parecía la única opción. Y funcionó.
El problema del mundo exterior.
Todo funcionó siempre y cuando todo el sistema de actores estuviera aislado del mundo exterior, y solo debatía y mostraba líneas en la consola. Cuando llegó el momento de implementar la dependencia en forma de la función updateUI
, que se suponía que se redibujaría para cada tick, no pude resolver este problema en la implementación actual. Ni feo ni hermoso, de ninguna manera. Y luego recordé akku: después de todo, allí puedes generar actores en el camino, y tengo a todos mis actores descritos en la etapa de compilación.
La solución es obvia: ¡usa akku! No, por supuesto, Akka todavía es excesivo, pero decidí lamer ciertos puntos a partir de ahí, a saber, crear un sistema de actores en el que pueda agregar dinámicamente nuevos actores y consultar actores existentes en la dirección.
Dado que los actores ahora se agregan y eliminan en tiempo de ejecución, pero se obtienen por la dirección en lugar de un enlace directo, debemos proporcionar un escenario en el que la dirección no se vea en ninguna parte y el actor no esté allí. Siguiendo el ejemplo del mismo acca, agregué un cuadro para letras muertas, y lo diseñé a través de mis DU 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()
Y el sistema en sí se ve así:
// . -- . 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() | _ -> ()
Aquí es donde la misma address:string
, sobre la que escribí anteriormente, fue útil. Y de nuevo funcionó, la dependencia externa ahora era fácil de llegar a donde necesitabas. Las funciones de construcción de los actores ahora aceptaron el sistema de actores como argumentos y obtuvieron las direcciones necesarias a partir de ahí:
// - ( ) - 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 []
Despacio
Por razones obvias, durante la depuración, configuré el juego a una velocidad baja: el retraso entre ticks fue de más de 500 milisegundos. Si reduce el retraso a 200, entonces los mensajes comenzaron a llegar tarde, y los equipos del usuario trabajaron con un retraso, lo que arruinó todo el juego. Una mosca adicional en la pomada fue el hecho de que el temporizador recibió el comando de detención en caso de pérdida varias veces. Para el usuario, esto no apareció de ninguna manera, pero sin embargo, hubo algún tipo de error.
La verdad desagradable fue que los actores son, por supuesto, convenientemente geniales, pero las llamadas a métodos directos son mucho más rápidas. Por lo tanto, a pesar del hecho de que almacenar la serpiente en un actor separado era conveniente desde el punto de vista de organizar el código, tuve que abandonar esta idea en nombre de la velocidad, porque durante 1 reloj del juego el mensaje era demasiado intenso:
- El usuario envía un número arbitrario de comandos directamente al actor del comando.
- El temporizador envía una marca al actor del equipo y, en una implementación temprana, también al actor de la serpiente para que mueva la serpiente a la celda siguiente.
- El actor de comando envía una lista de comandos para la serpiente cuando el mensaje correspondiente proviene del temporizador.
- El actor serpiente, habiendo actualizado su estado de acuerdo con los 2 mensajes superiores, envía el estado al actor de campo.
- El actor de campo vuelve a dibujar todo. Si la serpiente encontró comida, entonces envía un mensaje de
GrowUp
al actor de la serpiente, luego de lo cual envía el nuevo estado al actor de campo.
Y para todo esto hay 1 ciclo de reloj, que no es suficiente, teniendo en cuenta la sincronización en las entrañas del MailboxProcessor
. Además, en la implementación actual, el temporizador envía el siguiente mensaje cada n milisegundos, independientemente de cualquier cosa, por lo que si no entramos en la medida 1 vez, los mensajes comienzan a acumularse y la situación empeora. Sería mucho mejor "estirar" esta medida en particular, procesar todo lo que se ha acumulado y seguir adelante.
Versión final
Obviamente, el esquema del mensaje debe simplificarse, mientras que es muy deseable dejar el código lo más simple y accesible posible; en términos relativos, no quiero meter todo en 1 actor divino, y entonces no tiene mucho sentido en los actores.
Por lo tanto, al mirar mi lista de actores, me di cuenta de que es mejor sacrificar primero a un actor serpiente. Se necesita un temporizador, también se necesita un búfer de comandos de usuario para acumularlos en tiempo real, pero solo se vierte una vez por latido, y no hay necesidad objetiva de mantener a la serpiente en un actor separado, esto se hizo solo por conveniencia. Además, al mantenerlo con el actor de campo, será posible procesar el script GrowUp
sin demora. Tick
mensaje de marca para la serpiente tampoco tiene mucho sentido, porque cuando recibimos un mensaje del actor del equipo, ya significa que ha sucedido un nuevo ritmo. Además de esto, el estiramiento del ritmo en caso de retraso, tenemos los siguientes cambios:
GrowUp
mensajes de Tick
& GrowUp
.- Mantenemos al actor serpiente en el actor de campo: ahora almacenará la "tapla" de estos estados.
System.Timers.Timer
del actor del temporizador. En cambio, el esquema de trabajo será el siguiente: después de recibir el comando Start
, envía Flush
actor del comando. Envía una lista de comandos al campo + actor de serpiente, el último actor procesa todo esto y envía un mensaje Next
al temporizador, solicitándole un nuevo tic. El temporizador, después de recibir Next
espera Thread.Sleep(delay)
y comienza todo el círculo nuevamente. Todo es simple
Para resumir.
- En la implementación anterior, 500 ms era el retraso mínimo permitido. En el retraso actual, puede eliminarlo por completo: el actor de campo requerirá un nuevo ritmo cuando esté listo. Recopilar mensajes sin formato de medidas anteriores ya no es posible.
- El mapa de mensajería se simplifica enormemente: en lugar de un gráfico complejo, tenemos el bucle más simple.
- Esta simplificación resolvió el error cuando el temporizador se
Stop
varias veces en caso de pérdida. - La lista de mensajes se ha reducido. ¡Menos código, menos maldad!
Se ve así:
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 //
Referencias