Skip to content

Metadata Card

  • Prerequisites: Vol 7 Distributed Systems Basics (distributed vs single-machine concurrency), Vol 9 Chapter 2 (threads and memory relationship)
  • Estimated time: 50 minutes
  • Core difficulty: Advanced
  • Reading mode: High focus
  • Optional skip: STM section can be skimmed if too difficult; doesn't affect subsequent content
  • Completion mark: Can state the core differences between Actor / CSP / STM models; can compare Java's lock model with Go's goroutine+channel pattern

Your Progress

You push open the third door and find the walls covered in blueprints—not circuit wiring diagrams, but design blueprints for how threads talk to each other. Every civilization pursues the same goal: multiple people working simultaneously, not fighting, not hurting each other. But their methods are vastly different: some built a message corridor, some built a shared plaza, and some invented an ATM machine.

Your Task

Concurrent programming is a problem every language must face. Deadlocks, race conditions, memory consistency issues—these aren't bugs, they're the inevitable result of shared mutable state. Different languages chose different concurrency models, each answering the same question: how do multiple execution units safely read and write data?

Chapter Layers

  • Required: Actor model, CSP model, shared memory + locks; the core differences between the three
  • Optional: STM software transactional memory
  • Advanced: Simulating Actor/CSP patterns in Java

Breaking Ground · Tracing the Origin

The ruins' walls are etched with the simplest concurrent code—a global counter. It looks harmless, but when you have ten threads calling it simultaneously, the wall shows three different final numbers:

java
private static int counter = 0;

// Multiple threads calling this simultaneously
public void increment() {
    counter++;  // Not an atomic operation!
}

counter++ is three steps in bytecode: read counter, add 1, write back counter. Two threads both read 0, each adds 1 and writes back 1—one increment is lost.

You add synchronized:

java
public synchronized void increment() {
    counter++;
}

Problem solved. But now 1000 threads queue up for one lock—this is the shared memory + lock model: data is shared, access is protected by locks.

The lock model is logically clear. But its problems are also obvious: what about deadlocks? Coarse locks hurt performance; fine-grained locks are easy to get wrong. You've tried refining the locks, only to sink into a quagmire of "lock or unlock." Is there a solution that doesn't need explicit locks?


Actor Model: Nothing Shared, Only Messages

The core principle of the Actor model: Don't share anything. Each Actor is an independent unit of computation with its own private state. Actors communicate only through asynchronous messages; they don't share any memory.

Actor A ----(message)----> Actor B
    |                         |
  own state                own state

Erlang is the most famous practitioner of this model. A popular Java implementation is Akka.

java
// Akka Actor example
public class CounterActor extends AbstractActor {
    private int count = 0;  // Private state, not shared

    @Override
    public Receive createReceive() {
        return receiveBuilder()
            .match(Increment.class, msg -> {
                count++;  // Only yourself can modify your own state
            })
            .match(GetCount.class, msg -> {
                getSender().tell(count, getSelf());
            })
            .build();
    }
}

// Usage
ActorRef counter = system.actorOf(Props.create(CounterActor.class));
counter.tell(new Increment(), ActorRef.noSender());
counter.tell(new GetCount(), actorRef);  // Async return

How does it work without locks? Because each piece of code only operates on its own private state. If it needs another Actor's data, it sends a message and waits for a reply—like two countries that can't directly change each other's laws, only send diplomatic letters.

Cost: Inside a single Actor, processing is serial. If an Actor needs to frequently communicate with 100 other Actors, messages become the bottleneck. Additionally, message serialization/deserialization has performance overhead.

Akka Java vs native Erlang: Erlang's Actors are built into the language, scheduled by the BEAM VM. Akka is a library on the JVM—Actors still run on top of JVM threads. The philosophy is identical, but Erlang's Actors are more "authentic"—fault tolerance, hot code reload, process monitoring are all at the VM level.


CSP Model: Don't Share Data, Communicate Data

CSP (Communicating Sequential Processes) is often confused with Actor—they both advocate "don't communicate by sharing memory; share memory by communicating." But the difference is: CSP uses Channel as an explicit intermediary; A doesn't need to know where B is.

Go's goroutine + channel is the representative of CSP. See how a counter is implemented in Go—no locks, no shared variables, data flows through channels:

go
func counter(ch chan int) {
    count := 0
    for {
        select {
        case <-ch:  // Received increment signal
            count++
        case ch <- count:  // Someone wants the current value
            // Sending it
        }
    }
}

func main() {
    ch := make(chan int)
    go counter(ch)   // Start a goroutine

    ch <- 1          // Send increment signal
    ch <- 1
    result := <-ch   // Get current count
}

Actor vs CSP:

