It's all about the snake. Everybody perfectly remembers what a snake is: a snake moves on a rectangular field. Finds food - grows in length, finds itself or the field border - dies. And the user can only send commands: left, right, up, down.
I decided to add a little action here and make the snake run away from the pakmans. And all this on the actors!
Therefore, today, using the snake example, I will talk about how to build a model of actors using the MailboxProcessor
from the standard library, what points to pay attention to, and what pitfalls you can expect.
The code written here is not perfect, it can violate some principles and can be written better. But if you are new and want to deal with mailboxes - I hope this article will help you.
If you know about mailboxes without me, you might be bored here.
For the sake of practice. I read about the model of actors, watched the video, I liked everything, but did not try it myself. Now I tried.
In spite of the fact that in fact I chose technology for the sake of technology, the concept very successfully laid down this task.
For my task, MailboxProcessor
is from the orbital station on sparrows, MailboxProcessor
much simpler, and it is included in the standard library, so no packages need to be connected.
The bottom line is simple. The mailbox inside has a message loop and some state. Your message loop will update this state according to the new incoming message.
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) )
Note that messageLoop
recursive, and at the end it must be called again, otherwise only one message will be processed, after which this actor will "die". Also, messageLoop
asynchronous, and each subsequent iteration is performed when a new message is received: let! msg = inbox.Receive()
let! msg = inbox.Receive()
.
Thus, the entire logical load goes to the updateState
function, which means that to create a mailbox of the processor, we can make a constructor function that accepts the state update function and the zero state:
// 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 )
Cool! Now we do not need to constantly make sure not to forget the return! loop newState
return! loop newState
. As is known, the actor keeps the state, but now it is not at all clear how to get this state from the outside. The mailbox processor has a PostAndReply
method that accepts the AsyncReplyChannel<'Reply> -> 'Msg
function AsyncReplyChannel<'Reply> -> 'Msg
. At first it put me in a stupor - it is completely incomprehensible where this function comes from. But in fact, everything turned out to be simpler: all messages should be wrapped in a DU wrapper, since we now have 2 operations on our actor: send the message itself and ask for the current state. Here's what it looks like:
// . // Mail<_,_> , Post & Get -- . // F# , // compare & equals . // -- . // [<Struct>] . type Mail<'msg, 'state> = | Post of 'msg | Get of AsyncReplyChannel<'state>
Our constructor function now looks like this:
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 )
Now, to work with mailbox, we need to wrap all our messages in this Mail.Post
. In order not to write it every time, it is better to wrap it in a small apish:
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()
I will tell you about the address:string
bit later, but for now our boilerplate is ready.
In the snake there is a snake, a user with his commands, a field and a regular transition to the next frame.
All of this together and you need to spread it over our actors.
I had the original layout like this:
Flush
actor. As a state stores System.Timers.Timer
Move Up/Down/Left/Right
, AddPerk Speed/Attack
(yes, my snake can crawl and attack the villains quickly) and Flush
from the timer. As a state, it stores a list of commands, and when it flashes, this list resets.Tick
message (to move the snake 1 cell forward), and the GrowUp
message from the field actor when it finds food.GrowUp
snake actor and the Stop
command to the timer if the game is over.As you can see, even with such a small number of entities, the message map already turns out to be non-trivial. And already at this stage difficulties arose: the fact is that by default F # does not allow cyclic dependencies. In the current line of code, you can only use the code written above, and the same applies to files in the project. This is not a bug, but a feature, and I love it very much, because it helps to keep the code clean, but what to do when circular references are needed by design? Of course, you can use rec namespace
- and then within one file you can refer to everything in this file, which I used.
The code was expectedly corrupted, but then it seemed the only option. And it all worked.
Everything worked as long as the whole system of actors was isolated from the outside world, and I just debugged and displayed lines in the console. When it came time to implement the dependency as a function updateUI
, which for each tick should redraw, I could not solve this problem in the current implementation. Neither ugly nor beautiful - no way. And then I remembered about Accu - there you can generate actors right along the way, and I have all my actors described at the compilation stage.
The output is obvious - use akku! No, of course, the acca is still overkill, but I decided to lick out certain points from there - namely, to make a system of actors into which you can dynamically add new actors and query existing actors.
Since the actors are now added and removed in runtime, but are obtained at the address, rather than a direct link, you need to provide a script when the address looks nowhere and the actor is not there. Following the example of the same account, I added a box for dead letters, and I designed it through my favorite DUs:
// 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()
And the system itself looks like this:
// . -- . 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() | _ -> ()
This is where the address:string
about which I wrote above came in handy for us. And again, everything worked, external dependence was now easy to throw where necessary. The constructor functions of the actors now accepted as argument the system of actors and reached from there the necessary addressees:
// - ( ) - 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 []
For obvious reasons, I set a low game speed during debugging: the delay between ticks was more than 500 milliseconds. If you reduce the delay to 200, the messages started to arrive late, and the commands from the user worked with a delay, which spoiled the whole game. An additional fly in the ointment was the fact that the stop command received the timer several times in case of loss. For the user, this did not manifest at all, but nevertheless, there was some kind of bug.
The unpleasant truth was that actors are, of course, conveniently cool, but a direct method call is much faster. Therefore, despite the fact that the snake itself was conveniently stored in a separate actor from the point of view of organizing the code, this idea had to be abandoned for the sake of speed, because the message exchange was too intense for 1 tact of the game:
GrowUp
message to the snake actor, after which it sends a new state back to the field actor.And for all this there is 1 clock cycle, which is not enough, taking into account synchronization in the depths of the MailboxProcessor
. Moreover, in the current implementation, the timer sends the following message every n milliseconds regardless of anything, so that if we didn’t fit in time, the messages start to accumulate, and the situation worsens. It would be much better to “stretch” this particular tact, process everything that has accumulated, and go on.
Obviously, the message scheme should be simplified, while it is very desirable to leave the code as simple and accessible as possible - let’s say, I don’t want to push everything in 1 god actor, and then the actors don’t make much of it.
Therefore, looking at my list of actors, I realized that the first thing to do is sacrifice a snake actor. A timer is needed, a buffer of user commands is also needed to accumulate them in real time, but to pour out only once per clock, and there is no objective need to keep a snake in a separate actor, this was done simply for convenience. In addition, having been with the field actor it will be possible to process the GrowUp
script without delay. Tick
message for a snake doesn’t make much sense either, because when we receive a message from an actor in commands, this already means that a new beat has happened. Adding to this the stretching of the clock in case of a delay, we have the following changes:
Tick
& GrowUp
posts.System.Timers.Timer
from the timer actor. Instead, the scheme of work will be the following: receiving the Start
command, it sends Flush
command actor. He sends a list of commands to the actor of the field + snakes, the last actor handles all this and sends the message Next
to the timer, thus requesting a new tick from it. The timer, on receiving Next
waits for Thread.Sleep(delay)
and starts the whole circle from the beginning. It's simple.Summarize.
Stop
several times in case of loss.It looks like this:
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}
Source: https://habr.com/ru/post/424861/
All Articles