Message Passing

otp

Message Passing

Definition

Message passing is the sole mechanism by which BEAM processes communicate. Processes cannot access each other's memory directly; instead, they send messages that are deep-copied into the recipient's mailbox. The recipient process selectively matches and retrieves messages using receive blocks or GenServer callbacks. This architectural choice -- shared-nothing concurrency with asynchronous message passing -- is the foundation of Erlang/OTP's legendary reliability and the enabling mechanism for the Prismatic Platform's distributed, fault-tolerant architecture.

The message passing model eliminates entire categories of concurrency bugs that plague shared-memory systems: race conditions on mutable state, lock contention, deadlocks from lock ordering, priority inversion, and memory corruption from concurrent access. These bugs are not merely unlikely in BEAM -- they are structurally impossible because there is no shared mutable state to race on, no locks to contend for, and no memory that multiple processes can simultaneously modify. The price paid for this guarantee is the CPU cost of message copying, which the BEAM runtime minimizes through efficient memory allocation and, for large binaries, reference-counted sharing.

Message passing also enables transparent distribution: sending a message to a process on another BEAM node uses identical syntax and semantics as local messaging. The BEAM runtime handles serialization, network transport, and delivery. This transparency means that code written for a single node can be distributed across a cluster without modification -- a property the Prismatic Platform leverages for horizontal scaling of its 530-agent architecture across multiple nodes.

Overview

The concept of message passing in concurrent systems predates the BEAM by several decades. Tony Hoare's Communicating Sequential Processes (CSP, 1978) and Carl Hewitt's Actor Model (1973) established the theoretical foundations for process-based concurrency without shared state. The Erlang language, designed at Ericsson in the late 1980s for telecommunications systems, adopted the actor model as its core concurrency primitive, and the BEAM virtual machine was purpose-built to execute millions of lightweight processes communicating exclusively through message passing.

In the Prismatic Platform, message passing is not an implementation detail but an architectural principle. Every interaction between the platform's 530+ AIAD agents, every telemetry event propagated to monitoring dashboards, every real-time UI update pushed through Phoenix LiveView, and every supervision signal flowing through the process hierarchy uses message passing as its transport. Understanding message passing is therefore prerequisite to understanding the platform's architecture at any level.

AspectDescription
ModelActor model with mailbox-based asynchronous messaging
IsolationComplete process isolation; no shared mutable state
CopyingDeep copy semantics (except large binaries: reference-counted)
DistributionTransparent across BEAM nodes via Distributed Erlang
OrderingPer-pair FIFO guaranteed; no global ordering
DeliveryAt-most-once by default; higher guarantees layered on top

Fundamental Primitives

BEAM message passing is built on three primitives: send, receive, and process identification.

send/2

The send/2 function (or Kernel.send/2) delivers a message to a process's mailbox. It is asynchronous: the sender does not wait for the recipient to process the message.

# Send a message to a process by PID
send(pid, {:security_finding, %{ip: "192.168.1.1", severity: :high}})

# Send to a registered process by name
send(:asset_registry, {:register_asset, asset})

# Send to a process on a remote node
send({:asset_registry, :"prismatic@node2"}, {:register_asset, asset})

Key properties of send/2:

PropertyDescription
AsynchronousReturns immediately; does not wait for delivery or processing
Non-blockingNever blocks the sender, regardless of recipient state
Copy semanticsMessage is deep-copied into recipient's heap
Always succeedsNever raises, even if recipient process has crashed
Returns messageReturn value is the sent message (useful for piping)

receive/1

The receive block pattern-matches against messages in the process mailbox, extracting and processing matching messages.

receive do
  {:security_finding, %{severity: :critical} = finding} ->
    handle_critical_finding(finding)

  {:security_finding, %{severity: severity} = finding} when severity in [:high, :medium] ->
    queue_for_review(finding)

  {:heartbeat, timestamp} ->
    update_last_seen(timestamp)

after
  5_000 ->
    # Timeout: no matching message in 5 seconds
    handle_timeout()
end

Selective Receive

BEAM's selective receive is a distinctive feature: the receive block scans the mailbox for the first message matching any of its patterns, skipping non-matching messages. Non-matching messages remain in the mailbox for future receive calls.

Mailbox: [msg_A, msg_B, msg_C, msg_D, msg_E]