ActorCSP
CommunicationAsync messagesSynchronous channel (unbuffered by default)
SenderContinues after sending (non-blocking)Blocks until receiver receives
ChannelActor address (need the other's reference)Channel as intermediary (decoupled)
Fault toleranceBuilt-in (supervision tree)Explicit handling, no built-in supervision

CSP's channel is an explicit independent object—unlike Actor where each Actor has its own mailbox. This brings one advantage: channels can be passed around like values, shared by multiple goroutines.

go
// channel passed as first-class citizen
func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2
    }
}

// Create channels and share them among goroutines
jobs := make(chan int, 100)  // Buffered channel
results := make(chan int, 100)
for w := 1; w <= 3; w++ {
    go worker(w, jobs, results)  // Multiple goroutines sharing the same channel
}

Why isn't synchronous channel a performance killer? When both sender and receiver are ready, Go's runtime hands off directly—no kernel scheduling involved. There's no absolute performance advantage or disadvantage versus Actor's async message queues; it's just a different design philosophy.


Shared Memory + Locks: The Model You're Using

Java takes the most direct route—shared memory, protecting access with synchronized, ReentrantLock, volatile, etc.

java
// Fine-grained locking
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;

public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

Advantages: Most flexible, highest performance ceiling, most intuitive (everyone changes the same variable).

Disadvantages: Easiest to trip yourself up.

java
// Classic deadlock recipe
class A {
    synchronized void methodA(B b) { b.methodX(); }
    synchronized void methodX() { }
}
class B {
    synchronized void methodB(A a) { a.methodY(); }
    synchronized void methodY() { }
}
// Thread 1: a.methodA(b)   Thread 2: b.methodB(a)
// Both waiting for the other to release the lock — deadlocked forever

Java's java.util.concurrent package provides many "already-written-lock" tools—ConcurrentHashMap, AtomicInteger, CountDownLatch—all designed to reduce your chance of directly operating locks. The language trend is to "hide the complexity of locks," not "make you a lock master."

The lock model doesn't have a "theoretically better" solution—it gives you full control and maximum room for error.


STM: Make Concurrency as Simple as Database Transactions

Software Transactional Memory (STM) brings the concept of database transactions to memory operations:

clojure
;; Clojure STM example
(def counter (ref 0))

(defn increment []
  (dosync
    (alter counter inc)))

All operations inside the dosync block are atomic and isolated. If two threads alter, one will retry.

Does it resemble a database? Almost identical—ACID (minus persistence). STM is implemented under the hood using MVCC (Multi-Version Concurrency Control), similar to the transaction engine thinking in databases.

STM is not widely adopted in mainstream languages. Java has a third-party Multiverse library, but it's rarely used in production. Clojure is the most well-known proponent of STM; it runs on the JVM.

Why isn't it popular? Two reasons: First, STM needs runtime support (transaction logs, conflict detection), adding overhead. Second, transactions inside STM also need locks (write locks), just at the transaction granularity.


Comparison Window: Four Ways to Implement a "Concurrent Counter"

Java locks:

java
class Counter {
    private AtomicInteger count = new AtomicInteger(0);
    public void increment() { count.incrementAndGet(); }
    public int get() { return count.get(); }
}

Java Actor (Akka pseudocode):

java
// Each operation sends a message; Actor processes serially
counter.tell(new Increment(), ...);

Go CSP:

go
func counter(ch chan int) {
    count := 0
    for { count += <-ch }
}
// ch <- 1  // External communication via channel

Clojure STM:

clojure
(def counter (ref 0))
(defn inc-counter [] (dosync (alter counter inc)))

Four approaches, four philosophies:

LocksActorCSPSTM
Shared stateYesNoNoYes
Explicit locksYesNoNoNo
Single-point serialNoYesConditionalNo
Learning curveLow→High(advanced)MediumMediumMedium
Deadlock riskYesNoNoNo (transaction retry)

Common Pitfalls

  1. "Actors can't deadlock" — Actors can't deadlock on locks, but can deadlock on message loops (A waits for B's reply, B waits for A's reply)
  2. "goroutine is a lightweight thread" — More accurately, it's a "user-space thread," not the same as an OS thread; 1000 goroutines are much lighter than 1000 OS threads
  3. "No sharing = high performance" — Message serialization/transmission also has cost; shared reads are faster than message passing (cache-friendly reads), but shared writes need locks

Pass Challenges

  • Warm-up: Draw a diagram showing the differences between Actor/CSP/Locks on "where data lives" and "how it's accessed"
  • Hands-on: In Java, implement a counter with synchronized and AtomicInteger respectively, run 100 threads each incrementing 10000 times, compare time
  • Observe: Run Go's race detector (go run -race), intentionally create a data race, see how the detector reports it

Traveler's Notes

Actor eliminates sharing, CSP eliminates direct references, locks eliminate uncontrollability—none of them eliminate the inherent complexity of concurrency; they just move it to a different place.

Next Stop Preview

Concurrency models handle "what to do to avoid errors," but another branch of high-level language thinking asks: "can we fundamentally avoid errors?"—Functional programming. Push open the fourth door and see how immutability and pure functions change your view of "programming" itself.

Built with VitePress | Software Systems Atlas