Skip to content

3.2 Message Concurrency: Actors, Channels, and Backpressure

Shared memory frames the problem as "who can access this state"; the message model reframes it as "who owns the state and how do other execution units make requests." This shift reduces the scope of shared state, but it does not automatically eliminate issues like message queuing, message loss, duplicate processing, or resource exhaustion.

Actor: Place State and Behavior Within the Same Boundary

An Actor typically consists of three components:

  • State that can only be directly modified by the Actor itself;
  • A mailbox for receiving messages;
  • A processing logic that determines the next step based on incoming messages.
text
Caller ──Reserve(orderId)──> Inventory Actor
                              ├─ Private inventory state
                              ├─ Duplicate detection record
                              └─ Mailbox
Caller <──Reserved / Rejected──┘

If an Actor processes one message at a time, then the execution of a single message does not need to contend with the state of another message from the same Actor. It provides ownership of state and message processing order, while concurrency remains present at the system level. Multiple Actors can run in parallel, and messages from different senders may arrive in arbitrary order.

Do not interpret Actors as following these absolute rules:

  • Local messages do not necessarily require byte serialization; the framework might pass object references directly. Therefore, messages should remain immutable;
  • A successful message send does not guarantee that the recipient has processed it;
  • Order guarantees are typically scoped. For example, in Erlang, messages sent by the same sender to the same receiver maintain send order, but this does not imply global ordering across multiple senders;
  • An Actor is not equivalent to an unbounded mailbox. Unbounded mailboxes convert message overload into uncontrolled memory growth.

Actor's Engineering Focus: Protocols and Fault Handling

Actors expose a set of message protocols as their interface:

java
sealed interface InventoryCommand {}
record Reserve(String orderId, int quantity) implements InventoryCommand {}
record Cancel(String orderId) implements InventoryCommand {}
record GetAvailability() implements InventoryCommand {}

Protocol design must be explicit about:

  • Whether messages are idempotent and how replaying them would behave;
  • How requests are linked to their corresponding responses;
  • Whether failures should trigger retries, be skipped, or cause termination;
  • What action to take when a mailbox becomes full, reject, block, discard, or degrade;
  • How actor state is restored after restart: from logs, snapshots, or external storage.

A supervision tree is a common pattern for organizing fault handling in the actor ecosystem: a parent actor monitors its children for failures and applies restart or termination strategies accordingly. It answers the question of who is responsible for handling failures, not whether failures should vanish. For instance, if a payment has already been initiated by a message, restarting the processor later still requires idempotency keys or a transactional message queue to prevent duplicate side effects.

Channel: Making Communication Itself a First-Class Object

In CSP-style programming, independent execution processes communicate primarily through channels. Senders typically don’t need to know the specific receivers, they only rely on the channel’s element type and its closure protocol.

go
type Job struct {
	ID  string
	URL string
}

func worker(ctx context.Context, jobs <-chan Job, results chan<- error) {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			results <- process(job)
		}
	}
}

This signature expresses three key properties:

  • jobs <-chan Job can only receive;
  • results chan<- error can only send;
  • context.Context carries cancellation and timeout semantics.

An unbuffered channel requires both sender and receiver to be ready simultaneously, making communication a synchronous point. A buffered channel allows temporary misalignment between production and consumption, but the buffer capacity defines how much backlog the system is willing to absorb.

Backpressure is a stability protocol

Suppose the ingress generates 2,000 tasks per second, but the downstream can only process 1,200:

text
Backpressure growth rate = 2,000 - 1,200 = 800 tasks/second

Any finite memory will eventually be exhausted. Increasing queue size merely delays the failure. The system must make an observable choice among these strategies:

  • Block producers: propagate pressure upstream;
  • Reject new tasks: fail fast and provide clear retry guidance to callers;
  • Drop or merge: suitable only for telemetry or refresh-type work that can tolerate loss;
  • Scale consumers: only if the bottleneck is truly horizontally scalable;
  • Reduce workload: lower the cost per individual task.

Queue capacity should be derived from acceptable wait time. For example, if the downstream maintains a stable throughput of 1,200 tasks per second and the business tolerates up to 2 seconds of queueing, the initial capacity estimate should not stray far from 2,400. Subsequent tuning should be based on burst traffic and memory usage under load testing, not on arbitrarily setting a value of one hundred thousand.

Actors and Channels Go Beyond Syntax Differences

DimensionActorCSP / Channel
Core AbstractionA entity with state and a mailboxA communication channel between execution processes
AddressingTypically sends messages to a specific Actor addressTypically sends messages to a specific Channel
State OwnershipState is encapsulated within the ActorState is determined by the processes using the Channel
Coordination MechanismAsynchronous messaging and behavior switchingSynchronous or buffered communication, selection operations
Common StrengthsEntity lifecycle management, supervision, location transparencyPipelining, fan-in/fan-out, backpressure
Common RisksMailbox accumulation, protocol evolution, failure replayGoroutine leaks, unclear closure semantics, circular waiting

Real-world systems can combine both approaches: Channels are used internally within Actors to schedule work, while pipeline stages leverage Actors to maintain persistent state. When choosing a model, focus on state ownership and fault boundaries, don’t align your decision with language communities.

Closing Protocol Determines Whether a System Can Properly Terminate

A common mistake in producer-consumer patterns isn't usually about data race conditions; it's about the absence of clear ownership over the closing signal:

  • It's typically the sender that closes the channel, and the receiver should never assume that no more messages will arrive;
  • When multiple senders are involved, a coordinator is needed to close the channel only after all senders have finished;
  • Cancellation and normal completion are distinct semantic states and cannot be conflated using a single null value;
  • If a consumer exits, any producer still blocking on sending must receive a cancellation signal; otherwise, it results in resource leaks.

These rules must be explicitly defined in APIs and tested, not left to team-level verbal agreements.

Completion Check

Draw the four stages of the "image processing pipeline": upload, virus scanning, transcoding, and storage. Then annotate each stage with:

  1. The maximum concurrent limit;
  2. Queue capacity and behavior when full;
  3. Who is responsible for closing the queue;
  4. What happens when a single image fails, should it be skipped, retried, or cause the entire pipeline to cancel;
  5. How to prevent the same task from being redundantly stored.

If the diagram contains only arrows without explicit capacity limits, cancellation paths, or failure handling, it is not a runnable concurrent design.

References

Built with VitePress | Software Systems Atlas