In 1973, Carl Hewitt published a paper describing something he called the Actor model — isolated computational units that communicated exclusively by message passing, with no shared memory, no locks, no races. At the time, “concurrent programming” meant carefully interleaving OS threads and hoping you got the locking right.
Fifty years later, concurrency is still where language design gets genuinely hard. Hewitt’s 1973 paper is still influencing the languages being built today. Its choices show up in Go’s goroutines, Erlang’s fault-tolerant processes, Swift’s actor keyword, and Rust’s borrow checker. Every major language has had to answer the same question. They have not agreed on an answer.
This is the 50-year history of that disagreement.
The Original Sin: Shared Mutable State
Before any of the elegant models arrived, there was the OS thread — and it remains the conceptual default even today.
An OS thread is a scheduled execution context with its own stack. Each Java thread starts with roughly 1 MB of stack memory. When multiple threads share data, they protect it with locks: mutexes, semaphores, read-write locks. Thread creation is expensive, context switching carries overhead, and the upper bound on useful threads per machine is in the thousands.
But the deeper problem is correctness, not cost. Acquire locks in the wrong order: deadlock. Forget to acquire a lock: race condition. In large systems, these bugs are genuinely hard to reason about — the state that caused the crash might have been written by a thread that finished running milliseconds earlier, on a different core, under conditions no test ever reproduced.
This is the problem every concurrency model in this article was built to escape.
Carl Hewitt’s Answer, 1973: The Actor Model
Hewitt’s paper described actors — computational entities with three properties: private state, a mailbox for incoming messages, and behavior that could spawn new actors, send messages, and decide how to handle the next message.
The critical insight was what actors could not do: they could not directly access each other’s state. Communication happened exclusively through messages. If Actor A wanted to know Actor B’s balance, it sent a message asking, and Actor B decided what to reply. No locks required. No race conditions possible — each actor processes one message at a time, sequentially.
This was a beautiful idea that lived mostly in academic papers for over a decade.
Erlang, 1986: The Actor Model Escapes the Lab
Ericsson was building telephone switching systems in the mid-1980s. The requirements were extreme: systems that could not go down, ever, because telephone exchanges were infrastructure. The engineers — Joe Armstrong, Robert Virding, and Mike Williams — needed a language for building distributed, fault-tolerant systems that could be upgraded without stopping.
Erlang implemented the Actor model as a language primitive. Erlang “processes” are not OS threads — they are extremely lightweight, starting with around 2 KB of memory. A single machine can run millions of them simultaneously. Each process has its own heap and garbage collector; a crash in one process cannot corrupt another’s memory. If an Erlang process fails, a supervisor process notices and restarts it. The system keeps running.
The results were remarkable. Ericsson’s AXD301 ATM switch, built with Erlang, reportedly achieved 99.9999999% availability — nine nines. That is less than 31 milliseconds of unplanned downtime per year. WhatsApp, which scaled to 900 million users with a small engineering team, ran on Erlang/BEAM infrastructure. Discord’s backend originally ran on Elixir, which targets the same BEAM VM.
Erlang’s actor model has a specific property worth naming: actors have identities. You send a message to a specific process, identified by its process ID. The actor on the receiving end knows its own identity. This matters for distributed systems — when processes can live on different machines, you need a way to address them by name.
Tony Hoare’s Answer, 1978: CSP
In 1978, British computer scientist Tony Hoare published a paper titled “Communicating Sequential Processes.” Where Hewitt’s actors were asynchronous — you send a message and move on, it’ll get there when it gets there — Hoare’s CSP was synchronous by default.
In CSP, independent processes communicate through channels. A write to a channel blocks until a reader is ready. A read from a channel blocks until a writer has something to send. The two processes rendezvous at the channel, exchange data, and continue. There are no mailboxes accumulating messages. There is no identity attached to either end of the channel — the channel is anonymous. Process A doesn’t know that Process B is reading from the same channel; it just knows the channel received its value.
This difference from Erlang’s actor model is not a minor implementation detail. It fundamentally shapes how you design distributed systems. When channels are anonymous, you decouple producers from consumers in a way you cannot do when actors have identities. A goroutine reading from a channel doesn’t know or care who wrote to it. This enables elegant pipeline patterns. It also means that if you need to route messages to a specific recipient — a particular user session, a particular service instance — you have to build that routing yourself on top of the channel abstraction.
Go, 2009: CSP Goes Mainstream
Go’s goroutines and channels are the most widely adopted implementation of CSP in computing history.
When Rob Pike, Robert Griesemer, and Ken Thompson designed Go at Google in 2007, they were explicitly reaching back to Hoare’s 1978 paper. The Go FAQ describes the idea as “multiplexing independently executing functions — coroutines — onto a set of threads.” They start at 2 KB of stack — compared to roughly 1 MB for a Java or OS thread — and the runtime grows and shrinks their stacks dynamically. Go’s scheduler multiplexes goroutines onto OS threads automatically.
The practical consequence: you can run tens or hundreds of thousands of goroutines on a single machine. You write sequential-looking code, spawn it as a goroutine with the go keyword, and communicate through typed channels.
| |
The channel is the synchronization primitive. No mutexes. No locks. Go’s standard library does include sync.Mutex for cases where shared state genuinely makes sense — Go is pragmatic, not ideological — but the preferred idiom is “don’t communicate by sharing memory; share memory by communicating.”
Go reached 1.0 in 2012 and became the dominant language for cloud infrastructure. Docker, Kubernetes, Terraform, and most of the tooling that runs the modern internet are written in Go. The goroutine model is a significant reason why.
The Erlang/Go Fork: A Fundamental Design Difference
The contrast between Erlang’s actor model and Go’s CSP represents a genuine philosophical split that still shapes language design today.
In Erlang, a process has an identity — its PID. You send a message to that PID. This maps naturally to distributed systems where you need to address specific services or route messages to specific users.
In Go, a channel has no identity. Any goroutine with a reference to it can read or write. The goroutines at either end don’t know each other exist — enabling clean pipeline architectures and work queues, but requiring additional structure when you need point-to-point addressing.
Neither is categorically better. Erlang’s model fits systems where fault isolation, process supervision, and distributed messaging dominate. Go’s model fits systems built as pipelines where decoupling producers from consumers is the primary concern.
Akka and Kotlin: Actors on the JVM
The JVM had no built-in support for the actor model. Jonas Bonér created Akka as a Scala and Java library to bring Erlang-style actors to the JVM ecosystem — private state, message mailboxes, supervisors that restart failed actors. LinkedIn, PayPal, and others have run Akka-based systems at scale.
But Akka has always fought an architectural tension: it is a library on top of a runtime not designed for it. The JVM garbage collector runs globally. When GC pauses occur, they affect all actors on that JVM — fundamentally different from Erlang, where each process has its own heap and GC. Akka can approximate the actor model’s benefits but cannot replicate Erlang’s isolation guarantees, which exist by language design rather than library convention.
Kotlin Coroutines (stable since Kotlin 1.3 in 2018) offered a different JVM path — coroutines lighter than threads, with cancellation as a first-class concept and structured concurrency: child coroutines are scoped to their parent, preventing them from outliving it and leaking resources. Structured concurrency addresses a real operational problem in large async codebases where orphaned tasks are a persistent source of bugs.
Java Virtual Threads, 2023: Thirty Years of Thread Limitations, Finally Addressed
Java’s concurrency story until recently was OS threads plus elaborate workarounds: ExecutorService, ForkJoinPool, CompletableFuture chains — all managing the fundamental cost of OS threads so you didn’t have to create unbounded numbers of them.
Project Loom started in 2017 with a simple goal: make Java threads cheap enough to use one per request. Java 21 (September 2023) made virtual threads generally available. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier OS thread cheaply. The OS thread is freed to run other virtual threads. The result: synchronous-looking code with async-style scalability. Millions of virtual threads can coexist on a handful of OS threads.
This is conceptually similar to goroutines — lightweight, runtime-managed concurrency. The difference is that goroutines shipped with Go 1.0 in 2012. Java arrived at the same destination eleven years later, carrying the weight of a three-decade-old threading model the whole way.
Rust’s Radical Answer, 2015: Make Data Races Uncompilable
Every model discussed so far tries to answer “how do we make concurrent programming easier?” Rust asked a different question: “what if certain concurrency bugs were impossible to compile?”
Rust has no built-in async runtime. async/await was stabilized in Rust 1.39 (November 2019), but the runtime — the piece that schedules and polls futures — is third-party. Tokio is the dominant choice.
This apparent gap is intentional. Rust’s ownership and borrowing system provides the real guarantee: in safe Rust, data races are impossible to compile. Not warned against. Not detected at runtime. Impossible. The borrow checker proves at compile time that no two threads hold a mutable reference to the same data simultaneously.
| |
This is a fundamentally different kind of answer. Erlang says “share nothing, message-pass everything.” Go says “use channels, not shared memory.” Rust says “if you’re going to share memory, the compiler will verify you’re doing it correctly.” The mechanism is static analysis at compile time, not a runtime model — and it is model-agnostic. Whether you use channels, shared state with Arc<Mutex<T>>, or async futures with Tokio, the ownership guarantees hold regardless.
JavaScript’s Single-Threaded Event Loop
JavaScript avoided traditional concurrency problems by refusing to have multiple threads in the first place. The browser’s JavaScript engine runs on a single thread. Race conditions on shared state are impossible because there is no shared state across concurrent execution contexts.
The tradeoff is that everything is asynchronous. Node.js’s libuv library handles I/O at the OS level — while JavaScript waits for a network response, the event loop runs other callbacks. async/await is syntactic sugar over Promises, which are syntactic sugar over callbacks. One task at a time.
For CPU-bound parallelism, JavaScript provides Web Workers: separate threads that communicate with the main thread exclusively by message passing, no shared memory by default. This is effectively the actor model as an escape hatch from the single-threaded default. The single-threaded model has proven remarkably effective for I/O-heavy server workloads — which describes most web backends — because the bottleneck is I/O latency, not CPU parallelism.
Swift Actors, 2021: A Language Keyword for an Old Idea
Swift 5.5, announced at WWDC 2021, introduced the actor keyword — making Swift the first mainstream language to give the actor model a language-level keyword rather than implementing it as a library.
An actor in Swift is a reference type (like a class) with one critical difference: the language guarantees that its mutable state is only accessed from one concurrent context at a time. You cannot call a method on an actor from outside without awaiting it. The compiler enforces this. There is no runtime race condition possible on actor-isolated state because the language makes it syntactically impossible to access it without going through the actor’s serialized execution context.
| |
This is the actor model as Hewitt described it in 1973 — private state, serialized access — but as a first-class language feature with compiler enforcement rather than a library convention you can accidentally violate. Swift’s actor keyword is arguably the cleanest language-level implementation of the original idea outside of Erlang itself.
The Comparison
After 50 years and a dozen concurrency models, the landscape looks like this:
| Model | Key Languages | Memory Per Concurrent Unit | Max Practical Concurrency | Shared State Allowed? | Key Risk |
|---|---|---|---|---|---|
| OS Threads | Java (pre-21), C, C++ | ~1 MB | Thousands | Yes | Deadlocks, race conditions |
| Actor Model | Erlang, Elixir, Swift | Hundreds of bytes | Millions | No | Mailbox overflow, message ordering |
| CSP Channels | Go | ~2 KB | Hundreds of thousands | Limited | Channel deadlocks, goroutine leaks |
| Virtual Threads | Java 21+ | ~few KB | Millions | Yes | Legacy lock-based bugs persist |
| Coroutines | Kotlin | ~few KB | Millions | Yes | Cancellation complexity |
| Async/Await | Rust, JavaScript, Swift | Varies | High | Varies | Colored function problem |
| Ownership + Channels | Rust | ~few KB | High | Compiler-verified only | Steep learning curve |
| Event Loop | JavaScript (Node.js) | N/A (single-threaded) | I/O-bound only | N/A | CPU-bound tasks block everything |
No Consensus After 50 Years
The most honest thing to say about concurrency models in 2026 is that there is still no consensus. Erlang’s 1986 actor model still runs some of the highest-availability telecom infrastructure on Earth. Go’s 2009 goroutines underpin most cloud infrastructure tooling. Java’s virtual threads arrived in 2023, finally bringing the JVM to a place Go reached fourteen years earlier. Swift’s actor keyword gave iOS developers language-enforced answer to shared state in 2021.
Each model makes different tradeoffs. The actor model maximizes fault isolation and makes shared state structurally impossible. CSP maximizes decoupling between concurrent units but requires discipline to avoid goroutine leaks. Virtual threads maximize backward compatibility — existing Java code benefits without a rewrite. Structured concurrency in Kotlin makes resource management tractable in large async codebases.
Rust’s approach is arguably the most radical departure from all prior models. It does not pick a concurrency paradigm and enforce it. It provides a compile-time proof system that rules out data races regardless of which paradigm you choose. You can write shared-memory concurrent code, CSP-style channels, or actor-like patterns — and the borrow checker will reject programs that violate memory safety in any of them. This is not “make concurrency easier.” It is “make an entire class of concurrency bugs unrepresentable in the type system.”
The argument Hewitt started in 1973 and Hoare continued in 1978 is not over. The next language that matters will probably take a position on concurrency that seems obvious in retrospect — and that most developers today assume is already solved.
Want to run concurrent code yourself? CodeArchaeology has hands-on guides for Go, Rust, Kotlin, and Swift — each with Docker examples so you can experiment without installing anything.
Comments
Loading comments...
Leave a Comment