Actor Model i F# ved hjelp av MailboxProcessor


lørdag 16. februar 2013 F# Polyglot Actor model Samtidighet

Jeg fortsetter å eksperimentere med F#, og nå går vi over til noe litt mere avansert. MailboxProcessor er en klasse man finner i modulen Microsoft.FSharp.Control. Den er en slags agent som prosesserer meldinger asynkront. Den har en meldingskø som mange kan skrive til, men kun MailboxProcessor-agenten kan lese fra den.

Dette minner veldig om Actor Model, som jeg har eksperimentert med blant annet i Erlang tidligere. Se En introduksjon til Erlang og  Ping Ring del 5 for mer info – begge fra 2010.

Actors

I denne artikkelen vil jeg gi et grunnleggende eksempel på hvordan Actors fungerer i F#. Jeg vil lage starten på en asynkron kalkulator, og så bruke den til å plusse sammen en rekke tall.

En liten debug-funksjon

Når jeg skal illustrere hvordan asynkron kode fungerer kan det være greit å printe ut litt til konsollet, slik at man kan se hvilken rekkefølge ting skjer i. Derfor lager jeg en funksjon jeg kaller dbg som skriver ut en streng sammen med et timestamp (med millisekund-presisjon).

open System
open Microsoft.FSharp.Control

let dbg msg =
    printfn "%s %s" 
        (DateTime.Now.ToString "HH:mm:ss,fff") 
        msg

De to første linjene importerer namespacene jeg trenger i programmet. Hvis jeg nå kaller funksjonen noen ganger:

dbg "test 1"
dbg "test 2"
dbg "test 3"
dbg "test 4"

Vil jeg få output som dette:

08:32:30,834 test 1
08:32:30,835 test 2
08:32:30,835 test 3
08:32:30,835 test 4

Et tips: Jeg koder i F# i Visual Studio. Men jeg kompilerer ikke, og kjører ikke programmet med F5 som jeg ville gjort i C#. I stedet har jeg åpnet et vindu som kalles F# Interactive, og hvis jeg markerer litt kode i editoren og trykker Alt+ENTER vil koden eksekveres og resultatet vises i det vinduet.

En Actor-basert kalkulator

En actor skal som sagt prosessere meldinger. F# er sterkt typet, så jeg trenger en meldingstype. Jeg bruker en Discriminated Union til det i dette tilfellet. Her sier jeg at en melding kan være enten en pluss-operasjon med et tilhørende tall, eller en get-operasjon for å lese ut verdien til kalkulatoren. Get-operasjonen sin verdi er en AsyncReplyChannel<int>. Dette er et objekt som kan brukes til å sende en melding tilbake fra actoren til den som sendte Get-meldingen.

AsyncReplyChannel

Her definerer jeg meldingstypene:

type Message =
    | Add of int 
    | Get of AsyncReplyChannel<int>

Skulle dette vært en orntlig kalkulator hadde jeg lagt til meldinger for å gange, dele og trekke fra også, men Add er godt nok for dette eksempelet.

Og nå følger opprettelsen av actoren. Jeg oppretter en instans av MailboxProcessor, og sender inn en anonym metode. I den oppretter jeg en rekursiv funksjon som jeg kaller loop, og så kaller jeg den. Argumentet til loop er i dette tilfellet minnet til kalkulatoren, som i utgangspunktet er tallet 0.

let asyncCalculator = new MailboxProcessor<Message>(fun inbox ->
    let rec loop state =
        async { let! msg = inbox.Receive()
                match msg with
                | Add x ->
                    sprintf "Actor adding %d" x |> dbg 
                    return! loop (state + x)
                | Get replyChannel ->
                    replyChannel.Reply state
                    return! loop state }
    loop 0)

Det første loop gjør er å forsøke å motta en melding fra mailboksen sin. Hvis mailboksen er tom vil dette kallet blokkere inntil det kommer en melding. Deretter avgjør loop hvilken type melding det er jeg har mottat, utfører det som skal gjøres, og kaller seg selv.

loop bruker noe vi kaller en asynkron blokk (eller asynkron workflow): async { .. }. Blokken er en computation expression, og har jeg forstått det riktig så inneholder den det Haskel-utviklere kaller en monade. Men du trenger ikke nødvendigvis forstå monader (en nesten umulig oppgave) for å bruke dem. Så vi kjører videre..

