Message Passing

What if the chefs never shared a workspace? Each has their own kitchen, their own knives, their own ingredients. They communicate by passing notes through a window: "I need the sauce." "Here it is."

No collisions. No race conditions. No locks. Just messages.

This is message passing---instead of concurrent entities sharing state, they share messages. Each entity owns its state privately. Interaction happens only through explicit communication.

The Actor Model

The purest form of message passing is the actor model. An actor is:

erlang
    actor Counter {
        private count = 0
        
        on receive(Increment):
            count = count + 1
            
        on receive(Decrement):
            count = count - 1
            
        on receive(GetCount, replyTo):
            send(replyTo, count)
    }
  • An entity with private state (no one else can see or modify it)
  • A mailbox for incoming messages
  • Behavior that processes messages one at a time

Notice what's not here: no locks, no synchronization primitives. The actor's private state is protected by construction, not by convention.

Communication

Actors communicate by sending messages:

wollok
    counter = spawn(Counter)
    
    send(counter, Increment)
    send(counter, Increment)
    send(counter, Increment)
    
    send(counter, GetCount, replyTo: self)
    
    // Later, we receive the reply
    receive {
        count => print("Count is: " + count)
    }

Messages are asynchronous. send doesn't wait for a response---it drops the message in the recipient's mailbox and continues. The sender doesn't block.

Isolation

Each actor is isolated:

python
    // WRONG: direct access (not allowed in actor model)
    counter.count = 5
    
    // RIGHT: send a message
    send(counter, SetCount(5))
  • Cannot access another actor's state directly
  • Cannot call another actor's methods
  • Can only send messages and receive responses

Fault Tolerance

Isolation enables powerful fault tolerance. If an actor crashes, it doesn't corrupt other actors' state. Supervisors can detect failures and restart actors:

erlang
    supervisor = spawn(Supervisor, children: [
        Counter,
        Logger,
        NetworkHandler
    ])

    // If Counter crashes, Supervisor restarts it
    // Other actors continue unaffected

Visualizing Actor Systems

Actor systems form networks of communicating entities:

The diagram shows a typical request flow: Client sends to Server, Server queries Database, results flow back. The Logger receives messages from multiple actors but processes them sequentially---no log entries get interleaved or corrupted.

Channels

An alternative to actors is channels---typed pipes that connect concurrent processes.

erlang
    channel = new Channel<Integer>()
    
    // Producer
    spawn(() => {
        for i in 1..100:
            channel.send(i)
    })
    
    // Consumer
    spawn(() => {
        while true:
            value = channel.receive()  // Blocks until value available
            process(value)
    })

Channels are the foundation of Communicating Sequential Processes (CSP), a mathematical model of concurrency. Go and Clojure's core.async are built on this model.

Channel Operations

erlang
    // Send: put a value in the channel
    channel.send(value)
    
    // Receive: take a value from the channel (blocks if empty)
    value = channel.receive()
    
    // Select: wait for any of multiple channels
    select {
        case msg = <-channel1:
            handleType1(msg)
        case msg = <-channel2:
            handleType2(msg)
    }

Buffered vs Unbuffered

Channels can be buffered or unbuffered:

python
    // Unbuffered: send blocks until someone receives
    unbuffered = new Channel<Integer>()
    
    // Buffered: send succeeds if buffer not full
    buffered = new Channel<Integer>(capacity: 10)

Unbuffered channels create a rendezvous---sender and receiver must both be ready. This synchronizes them. Buffered channels allow the sender to continue without waiting, up to the buffer size.

Agents

Clojure offers agents---a middle ground between shared state and message passing:

sql
    counter = agent(0)
    
    // Send an update function (asynchronous)
    send(counter, (current) => current + 1)
    send(counter, (current) => current + 1)
    
    // Read current value (always safe)
    value = deref(counter)

Agents have identity (unlike pure immutability) but coordinate updates (unlike raw mutable state). Updates are functions, not direct mutations.

Comparing Approaches

ActorsChannelsAgents
UnitActor (entity)Channel (pipe)Agent (identity)
StatePrivate per actorNone in channelManaged value
CommunicationAsync messagesSync or asyncAsync functions
TopologyNetwork of actorsPipelinesShared references

Actors are best when you have many independent entities with their own state and behavior. Think game characters, user sessions, device controllers.

Channels are best for pipelines and producer-consumer patterns. Data flows through stages of processing.

Agents are best when you need shared state with safe updates. Like reference types that coordinate automatically.

Patterns with Message Passing

Request-Reply

wollok
    // Client sends request with reply channel
    replyChannel = new Channel<Response>()
    send(server, Request(data, replyTo: replyChannel))
    
    // Wait for response
    response = replyChannel.receive()

Fan-Out

erlang
    // Distribute work to multiple workers
    workers = [spawn(Worker) for _ in 1..10]
    
    for task in tasks:
        worker = workers[task.id % workers.length]
        send(worker, task)

Fan-In

erlang
    // Collect results from multiple sources
    results = new Channel<Result>()
    
    for source in sources:
        spawn(() => {
            data = fetch(source)
            results.send(data)
        })
    
    // Collect all results
    all_results = [results.receive() for _ in sources]

Pipeline

erlang
    raw = new Channel<RawData>()
    parsed = new Channel<ParsedData>()
    validated = new Channel<ValidData>()
    
    spawn(() => {  // Stage 1: Parse
        while data = raw.receive():
            parsed.send(parse(data))
    })
    
    spawn(() => {  // Stage 2: Validate
        while data = parsed.receive():
            validated.send(validate(data))
    })
    
    spawn(() => {  // Stage 3: Store
        while data = validated.receive():
            store(data)
    })

Trade-offs

Message passing isn't free:

Copying overhead: Messages are often copied between actors. Large data structures mean large copies.

Latency: Asynchronous messages have inherent latency. Direct function calls are faster.

Complexity in coordination: Some problems are naturally about shared state. Forcing them into messages can be awkward.

Debugging: Following message flows through actors can be harder than following a call stack.

But message passing eliminates entire classes of bugs:

  • No data races (nothing is shared)
  • No deadlocks (no locks)
  • Failures are contained (isolation)
  • Scaling is natural (actors can be distributed)

The Philosophy

Message passing reflects a particular view of the world: entities are autonomous, with their own state and behavior. They interact through explicit communication, not telepathic access to each other's minds.

When you choose message passing, you're choosing:

  • Explicit over implicit communication
  • Isolation over sharing
  • Asynchrony over synchrony
  • Distribution-ready architecture

We've seen two approaches to concurrency: managing shared state with locks, and avoiding shared state with messages. The next chapter explores a third perspective: reactive programming, where values propagate changes automatically.