receive do
  msg_C -> process(msg_C)    # Matches msg_C, skips msg_A and msg_B
end

Mailbox after: [msg_A, msg_B, msg_D, msg_E]    # msg_C consumed

This enables processes to prioritize certain message types without losing other messages. However, selective receive has a performance implication: if a process accumulates many non-matching messages, each receive call must scan through them. The Prismatic Platform mitigates this by ensuring processes handle all expected message types (preventing mailbox accumulation) and by using GenServer's structured callback model rather than raw receive blocks.

Message Ordering Guarantees

BEAM provides specific ordering guarantees for message delivery.

GuaranteeScopeDescription
Per-pair orderingBetween two specific processesMessages from process A to process B arrive in send order
No global orderingAcross all processesMessages from A and C to B may interleave arbitrarily
No delivery guaranteeNetwork partitionsMessages may be lost if the network drops them
At-most-once deliveryDefault semanticsMessages are never duplicated by the runtime
Process A sends to B: [m1, m2, m3]  → B receives: [m1, m2, m3] (order preserved)
Process C sends to B: [m4, m5]      → B receives: [m4, m5] (order preserved)
Combined at B: [m1, m4, m2, m5, m3] (interleaved, but A's and C's relative order preserved)

For the Prismatic Platform, per-pair ordering is sufficient for most agent communication patterns. When global ordering is required (e.g., for event sourcing or audit logs), additional coordination mechanisms (sequence numbers, vector clocks, or ordered PubSub topics) are layered on top of raw message passing.

Copy Semantics and Performance

When a message is sent, the BEAM runtime deep-copies it from the sender's heap into the recipient's heap. This copy is essential for process isolation: without it, the sender could modify the message after sending, violating the recipient's assumptions.

What Gets Copied

Data TypeCopy BehaviorPerformance
Small terms (atoms, integers, small tuples)Full deep copyMicroseconds
Lists and mapsFull deep copy (recursive)Proportional to size
Binaries < 64 bytesFull copy (heap binary)Microseconds
Binaries >= 64 bytesReference copy (refc binary)Constant time
Large structsFull deep copyProportional to size

The large binary optimization is significant: when sending a binary of 64 bytes or more, the BEAM copies only a reference (pointer) rather than the entire binary. The binary itself lives in a shared, reference-counted area outside any individual process heap. This means that broadcasting a large OSINT data payload to multiple agents incurs near-constant cost regardless of the number of recipients.

# Small message: full copy (fast, proportional to size)
send(agent_pid, {:finding, %{ip: "10.0.0.1", port: 443}})

# Large binary: reference copy (constant time)
large_payload = File.read!("osint_results.json")  # 500KB
send(agent_pid, {:bulk_data, large_payload})  # Only copies reference

Performance Guidelines

GuidelineRationale
Keep messages small when possibleReduces copy overhead
Use binaries for large dataTriggers reference-counted sharing
Avoid sending large nested structures frequentlyDeep copy is proportional to depth and breadth
Prefer PubSub for broadcastingSingle copy to PubSub, distributed to subscribers
Profile with :observer for mailbox growthDetect processes accumulating messages

GenServer: Structured Message Passing

While raw send/receive is available, the Prismatic Platform overwhelmingly uses GenServer for inter-process communication. GenServer provides a structured layer over message passing with three communication patterns.

call (Synchronous Request-Reply)

# Client: sends message and waits for reply (default 5s timeout)
{:ok, rating} = GenServer.call(SecurityRater, {:rate_domain, "example.com"})

# Server: handles the call and replies
@impl GenServer
def handle_call({:rate_domain, domain}, _from, state) do
  rating = compute_rating(domain, state)
  {:reply, {:ok, rating}, state}
end

call is implemented as: send a tagged message, enter a receive block waiting for a reply with a matching tag. The tag is a unique reference that prevents reply confusion.

cast (Asynchronous Fire-and-Forget)

# Client: sends message and returns immediately
GenServer.cast(AssetRegistry, {:register, asset})

# Server: handles the cast (no reply)
@impl GenServer
def handle_cast({:register, asset}, state) do
  new_state = Map.put(state.assets, asset.id, asset)
  {:noreply, new_state}
end

info (Process Messages)

# Any process message (timers, monitors, PubSub, raw sends)
@impl GenServer
def handle_info({:DOWN, _ref, :process, pid, reason}, state) do
  # A monitored process has crashed
  new_state = handle_process_down(pid, reason, state)
  {:noreply, new_state}
