Skip to content
AgileLogics
6 min read

Agents with MailboxProcessor

Prerequisites: Async and task

Shared mutable state and threads are a recipe for race conditions. An agent avoids the problem: it owns its state, and other code talks to it only by sending messages, which it processes one at a time. F#'s built-in agent is MailboxProcessor.

A counter agent

type CounterMessage =
    | Increment of int
    | Get of AsyncReplyChannel<int>

let counter =
    MailboxProcessor.Start(fun inbox ->
        let rec loop total =
            async {
                let! message = inbox.Receive()
                match message with
                | Increment n -> return! loop (total + n)
                | Get reply ->
                    reply.Reply total
                    return! loop total
            }
        loop 0)

[ 1 .. 100 ]
|> List.map (fun i -> async { counter.Post(Increment i) })
|> Async.Parallel
|> Async.RunSynchronously
|> ignore

let total = counter.PostAndReply Get
printfn "Total: %d" total
Total: 5050
  • Messages are a discriminated union — the agent's public "API".
  • The state (total) is a parameter of a recursive loop: no mutable, no locks.
  • Post sends a message without waiting. PostAndReply sends one that carries a reply channel and waits for the answer (PostAndAsyncReply does the same without blocking).
  • A hundred concurrent posts, and the total is still exactly 5050, because the agent handles them one by one.

A cache agent

type CacheMessage =
    | Set of key: string * value: string
    | TryGet of key: string * AsyncReplyChannel<string option>
    | Count of AsyncReplyChannel<int>

let cache =
    MailboxProcessor.Start(fun inbox ->
        let rec loop (store: Map<string, string>) =
            async {
                match! inbox.Receive() with
                | Set (k, v) -> return! loop (store |> Map.add k v)
                | TryGet (k, reply) ->
                    reply.Reply(store |> Map.tryFind k)
                    return! loop store
                | Count reply ->
                    reply.Reply store.Count
                    return! loop store
            }
        loop Map.empty)

cache.Post(Set ("lang", "F#"))
cache.Post(Set ("runtime", ".NET"))

printfn "%A" (cache.PostAndReply(fun ch -> TryGet ("lang", ch)))
printfn "%A" (cache.PostAndReply(fun ch -> TryGet ("editor", ch)))
printfn "%d" (cache.PostAndReply Count)
Some "F#"
None
2

match! receives and matches in one step.

When to use agents

  • Serializing access to a resource: a log file, a connection, a rate-limited API.
  • Keeping state in a long-running service: in-memory caches, game state, crawler queues.
  • Pipelines of workers passing messages to each other.

Agents live in one process. For distributed actors across machines, look at Akka.NET (which has an F# API) or Microsoft Orleans. Handle exceptions inside the loop: an unhandled exception stops the agent (subscribe to its Error event to find out).