Sende meldinger til kalkulatoren

I kodesnutten nedenfor starter jeg først actoren. Deretter kjører jeg en løkke fra 1 til 10 og sender Add-meldinger ved hjelp av kalkulatorens Post-metode. Til slutt bruker jeg PostAndReply for å sende en Get-melding og vente på svaret. Siste linje skriver ut svaret.

asyncCalculator.Start()

for n in [1..10] do
    Add n |> asyncCalculator.Post

let result = asyncCalculator.PostAndReply(fun replyChannel ->
    dbg "Getting result"
    Get (replyChannel))

sprintf "Result is %d" result |> dbg

Når jeg kjører denne koden får jeg følgende output i F# Interactive-vinduet:

08:33:14,972 Getting result
08:33:14,977 Actor adding 1
08:33:14,977 Actor adding 2
08:33:14,977 Actor adding 3
08:33:14,978 Actor adding 4
08:33:14,978 Actor adding 5
08:33:14,978 Actor adding 6
08:33:14,978 Actor adding 7
08:33:14,978 Actor adding 8
08:33:14,979 Actor adding 9
08:33:14,979 Actor adding 10
08:33:14,979 Result is 55

Her ser du to ting: For det første så virker det som om actoren behandler meldingene i "riktig" rekkefølge. MailboxProcessor behandler meldingene sine synkront, og i den rekkefølgen de ankommer i køen. Og for det andre ser du at kalkulatore som forventet er asynkron, siden den ikke kom igang med å behandle meldingene før siste melding ble sendt ("Getting result" ble skrevet ut før actoren begynte å "adde".

Asynkron "getter"

I koden over blokkerer jeg hovedtråden mens jeg venter på svar fra Get-meldingen. Det trenger jeg ikke å gjøre. I stedet for å bruke PostAndReply kan jeg bruke en annen variant som heter PostAndAsyncReply. Da må jeg sende Get-meldingen og vente på svaret i en async-blokk.

Slik ser det ut:

let asyncGetter =
    async {
        let! reply = asyncCalculator.PostAndAsyncReply(fun channel ->
            dbg "Getting result async"
            Get (channel))
        sprintf "Result is %d" reply |> dbg }


for n in [1..10] do
    Add n |> asyncCalculator.Post

Async.Start (asyncGetter)

dbg "Main thread done!"

Og da fikk jeg en litt mere spennende output:

08:45:37,670 Actor adding 1
08:45:37,670 Actor adding 2
08:45:37,670 Actor adding 3
08:45:37,670 Main thread done!
08:45:37,671 Actor adding 4
08:45:37,671 Actor adding 5
08:45:37,671 Actor adding 6
08:45:37,671 Actor adding 7
08:45:37,672 Getting result async
08:45:37,672 Actor adding 8
08:45:37,672 Actor adding 9
08:45:37,672 Actor adding 10
08:45:37,677 Result is 110

Denne gangen ser vi at kalkulatoren kommer igang med prosesseringen av Add-meldinger før hovedtråden er ferdig. Vi ser også at Get-meldingen blir fyrt avgårde før Add-prosesseringen er ferdig.

Konklusjon

Jeg har vært veldig fasinert av Actor Model siden jeg lørte om det for tre år siden, og det føles som en intuitiv og lur måte å designe samtidighetssystemer på. Men jeg har fortsatt aldri brukt det til noe fornuftig. Hovedgrunnen er nok at støtten for det har vært dårlig (eller lite kjent) på de plattformene jeg har utviklet for. Men nå vet jeg at støtten er der på .NET-plattformen!

For ordens skyld, du kan bruke MailboxProcessor fra andre .NET-språk enn F#. Da heter den FSharpMailboxProcessor.

Måten det gjøres på i F# virker grei nok – det er litt mere "knotete" enn i Erlang, men det der med AsyncReplyChannel var egentlig ganske elegant. Jeg ser derimot at jeg bør bli komfortabel med asynkrone workflows før jeg tør bruke MailboxProcessor for fullt.

For mer info om MailboxProcessor se MSDN og denne siden på wikibooks.


comments powered by Disqus