end

@impl GenServer
def handle_info(:periodic_scan, state) do
  perform_scan(state)
  Process.send_after(self(), :periodic_scan, :timer.minutes(5))
  {:noreply, state}
end

GenServer Communication Summary

PatternFunctionBlocking?Reply?Use Case
callGenServer.call/3Yes (with timeout)YesQueries, synchronous operations
castGenServer.cast/2NoNoCommands, fire-and-forget
infohandle_info/2N/A (incoming)NoSystem messages, timers, monitors

Mailbox Semantics

Every BEAM process has a mailbox -- an ordered queue of received messages. Understanding mailbox behavior is important for performance and correctness.

Mailbox as a Queue

Arrival order: msg1, msg2, msg3, msg4, msg5

Mailbox: [msg1 | msg2 | msg3 | msg4 | msg5]
          ^
          receive scans from here

Selective receive matching msg3:
  Scans: msg1 (skip), msg2 (skip), msg3 (MATCH)
  Mailbox after: [msg1 | msg2 | msg4 | msg5]

Mailbox Overflow

Mailboxes are unbounded by default. If a process receives messages faster than it processes them, the mailbox grows without limit, consuming memory until the BEAM VM runs out of memory. This is a common source of production issues in message-heavy systems.

SymptomDiagnosisSolution
Growing memory usage:erlang.process_info(pid, :message_queue_len)Profile message processing rate
Slow receiveLarge mailbox causes linear scanHandle all message types, avoid selective receive on large mailboxes
OOM crashMailbox exceeds available memoryAdd backpressure, rate limit senders

The Prismatic Platform prevents mailbox overflow through several mechanisms:

  • GenStage/Broadway backpressure: Consumers request only as many messages as they can process
  • PubSub with demand: Subscribers control message flow rate
  • Process monitoring: Telemetry tracks message_queue_len across all critical processes
  • Circuit breakers: Stop sending to overloaded processes

Message passing integrates with BEAM's process monitoring and linking mechanisms to enable fault-tolerant communication patterns.

Monitors (Asymmetric)

# Monitor a process - receive :DOWN message if it crashes
ref = Process.monitor(worker_pid)

receive do
  {:DOWN, ^ref, :process, ^worker_pid, reason} ->
    handle_worker_crash(reason)
end
# Link two processes - if either crashes, the other receives an exit signal
Process.link(worker_pid)

# Trap exits to handle link signals as messages
Process.flag(:trap_exit, true)

receive do
  {:EXIT, ^worker_pid, reason} ->
    handle_linked_process_exit(reason)
end
MechanismDirectionSignalDefault BehaviorUse Case
MonitorOne-way (watcher to target):DOWN messageMessage delivered to watcherTracking external process health
LinkBidirectionalExit signalLinked process crashes tooSupervisor-child relationships

Advanced Message Patterns

Beyond the fundamental primitives, the Prismatic Platform implements several advanced message passing patterns that leverage BEAM's capabilities for complex coordination scenarios.

Request-Reply with Correlation

When multiple concurrent requests are outstanding, correlation IDs prevent reply confusion:

defmodule Prismatic.MessagePatterns.CorrelatedRequest do
  @moduledoc """
  Request-reply pattern with explicit correlation IDs for
  disambiguating responses when multiple requests are in flight.
  """

  @spec request(pid(), term(), timeout()) :: {:ok, term()} | {:error, :timeout}
  def request(target, payload, timeout \\ 5_000) do
    correlation_id = make_ref()
    send(target, {:request, self(), correlation_id, payload})

    receive do
      {:response, ^correlation_id, result} -> {:ok, result}
    after
      timeout -> {:error, :timeout}
    end
  end

  @spec reply(pid(), reference(), term()) :: :ok
  def reply(caller, correlation_id, result) do
    send(caller, {:response, correlation_id, result})
    :ok
  end
end

Scatter-Gather for Multi-Agent Coordination

When the platform needs input from multiple agents simultaneously:

