Common Concurrency Patterns
Actor Model
Read aloud is unavailable in this browser.
A Different Vision of Concurrency
In 1973, Carl Hewitt proposed a radically different approach to concurrent computation. Rather than threads sharing memory and coordinating through locks, he envisioned a world of independent actors—self-contained units of computation that interact only by sending messages to each other. No shared state. No locks. No race conditions.
This is the actor model, and while it remained largely academic for decades, it has found powerful expression in modern systems: Erlang's fault-tolerant telecommunications systems, Akka's distributed JVM applications, and Orleans' virtual actors in cloud gaming. WhatsApp's 900+ million users are served by Erlang actors. These aren't toy systems—they're production infrastructure handling millions of concurrent connections.
By the end of this page, you will understand the actor model deeply: its core principles and how they eliminate traditional concurrency hazards, the message-passing semantics that govern actor communication, how actors enable both local and distributed concurrency with the same programming model, supervision hierarchies for fault tolerance, and practical implementation patterns across actor frameworks.
Core Principles of the Actor Model
The actor model is built on a small set of powerful principles that fundamentally change how we reason about concurrent systems.
What is an Actor?
An actor is the fundamental unit of computation. Each actor encapsulates:
- State: Private data that no other actor can directly access
- Behavior: Logic that processes incoming messages
- Mailbox: A queue of pending messages to be processed
Actors are reactive—they only execute in response to messages. Between messages, an actor is dormant, consuming no CPU cycles.
| Axiom | Description | Implication |
|---|---|---|
| Create | An actor can create new actors | Enables dynamic system structure |
| Send | An actor can send messages to other actors | All communication is asynchronous |
| Become | An actor can designate behavior for next message | State transitions are explicit |
Key Properties:
1. Isolation (Share Nothing)
Actors share no mutable state. Each actor's memory is completely private. The only way to affect another actor's state is to send it a message—which the receiver processes when ready. This eliminates:
- Race conditions (no concurrent access to shared data)
- Deadlocks (no locks to hold and wait on)
- Data corruption (no torn reads/writes)
2. Asynchronous Message Passing
Message sends are fire-and-forget—the sender doesn't wait for a response. Messages are queued in the receiver's mailbox and processed one at a time. This enables:
- Natural concurrency (send continues immediately)
- Location transparency (local and remote actors look the same)
- Non-blocking operation (no thread tied up waiting)
3. Sequential Message Processing
Each actor processes one message at a time, in order of arrival. This provides:
- Simplified reasoning (no internal concurrency)
- Atomicity (message processing is atomic from actor's perspective)
- Consistency (state changes are sequential)
Threads share memory and require explicit synchronization. Actors share nothing and communicate through messages. A single thread can multiplex many actors (millions in Erlang). Actor systems are typically built on a small thread pool, with runtime scheduling actors onto threads as messages arrive.
Message Passing Semantics
Understanding the semantics of message passing is crucial for building correct actor systems.
Message Delivery Guarantees:
Different actor systems provide different guarantees about message delivery:
| Guarantee | Meaning | Examples |
|---|---|---|
| At-most-once | Message delivered 0 or 1 time | Default in Akka, Erlang (local) |
| At-least-once | Message delivered 1+ times | With acknowledgments and retry |
| Exactly-once | Message delivered exactly 1 time | Requires distributed transactions |
Message Ordering:
A critical question: if actor A sends messages M1, M2, M3 to actor B, in what order does B receive them?
Guarantee: Messages from one actor to another are delivered in send order (FIFO per sender-receiver pair).
Non-guarantee: Messages from different senders may interleave arbitrarily.
12345678910111213141516171819
// Message ordering example (Akka/Scala) // Actor A sends to C: M1, M2, M3// Actor B sends to C: X1, X2 // GUARANTEED: C receives M1 before M2 before M3// GUARANTEED: C receives X1 before X2// NOT GUARANTEED: relative order of M* and X* // Possible orderings C might see:// M1, M2, M3, X1, X2 (all A first)// M1, X1, M2, X2, M3 (interleaved)// X1, X2, M1, M2, M3 (all B first)// M1, X1, X2, M2, M3 (interleaved differently) // For global ordering, use techniques like:// 1. Single coordinator actor// 2. Sequence numbers// 3. Vector clocksRequest-Response Pattern:
While sends are asynchronous, we often need responses. The actor model handles this with explicit reply messages:
1234567891011121314151617181920212223242526272829303132333435
// Akka request-response pattern import akka.actor.{Actor, ActorRef, Props} // Request message includes reply-to addresscase class Request(query: String, replyTo: ActorRef)case class Response(result: String) class ServerActor extends Actor { def receive = { case Request(query, replyTo) => val result = processQuery(query) replyTo ! Response(result) // Send reply } def processQuery(q: String): String = s"Result for: $q"} class ClientActor(server: ActorRef) extends Actor { def receive = { case "start" => // Include self as reply address server ! Request("hello", self) case Response(result) => println(s"Got response: $result") }} // Modern Akka also supports ask pattern (Futures)import akka.pattern.askimport scala.concurrent.duration._ implicit val timeout = Timeout(5.seconds)val future = server ? Request("hello", _) // Returns Future[Response]The actor model philosophy prefers 'tell' (fire-and-forget) over 'ask' (request-response). Ask creates a Future that ties up resources waiting. Tell allows the sender to continue immediately. Design actors to tell each other what to do, not ask and wait for answers.
Erlang/OTP: The Original Actor System
Erlang was designed from the ground up around the actor model (though Erlang calls actors "processes"). Developed at Ericsson for telecommunications, Erlang pioneered many patterns now common in actor systems.
Basic Erlang Actors (Processes):
123456789101112131415161718192021222324252627282930313233343536373839
-module(counter).-export([start/0, increment/1, get/1, loop/1]). %% Start a new counter actorstart() -> spawn(fun() -> loop(0) end). %% Create actor with initial state 0 %% Send increment messageincrement(Pid) -> Pid ! increment. %% Fire-and-forget send %% Send get request (include reply address)get(Pid) -> Pid ! {get, self()}, receive {count, Value} -> Value after 5000 -> timeout end. %% Actor's message processing looploop(Count) -> receive increment -> loop(Count + 1); %% Tail recursion with new state {get, From} -> From ! {count, Count}, %% Send reply loop(Count); %% Continue with same state stop -> ok %% Exit the loop (actor terminates) end. %% Usage:%% Counter = counter:start().%% counter:increment(Counter).%% counter:increment(Counter).%% counter:get(Counter). %% Returns 2Key Erlang/Actor Features:
1. Lightweight Processes
Erlang processes are extremely lightweight—a process uses only ~300 bytes initially. A single Erlang VM can run millions of processes. This enables "one process per connection" patterns that would be infeasible with OS threads.
2. Immutable Data
Erlang variables are immutable—once bound, they cannot change. This eliminates aliasing problems and makes message passing safe (no need to copy data).
3. Pattern Matching
The receive block uses pattern matching to select which messages to handle. Unmatched messages remain in the mailbox (selective receive).
4. Let It Crash
Rather than defensive programming with extensive error handling, Erlang embraces failure. Let processes crash; supervisors restart them. This leads to simpler code and more resilient systems.
WhatsApp serves 900+ million users with fewer than 50 engineers, running on Erlang. A single WhatsApp server handles 2+ million concurrent connections—each connection is an Erlang process. This demonstrates the actor model's power for massive concurrency.
Akka: Actors on the JVM
Akka brings the actor model to the JVM ecosystem (Scala and Java), adding features for distributed systems, persistence, and cluster management.
Akka Actor Definition:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
import akka.actor.{Actor, ActorRef, ActorSystem, Props} // Message definitions (case classes for pattern matching)sealed trait CounterMessagecase object Increment extends CounterMessagecase object Decrement extends CounterMessagecase class GetCount(replyTo: ActorRef) extends CounterMessagecase class CountResult(value: Int) // Actor implementationclass CounterActor extends Actor { // Private mutable state private var count: Int = 0 // Message handler def receive: Receive = { case Increment => count += 1 println(s"Incremented to $count") case Decrement => count -= 1 println(s"Decremented to $count") case GetCount(replyTo) => replyTo ! CountResult(count) }} // Usageobject CounterApp extends App { // Create actor system (container for actors) val system = ActorSystem("counter-system") // Create counter actor val counter = system.actorOf(Props[CounterActor], "counter") // Send messages (fire-and-forget) counter ! Increment counter ! Increment counter ! Decrement // For request-response, create a temporary actor or use ask pattern import akka.pattern.ask import akka.util.Timeout import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.Implicits.global implicit val timeout = Timeout(5.seconds) (counter ? GetCount(null)).mapTo[CountResult].foreach { result => println(s"Final count: ${result.value}") system.terminate() }}Akka's Typed Actors:
Akka Typed (now the recommended API) provides compile-time type safety for actor messages:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
import akka.actor.typed.{ActorRef, ActorSystem, Behavior}import akka.actor.typed.scaladsl.Behaviors // Typed message protocolsealed trait Commandcase object Increment extends Commandcase class GetCount(replyTo: ActorRef[Int]) extends Command // Typed actor behaviorobject Counter { def apply(): Behavior[Command] = counter(0) private def counter(count: Int): Behavior[Command] = Behaviors.receive { (context, message) => message match { case Increment => context.log.info(s"Incremented to ${count + 1}") counter(count + 1) // Return new behavior with updated state case GetCount(replyTo) => replyTo ! count Behaviors.same // Keep same behavior } }} // Usage@main def run(): Unit = { val system: ActorSystem[Command] = ActorSystem(Counter(), "typed-counter") system ! Increment system ! Increment // Ask pattern with typed ActorRef import akka.actor.typed.scaladsl.AskPattern._ import scala.concurrent.duration._ given timeout: akka.util.Timeout = 3.seconds given scheduler: akka.actor.typed.Scheduler = system.scheduler given ec: scala.concurrent.ExecutionContext = system.executionContext val result = system.ask[Int](ref => GetCount(ref)) result.foreach(count => println(s"Count: $count"))}In Akka Typed, state changes are expressed by returning a new Behavior. The counter(count + 1) call returns a behavior that will handle the next message with the incremented count. This functional approach makes state management explicit and safer.
Supervision and Fault Tolerance
One of the actor model's most powerful features is its approach to fault tolerance through supervision hierarchies. Rather than handling every possible error locally, actors delegate failure handling to supervisors.
The Supervision Hierarchy:
Actors form a tree structure:
- Every actor has exactly one supervisor (its parent)
- Parents supervise their children
- The root of the tree is the actor system's guardian
When a child actor fails (throws an exception), the supervisor decides how to respond:
| Strategy | Action | Use Case |
|---|---|---|
| Resume | Continue processing with current state | Transient error, state is valid |
| Restart | Stop actor, create fresh instance | Corrupted state, need clean slate |
| Stop | Terminate the actor permanently | Unrecoverable error, give up |
| Escalate | Pass failure to own supervisor | Don't know how to handle |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy}import akka.actor.typed.scaladsl.Behaviorsimport scala.concurrent.duration._ // Worker that might failobject Worker { sealed trait Command case class Process(data: String) extends Command def apply(): Behavior[Command] = Behaviors.receive { (context, message) => message match { case Process(data) => if (data == "fail") throw new RuntimeException("Simulated failure!") context.log.info(s"Processed: $data") Behaviors.same } }} // Supervisor with restart strategyobject Supervisor { sealed trait Command case class ProcessWork(data: String) extends Command def apply(): Behavior[Command] = Behaviors.setup { context => // Create supervised worker with restart strategy val worker: ActorRef[Worker.Command] = context.spawn( Behaviors.supervise(Worker()) .onFailure[RuntimeException]( SupervisorStrategy.restart .withLimit(maxNrOfRetries = 3, withinTimeRange = 1.minute) ), "worker" ) Behaviors.receiveMessage { case ProcessWork(data) => worker ! Worker.Process(data) Behaviors.same } }} // Usage:// supervisor ! ProcessWork("data1") // Works// supervisor ! ProcessWork("fail") // Worker crashes, restarts automatically// supervisor ! ProcessWork("data2") // Works (on restarted worker)Let It Crash Philosophy:
Erlang/OTP pioneered the "let it crash" philosophy:
- Don't Defend Against Every Error: Complex defensive programming creates complex code with its own bugs
- Let Failures Happen: When something goes wrong, let the actor crash
- Supervisors Clean Up: Supervisors restart failed actors with fresh state
- Isolation Limits Blast Radius: Actor isolation ensures one failure doesn't corrupt the entire system
- Simplify Error Handling: Move error handling up the hierarchy to dedicated supervisors
This approach leads to simpler code and more resilient systems. The key insight is that most errors are transient—a fresh restart often resolves the problem.
Ericsson's AXD301 switch, built on Erlang/OTP supervision, achieved 99.9999999% uptime (31ms downtime per year). The supervision hierarchy allows the system to heal itself continuously—failed components restart while the rest continues operating.
Distribution and Location Transparency
The actor model naturally extends to distributed systems. Since actors communicate only through messages and share no state, an actor on another machine looks the same as a local actor.
Location Transparency:
Actor references (addresses) abstract away physical location. Sending a message to a remote actor uses the same syntax as sending to a local actor—the runtime handles serialization and network transport.
Erlang Distribution:
Erlang nodes can connect to form clusters. Any process can send messages to any process on any connected node:
1234567891011121314151617181920212223242526272829303132333435
%% Start Erlang nodes:%% Node 1: erl -name node1@192.168.1.10 -setcookie secret%% Node 2: erl -name node2@192.168.1.11 -setcookie secret %% On Node 1: Register a service-module(service).-export([start/0, handle/0]). start() -> Pid = spawn(fun handle/0), register(my_service, Pid). %% Register with name handle() -> receive {From, Message} -> io:format("Received: ~p from ~p~n", [Message, From]), From ! {reply, "Got it!"}, handle() end. %% On Node 2: Connect and send messageconnect_and_send() -> %% Connect to Node 1 net_adm:ping('node1@192.168.1.10'), %% Send message to registered process on remote node {my_service, 'node1@192.168.1.10'} ! {self(), "Hello from Node 2!"}, %% Wait for reply receive {reply, Response} -> io:format("Got response: ~p~n", [Response]) end. %% The message send syntax is IDENTICAL for local and remote!Akka Cluster:
Akka provides sophisticated clustering with automatic node discovery, sharding, and distributed data:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Akka Cluster configuration (application.conf)akka { actor { provider = cluster } remote.artery { canonical.hostname = "127.0.0.1" canonical.port = 2551 } cluster { seed-nodes = [ "akka://ClusterSystem@127.0.0.1:2551", "akka://ClusterSystem@127.0.0.1:2552" ] }} // Scala code for cluster-aware actorimport akka.cluster.typed.{Cluster, Subscribe}import akka.cluster.ClusterEvent._ object ClusterListener { sealed trait Event case class MemberEventWrapper(event: MemberEvent) extends Event def apply(): Behavior[Event] = Behaviors.setup { context => // Subscribe to cluster membership events val cluster = Cluster(context.system) cluster.subscriptions ! Subscribe( context.messageAdapter(MemberEventWrapper), classOf[MemberEvent] ) Behaviors.receiveMessage { case MemberEventWrapper(MemberUp(member)) => context.log.info(s"Member up: ${member.address}") Behaviors.same case MemberEventWrapper(MemberRemoved(member, _)) => context.log.info(s"Member removed: ${member.address}") Behaviors.same case _ => Behaviors.same } }}While the actor model simplifies distribution, it doesn't eliminate distributed systems challenges. Network partitions, message loss, node failures, and ordering issues remain. The Eight Fallacies of Distributed Computing still apply. Actor systems provide tools to handle these, but you must design for failure.
Actor Patterns and Best Practices
Effective actor systems follow common patterns and practices.
Router/Load Balancer:
Distribute work across multiple worker actors for parallelism:
1234567891011121314151617181920212223242526272829
import akka.actor.typed.{ActorRef, Behavior}import akka.actor.typed.scaladsl.{Behaviors, Routers} object Worker { case class Task(data: String) def apply(): Behavior[Task] = Behaviors.receive { (ctx, task) => ctx.log.info(s"Processing: ${task.data}") Behaviors.same }} object WorkerPool { case class ProcessTask(data: String) def apply(): Behavior[ProcessTask] = Behaviors.setup { context => // Create a pool of 5 workers with round-robin routing val pool: ActorRef[Worker.Task] = context.spawn( Routers.pool(5)(Worker()), "worker-pool" ) Behaviors.receiveMessage { case ProcessTask(data) => pool ! Worker.Task(data) // Routed to next available worker Behaviors.same } }}- •Keep Actors Small: Each actor should do one thing. Complex behavior emerges from simple actor composition.
- •Avoid Blocking: Never block inside an actor (no Thread.sleep, no blocking I/O). Use async patterns or dedicated blocking dispatchers.
- •Immutable Messages: Messages should be immutable to avoid shared mutable state. In Scala, use case classes; in Java, use records.
- •Prefer Tell Over Ask: Fire-and-forget (!) is more natural than request-response (?). Ask creates hidden complexity.
- •Design for Failure: Assume actors will crash. Use supervision hierarchies. Don't put critical state only in actor memory.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.