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:
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:
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 stateErlang is the most famous practitioner of this model. A popular Java implementation is Akka.
// 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 returnHow 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:
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:
| Actor | CSP | |
|---|---|---|
| Communication | Async messages | Synchronous channel (unbuffered by default) |
| Sender | Continues after sending (non-blocking) | Blocks until receiver receives |
| Channel | Actor address (need the other's reference) | Channel as intermediary (decoupled) |
| Fault tolerance | Built-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.
// 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.
// 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.
// 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 foreverJava'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 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:
class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); }
public int get() { return count.get(); }
}Java Actor (Akka pseudocode):
// Each operation sends a message; Actor processes serially
counter.tell(new Increment(), ...);Go CSP:
func counter(ch chan int) {
count := 0
for { count += <-ch }
}
// ch <- 1 // External communication via channelClojure STM:
(def counter (ref 0))
(defn inc-counter [] (dosync (alter counter inc)))Four approaches, four philosophies:
| Locks | Actor | CSP | STM | |
|---|---|---|---|---|
| Shared state | Yes | No | No | Yes |
| Explicit locks | Yes | No | No | No |
| Single-point serial | No | Yes | Conditional | No |
| Learning curve | Low→High(advanced) | Medium | Medium | Medium |
| Deadlock risk | Yes | No | No | No (transaction retry) |
Common Pitfalls
- "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)
- "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
- "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
synchronizedandAtomicIntegerrespectively, 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.