defmodule Prismatic.MessagePatterns.ScatterGather do
  @moduledoc """
  Scatter-gather pattern: broadcast a request to multiple agents
  and collect their responses within a timeout window.
  """

  @spec scatter_gather([pid()], term(), timeout()) ::
          %{responses: [term()], timeouts: [pid()]}
  def scatter_gather(agents, request, timeout \\ 10_000) do
    correlation_id = make_ref()

    Enum.each(agents, fn agent ->
      send(agent, {:scatter, self(), correlation_id, request})
    end)

    gather(agents, correlation_id, timeout, %{responses: [], timeouts: []})
  end

  defp gather([], _correlation_id, _timeout, acc), do: acc

  defp gather(remaining, correlation_id, timeout, acc) do
    start = System.monotonic_time(:millisecond)

    receive do
      {:gather, ^correlation_id, agent_pid, result} ->
        elapsed = System.monotonic_time(:millisecond) - start
        new_remaining = List.delete(remaining, agent_pid)
        new_acc = %{acc | responses: [{agent_pid, result} | acc.responses]}
        gather(new_remaining, correlation_id, max(0, timeout - elapsed), new_acc)
    after
      timeout ->
        %{acc | timeouts: remaining}
    end
  end
end

Dead Letter Handling

Messages sent to crashed processes are silently lost in BEAM. The platform implements dead letter handling for critical message paths:

defmodule Prismatic.MessagePatterns.DeadLetterHandler do
  @moduledoc """
  Captures messages that could not be delivered to their intended
  recipient and routes them to a dead letter queue for investigation.
  """

  use GenServer

  @type dead_letter :: %{
    intended_recipient: pid() | atom(),
    message: term(),
    reason: :process_not_found | :process_crashed,
    timestamp: DateTime.t()
  }

  @spec safe_send(pid() | atom(), term()) :: :ok | {:dead_letter, dead_letter()}
  def safe_send(target, message) when is_pid(target) do
    if Process.alive?(target) do
      send(target, message)
      :ok
    else
      dead_letter = %{
        intended_recipient: target,
        message: message,
        reason: :process_crashed,
        timestamp: DateTime.utc_now()
      }

      GenServer.cast(__MODULE__, {:record, dead_letter})
      {:dead_letter, dead_letter}
    end
  end

  def safe_send(name, message) when is_atom(name) do
    case Process.whereis(name) do
      nil ->
        dead_letter = %{
          intended_recipient: name,
          message: message,
          reason: :process_not_found,
          timestamp: DateTime.utc_now()
        }

        GenServer.cast(__MODULE__, {:record, dead_letter})
        {:dead_letter, dead_letter}

      pid ->
        safe_send(pid, message)
    end
  end
end

Prismatic's Message-Driven Architecture

The Prismatic Platform uses message passing as the fundamental communication mechanism across all subsystems.

Communication PatternMechanismExample
Agent coordinationGenServer call/castAgent requesting data from another agent
Telemetry propagationPubSub broadcastQuality metrics distributed to dashboards
Supervision signalsLinks + monitorsSupervisor detecting child process crash
Pipeline flowGenStage demand-drivenBroadway producer sending events to processor
Real-time UI updatesPubSub to LiveViewSecurity rating update pushed to dashboard
Color Team signalsBlackboard + PubSubGray findings triggering Red Team analysis
Inter-node communicationDistributed ErlangCross-node agent coordination

PubSub: Scalable Message Distribution

Phoenix PubSub provides a higher-level abstraction over message passing for one-to-many communication.

# Publisher: broadcast to all subscribers
Phoenix.PubSub.broadcast(
  PrismaticWeb.PubSub,
  "security:ratings",
  {:rating_updated, %{domain: "example.com", grade: :B, score: 780}}
)

# Subscriber: receive broadcasts as process messages
Phoenix.PubSub.subscribe(PrismaticWeb.PubSub, "security:ratings")

# In GenServer or LiveView
@impl true
def handle_info({:rating_updated, rating}, state) do
  {:noreply, update_dashboard(state, rating)}
end

PubSub is implemented on top of message passing: a PubSub server maintains a registry of topic subscriptions, and when a message is broadcast to a topic, it is sent (via send/2) to each subscribed process. For distributed PubSub, the broadcast is forwarded to PubSub servers on other nodes, which then deliver to their local subscribers.

Transparent Distribution

One of BEAM's most powerful features is that message passing works identically across node boundaries.

# Local message passing
send(local_pid, {:analyze, domain})

# Remote message passing - identical syntax
send({:analyzer, :"prismatic@node2"}, {:analyze, domain})

