Skip to content

VOL 09 / CH 03 / LESSON 02

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, signals from one sender to one receiver preserve send order. Selective receive can still process matching messages ahead of earlier unmatched ones, and multiple senders have no shared global order;
  • 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 {}

These Java 17 declarations describe message shapes, not an actor implementation. They do not validate fields: check nonblank order IDs and positive quantities at the boundary, and reject a reused idempotency key with a different payload.

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 does not resolve the external side effect. An outbox can atomically record local state and an intent to send; the eventual receiver still needs deduplication or an idempotency key to avoid a duplicate payment.

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,
	process func(context.Context, Job) error) {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			if ctx.Err() != nil {
				return
			}
			err := process(ctx, job)
			select {
			case results <- err:
			case <-ctx.Done():
				return
			}
		}
	}
}

This is a worker fragment for Go 1.22 or later; its containing file must import context and supply process. Both process and the result send receive the cancellation signal. The caller owns channel closure: when several workers share results, wait for all of them before closing it.

The signature expresses three 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
Backlog growth rate = 2,000 - 1,200 = 800 tasks/second

If the system keeps accepting work without limiting the backlog, it eventually exhausts any finite memory budget. 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.

As a rough starting point, draining 2,400 queued jobs at a steady 1,200 jobs per second takes about 2 seconds. This is not a per-request latency guarantee: job-size variation, bursts, and downstream stalls affect the tail. Choose an initial capacity from the latency and memory budgets, then measure queue waiting time and overload behavior. A larger queue stores the delay; it does not make the consumer faster.

Actors and Channels Go Beyond Syntax Differences ​

DimensionActorCSP / Channel
Core AbstractionAn 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 use 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.

Test the worker with an open input channel that has no jobs, then with a job processed but nobody receiving results. Cancel the context in each case and wait for worker exit; neither wait should hang. Also test normal completion after jobs is closed. The select on results fixes the blocked-send case, but it cannot forcibly stop a process callback that ignores its context. If cancellation and a send are both ready, either branch may win; cancellation does not retract a result already sent.

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