How to Use the Actor Model

2021-02-08

The Shared Memory Model

Traditional concurrent programming adopts the shared memory model, where a portion of memory is visible to multiple threads simultaneously. Modifications to shared memory typically require mutex locks to ensure correctness under concurrent access. However, mutex locks effectively force concurrent execution to become sequential at those points, reducing CPU core utilization. According to Amdahl's Law, the higher the degree of concurrency, the more severe the impact of serialized sections on multi-core utilization.

Some might suggest using lock-free algorithms, namely the CAS (Compare-And-Swap) mechanism. CAS does offer higher efficiency than mutex locks. But it still encounters one problem: false sharing.

Modern CPUs load data into the cache hierarchy (L1, L2, etc.) in units of cache lines. On x86 architecture, a cache line is 64 bytes. If two CPU cores load the same memory segment and one core modifies it, the other core must reload that cache line, wasting CPU computation time. This isn't a rare scenario — early versions of Java's ArrayBlockingQueue, for example, suffered from false sharing, leading to poor performance in high-concurrency environments. (This has since been addressed in later Java versions via @Contended annotation and padding.)

In the shared memory model, solving false sharing requires adding redundant data (padding) to ensure that concurrent modifications don't occur within the same cache line. However, padding wastes memory, and cache line sizes vary across CPU architectures. Using padding reduces code portability — a significant concern given the rise of ARM server architectures.

The Actor Model

Concurrent programming isn't limited to one model. Beyond shared memory, there are various message-passing models.

The key idea of message-passing models is that different functional units achieve concurrency by passing messages. Golang's CSP model is one variant. The Actor model is another. Both Actor and CSP use message channels for communication, but the difference is that in the Actor model, an Actor is unaware of other Actors' message channels, whereas in CSP, the communicating units know about each other's channels.

The core idea of the Actor model is "everything is an Actor." Each functional unit is abstracted as an Actor. Every Actor can:

In implementation, each Actor has an internal message channel called a "mailbox". Incoming messages are stored in the mailbox. The built-in computation logic continuously reads messages from the mailbox and processes them asynchronously. Message passing is decoupled by mailboxes, avoiding mutual blocking. Each Actor's logic simply reads messages sequentially from its mailbox, minimizing shared memory between different Actors.

Implementations of the Actor Model

In the industry, there are many implementations of the Actor model. The most mature is arguably Erlang, a language developed by Ericsson originally for telecommunications programming. As a functional language, Erlang supports hot code swapping and dynamic updates. Actor model support is built into the language itself: "Every Process is an Actor." A Process is a lightweight user-space thread of only a few hundred bytes. In Erlang's BEAM virtual machine, Processes are fairly scheduled, and garbage collection is per-Process. This means that when some Processes are in a Stop-The-World (STW) GC phase, other Processes remain active. Therefore, Erlang is extremely well-suited for soft real-time systems.

On the JVM, Akka is the corresponding Actor model implementation. Akka is a framework written in Scala, supporting both Java and Scala. Thanks to the high-performance JVM, Akka has seen significant industrial adoption. For example, PayPal ran a Scala + Akka application on just 8 dual-core VM instances to handle billions of transactions per day.

Rust also has a high-performance Actor implementation: Actix. The Actix-based HTTP server framework, Actix Web, consistently ranks at the top of the TechEmpower framework benchmarks.

Using the Actor Model

By definition, the Actor model is a message-driven programming paradigm. As such, the resulting code differs significantly from conventional approaches.

In the Actor world, every functional unit is implemented as an Actor. The computation logic mainly involves: reading messages from the mailbox, handling different message types with different logic, and sending messages to other Actors. When necessary, an Actor can even spawn new Actors to perform specific operations.

Overall, the Actor model is extremely well-suited for building stream-based message processing systems.

Some say Actors are another form of object-oriented design. Each Actor is an object with cohesive computation logic encapsulated within it. Object inheritance can be compared to the parent-child Actor hierarchy. Polymorphism can be understood as an Actor handling different message types. Actor objects don't directly call each other on the stack; instead, they communicate asynchronously through message passing.

Based on their content, messages can be roughly divided into three categories: command messages, event messages, and document messages. They roughly correspond to: "perform some operation," "something happened," and "here is some resource content."

Depending on specific requirements, a message system architecture can include Actor objects responsible for message dispatching, splitting, aggregation, wrapping, and so on.

Robustness of the Actor Model

The power of the Actor model lies in parent-child Actor relationships. Erlang and Akka give this a more engineering-oriented term: Supervision. A parent Actor manages the lifecycle of its children: creation, startup, error handling, restart, termination, and more. From this, a powerful engineering philosophy emerged: "Let it crash."

"Let it crash" doesn't literally mean "let things crash randomly."

This pattern is built on the lightweight nature of Actor objects: in Erlang, a Process is only a few hundred bytes; in Akka, it's simply a JVM object. When an Actor fails, it errors out directly. Its parent Actor then decides its lifecycle based on the error type — restart or recreate. The "kernel-error" pattern is representative here: stateful Actors often delegate high-risk operations to child Actors. Even if a child Actor fails, the parent remains operational. Upon failure, the parent can choose alternative strategies. This effectively ensures overall system robustness.

Building on this, Ericsson's Erlang services once achieved an astonishing "nine nines" (99.9999999%) availability, with average annual downtime of only tens of milliseconds.

Without Actors, How to "Let It Crash"

In an online discussion, someone gave the example of a C++ program with a hard-to-locate memory leak. In production, the application's memory usage was monitored. When it reached a certain threshold, the application was simply restarted to avoid future allocation failures. This is an example of the same philosophy, just without an Actor monitoring system, making the overall implementation more complex.

"Let it crash" can also be compared to Kubernetes Pod lifecycle management, such as common restart-on-failure policies. But this relies on fast Pod restart times. If a Pod runs a Spring Boot service that takes 30+ seconds to start, the cost of "letting it crash" is actually quite high, since service availability isn't guaranteed during that period. This is one reason Golang is better suited for cloud-native services. GraalVM looks promising — we'll see how it'll move forward.

References