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.
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:
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.
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 Jobcan only receive;results chan<- errorcan only send;context.Contextcarries 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:
Backpressure growth rate = 2,000 - 1,200 = 800 tasks/secondAny 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
| Dimension | Actor | CSP / Channel |
|---|---|---|
| Core Abstraction | A entity with state and a mailbox | A communication channel between execution processes |
| Addressing | Typically sends messages to a specific Actor address | Typically sends messages to a specific Channel |
| State Ownership | State is encapsulated within the Actor | State is determined by the processes using the Channel |
| Coordination Mechanism | Asynchronous messaging and behavior switching | Synchronous or buffered communication, selection operations |
| Common Strengths | Entity lifecycle management, supervision, location transparency | Pipelining, fan-in/fan-out, backpressure |
| Common Risks | Mailbox accumulation, protocol evolution, failure replay | Goroutine 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:
- The maximum concurrent limit;
- Queue capacity and behavior when full;
- Who is responsible for closing the queue;
- What happens when a single image fails, should it be skipped, retried, or cause the entire pipeline to cancel;
- 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.