# GenServer call - also works remotely
GenServer.call({AssetRegistry, :"prismatic@node2"}, {:lookup, "example.com"})

The BEAM runtime handles serialization (using Erlang's External Term Format), TCP transport, and delivery transparently. The only difference is latency and the possibility of network failures.

AspectLocalDistributed
Syntaxsend(pid, msg)send({name, node}, msg)
LatencyMicrosecondsMilliseconds (network-dependent)
DeliveryGuaranteed (process alive)May fail (network partition)
OrderingPer-pair guaranteedPer-pair guaranteed (within TCP)
Copy costHeap-to-heap copySerialization + network + deserialization

Message Passing Anti-Patterns

Understanding what not to do is as important as understanding the correct patterns. The following anti-patterns are actively prevented in the Prismatic Platform through code review and static analysis.

Unbounded Message Accumulation

Sending messages without regard for the recipient's processing capacity leads to mailbox overflow. The platform enforces backpressure through GenStage demand-driven pipelines.

Synchronous Call Chains

A chain of GenServer.call invocations (A calls B, B calls C, C calls D) creates implicit synchronization points that can deadlock if any process in the chain is blocked or slow. The platform limits call chains to a maximum depth of 3 and prefers asynchronous cast with eventual consistency for deep coordination.

Large Message Broadcasting

Broadcasting large messages to many subscribers multiplies the copy cost. The platform uses reference-based indirection: broadcast a small reference, and let each subscriber fetch the full data via a shared ETS table or process when needed.

Process Mailbox as a Queue

Using a process mailbox as a persistent queue (sending thousands of messages with the expectation that they will be processed "eventually") is an anti-pattern. Mailboxes are designed for active communication, not storage. The platform uses Broadway and dedicated queue processes for buffering workloads.

Comparison with Other Messaging Systems

SystemModelDelivery GuaranteeOrderingLatency
BEAM message passingActor modelAt-most-oncePer-pair FIFOMicroseconds (local)
RabbitMQBroker-basedAt-least-once (with acks)Per-queue FIFOMilliseconds
Apache KafkaLog-basedAt-least-oncePer-partitionMilliseconds
gRPCRPCExactly-once (with retries)Per-streamMilliseconds
Go channelsCSPSynchronousPer-channel FIFONanoseconds
AkkaActor modelAt-most-oncePer-pair FIFOMicroseconds

BEAM message passing trades delivery guarantees (at-most-once) for simplicity and performance. When stronger guarantees are needed, the platform layers acknowledgment protocols, persistent queues, or external message brokers on top of the native primitives.

Best Practices

  1. Prefer GenServer over raw send/receive: GenServer provides timeouts, monitoring integration, structured callbacks, and telemetry hooks that raw message passing does not.

  2. Handle all expected message types: Every GenServer should have a catch-all handle_info/2 clause that logs unexpected messages rather than letting them accumulate in the mailbox.

  3. Use PubSub for one-to-many communication: Avoid manually broadcasting to a list of PIDs; PubSub handles subscriber management, distribution, and cleanup.

  4. Monitor message queue lengths in production: Add telemetry reporters that track :message_queue_len for critical processes and alert when queues grow beyond expected bounds.

  5. Design messages for pattern matching: Structure messages as tagged tuples ({:event_type, payload}) rather than opaque maps, enabling efficient selective receive and clear documentation.

  6. Keep messages immutable and self-contained: Messages should carry all necessary context; avoid sending references to mutable state that could change between send and receive.

  • BEAM - Virtual machine implementing the message passing runtime and scheduler
  • GenServer - Structured abstraction over raw message passing with call/cast/info patterns
  • Process Isolation - BEAM property that makes shared-nothing message passing necessary
  • PubSub - One-to-many message distribution built on process message passing
  • Channel - Phoenix Channel providing client-server message passing over WebSocket
  • Supervisor - Process supervision using links and exit signals
  • Backpressure - Flow control preventing mailbox overflow in high-throughput pipelines
  • Distributed System - Architecture leveraging BEAM's transparent distribution
  • Pattern Matching - Language feature enabling selective receive and message dispatch
  • Fault Tolerance - System property enabled by process isolation and message passing

See Also

  • Architecture - Platform architecture and inter-process communication patterns
  • Technologies - BEAM runtime and OTP framework details
  • Agents - Agent communication patterns using message passing

Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL