The question this answers
Can we make the synchronization unnecessary by moving the data instead of protecting it — and what does that trade cost?
A trading dashboard where five UI components and one websocket reader all need the current order book. Option A: one shared book behind a read/write lock. Option B: the reader owns the book and sends immutable snapshots to each component.
In the shared-state design: the order book itself, mutated by the reader and read by five consumers. In the message-passing design: nothing. The book has exactly one owner and the messages are values that no one mutates after sending.
A consumer never observes the order book in a partially applied state — bids updated, asks not yet — and no two parties mutate the book at the same time.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The trade, stated plainly
Shared-state concurrency says: many parties may touch this data, so we will define a protocol — a lock, a set of atomics, a memory-ordering discipline — such that every interleaving preserves the invariant. That works, and Mutexes: What They Protect and What They Do Not, Lock Scope: What You Hold It Across and Finding the Critical Section are how you do it well. But it scales badly in a specific way: every new piece of code that touches the data must obey the protocol, and the compiler will not remind them. The invariant is preserved by discipline distributed across the codebase.
Message passing says: only one party may touch this data at a time, and we transfer *ownership* rather than granting access. There is no protocol to obey because there is nothing to obey it about. A component that received a snapshot cannot possibly observe a torn update, because the snapshot was complete when it was made and nobody is going to modify it afterwards.
The name for what makes this safe is not "messages" — it is the ownership rule underneath. Send a message that is a reference to a mutable object, and keep using that object after sending, and you have shared mutable state again with an extra layer of indirection making it harder to see. This is the single most common way message passing is adopted and gets none of the benefit (Copy or Share?, Immutability as a Concurrency Strategy).
1book = OrderBook() # mutable, owned by nobody in particular2 3def on_tick(update):4 book.apply(update) # mutating in place5 for q in subscriber_queues:6 q.put(book) # "sending" a reference to the live object7 8# Component thread:9def render(q):10 b = q.get()11 top = b.bids[0] # b is the SAME object on_tick is still mutating12 depth = len(b.asks) # these two reads may straddle an apply()1book = OrderBook() # owned exclusively by the reader task2 3def on_tick(update):4 book.apply(update) # only this task ever mutates it5 snap = book.snapshot() # an immutable value, complete6 for q in subscriber_queues:7 q.put(snap) # ownership of a value, not access8 9# Component thread:10def render(q):11 snap = q.get()12 top = snap.bids[0]13 depth = len(snap.asks) # both reads see one consistent instantThe difference is not the queue — both versions have one. It is whether the thing sent can still be mutated by the sender. Sending a reference to live mutable state reintroduces every interleaving the queue was supposed to eliminate, and hides it behind a construct that *looks* like message passing.
What you actually give up
The honest costs are three. First, copying. A snapshot of a 50 000-level order book at 1 000 ticks per second is a lot of allocation, and in a garbage-collected runtime that allocation becomes pause time. Persistent data structures help enormously here — structural sharing means a snapshot copies the changed path and shares the rest — but "just copy it" is not free and pretending otherwise is how message passing gets a reputation for being slow (Copy-on-Write as a Concurrency Strategy).
Second, ordering. With shared state under a lock there is one book and one truth. With messages there are six copies at six different points in the update stream, and asking "what is the current price?" no longer has a single answer. Usually that is fine — a UI component rendering a 40 ms stale book is correct. Sometimes it is a bug: two components that must agree with each other now need their own coordination, and you have reinvented consistency (Ordering Guarantees: Four Levels, Four Prices, Determinism: Same Input, Same Output?).
Third, latency and indirection. A locked read is a few hundred nanoseconds. A message is an enqueue, a scheduler wake, a dequeue, and possibly a copy — microseconds at best. For a hot path called a million times a second that is decisive; for a UI update at 60 Hz it is invisible. The gain, in exchange, is that the entire class of bugs in Reasoning About Races: A Method, Not an Instinct and Data Race Is Not Race Condition cannot occur between these components, and that new code cannot break the invariant by forgetting a lock, because there is no lock to forget.
| Dimension | Shared state + lock | Message passing |
|---|---|---|
| What must be correct | Every call site obeys the locking protocol | The sender does not retain a mutable reference |
| Enforced by | Code review and convention | The type system, if the language has ownership or immutability |
| Cost per operation | Lock acquire/release, hundreds of ns uncontended | Enqueue, wake, dequeue, plus copy — microseconds |
| Cost under contention | Rises sharply; convoys, cache-line ping-pong | Roughly flat; the queue lock is held briefly |
| Failure mode | Race condition, deadlock, torn read | Staleness, unbounded queue growth, memory from copies |
| Consistency across readers | One truth, if they all lock | Each reader has its own snapshot at its own instant |
| Adding a sixth reader | More contention on the same lock | One more queue and one more copy per tick |
| Debuggability | Bug appears as wrong data with no stack trace | Bug appears as a stalled or growing queue, which is visible |
Where the boundary goes
Message passing is not an all-or-nothing architecture choice. The productive version is: draw the boundary around the state that is genuinely contended and genuinely invariant-bearing, give it one owner, and make everything crossing that boundary a message. Inside the owner, use ordinary sequential code with no synchronization at all — that is the real prize, because sequential code is code you can reason about.
The order book gets an owner. The five UI components each get a queue. Inside the reader task there are no locks, no atomics and no memory-ordering questions, because it is single-threaded by construction. This is the same structural idea as the The Actor Model, and as the single-writer principle behind many high-throughput systems.
Where it stops working: when the "message" has to travel both ways and the sender needs the answer. A request/response over a channel is a synchronous call with more machinery, worse error propagation and a new deadlock shape — two owners each blocked waiting for a reply from the other (The Four Conditions). If most of your messages are requests expecting replies, the boundary is in the wrong place.
Key points
- The safety comes from the ownership rule, not from the queue: exactly one party may mutate a given piece of state at a time.
- Sending a reference to state the sender keeps mutating is shared mutable state wearing a queue as a disguise, and it gets none of the benefit.
- You pay in copying, in latency per operation, and in giving up a single global "current value" across readers.
- You gain sequential, lock-free code inside each owner — the entire race and deadlock class disappears between components, not just becomes manageable.
- Failures change character from "wrong data, no stack trace" to "a queue is growing", which is a failure you can see on a dashboard.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Identify the state with contention and an invariant; give it exactly one owning task or thread.
- • Every other party gets a queue or channel into that owner instead of a reference to the state.
- • The owner processes messages one at a time, sequentially, so its internal code needs no synchronization whatsoever.
- • Outbound data is a value: an immutable snapshot, a copy, or a structurally shared persistent structure — never a live reference.
- • Consumers own what they received. They may hold it, index it and read it repeatedly with no locking, because nothing else can change it.
- • Shared reference "message": reader applies half an update (bids written, asks not yet); component reads bids[0] and len(asks) — a torn read of a logically atomic update, and no lock was involved because the code looked like message passing.
- • Value message: reader applies the whole update, then snapshots, then sends. The component reads bids and asks from an instant that definitively existed. No interleaving can produce a torn view.
- • Staleness schedule: owner sends snapshot v41 to the ticker and v41 to the risk panel; the risk panel is busy and processes v39 first. The two components disagree for 20 ms. That is not a bug unless something requires them to agree — but if something does, message passing did not remove the coordination problem, it moved it.
- • The reply deadlock: owner A sends a request to owner B and blocks for the reply; B sends a request to A and blocks. Both mailboxes have work and neither will process it — request/response over channels reconstructs circular wait (Deadlock).
- • Backpressure schedule: the risk panel consumes at 10 Hz while the owner sends at 1 000 Hz. With a bounded queue the owner blocks or drops; with an unbounded one, memory grows until the process dies (Bounded vs Unbounded Queues).
- • Message passing guarantees that no two parties mutate the same state concurrently, provided the ownership rule is actually upheld.
- • It guarantees each consumer sees a self-consistent view — a snapshot corresponds to a real instant of the source state.
- • It does NOT guarantee freshness. Every consumer is behind by at least one hop, and by however long its queue is.
- • It does NOT guarantee that consumers agree with each other. There is no global "now" across independently draining queues.
- • It does NOT eliminate deadlock. Circular waits reappear as soon as messages expect replies, and they are harder to see because there are no locks to inspect.
- • It does NOT bound memory. Copies and queues both consume it, and both are unbounded unless you bound them.
- • Contention moves from the data to the queues. The owner's inbound queue is a serialisation point, and if every party sends to one owner, that owner is the bottleneck (What Contention Actually Costs).
- • The owner is single-threaded by design, so it is a hard throughput ceiling — one core's worth of work, no matter how many cores exist.
- • Copying costs allocator time and, in managed runtimes, collector time; at high rates that becomes a pause problem rather than a lock problem (Garbage Collection: Pause, Throughput, Footprint — Pick Two in Performance).
- • Fan-out multiplies: N consumers means N enqueues and N snapshots per update, so the owner's per-tick cost grows linearly in subscribers.
- • Accidental sharing: a message that carries a mutable reference, restoring every race the design was meant to remove.
- • Staleness bugs: acting on a snapshot that no longer reflects reality, where the shared-state version would have read the current value.
- • Cross-consumer inconsistency: two components rendering different instants and a user seeing them disagree.
- • Deadlock via request/response cycles between owners.
- • Unbounded queue growth when a consumer is slower than the producer.
- • Memory exhaustion from copies when the state is large and the update rate is high.
- • When the state has a clear owner and the readers only need to observe it — the classic single-writer, many-reader shape.
- • When the invariant spans several fields, so "one lock per field" is wrong and "one big lock" is contended; ownership makes the whole update indivisible for free.
- • When the codebase is large enough that "everyone remembers to lock" is not a plan — the compiler or the structure enforces it instead.
- • When staleness is acceptable, which for UI, dashboards, caches and most read paths it is.
- • When readers must see the current value, not a recent one — an accounting balance check cannot act on a 40 ms old snapshot.
- • When the state is large and changes often, so copying dominates the actual work.
- • When the access pattern is mostly reads with rare writes: a read/write lock is cheaper and simpler, and the contention you were avoiding was never there (Read/Write Locks, Honestly).
- • When most interactions are request/response, because then you have a synchronous API implemented with asynchronous machinery, and worse stack traces.
- • Queue depth and age per consumer — the direct measure of how stale each consumer's view is.
- • Allocation rate and collector pause time before and after adopting snapshots; the copying cost shows up there, not in CPU profiles of your own code (Allocation Rate Is a Cost Even Without a Leak in Performance).
- • Owner task utilisation. A single-owner design fails by saturating one core, and the signal is the owner's queue growing while every other core is idle.
- • Per-message size, measured rather than estimated — the number that decides whether copying is viable.
- • Cross-consumer divergence, if consistency matters: instrument the version each consumer last processed and alert on the spread.
- • You need a snapshot or persistent-structure story for the state, which is real design work if the structure is non-trivial.
- • Every message type is a small piece of API surface between components, with its own versioning problem as the system evolves.
- • Error handling has no natural caller: a message that fails to process has nowhere to throw, so failures need an explicit destination.
- • The ownership rule has to be enforced somehow. In a language without ownership types that is a convention, and conventions decay — so the discipline you removed from locking reappears, smaller, around what may be sent.
- • A read/write lock over the shared structure, when reads dominate and readers need currency. Simpler, cheaper, and correct if the protocol is followed (Read/Write Locks, Honestly).
- • An immutable structure with an atomic pointer swap: readers take the current version with one atomic load and no queue at all — most of the benefit, none of the fan-out cost (Immutability as a Concurrency Strategy, Safe Publication: Handing Over a Finished Object).
- • Copy-on-write, when writes are rare and readers are many, which is message passing's benefit with the copy paid only on change (Copy-on-Write as a Concurrency Strategy).
- • Do nothing: if the state is only ever touched by one thread already, the safest concurrency strategy is the absence of concurrency (Concurrency Is Always Bought With Complexity).
What people believe, and what is true
Message passing is safe because messages are queued.
It is safe because exactly one party owns the data. A queue carrying mutable references gives you the queue and none of the safety.
Message passing avoids deadlock.
It avoids lock-ordering deadlock. It introduces reply-cycle deadlock, which is harder to detect because there is no lock table to dump.
Copying is too expensive, so message passing does not scale.
Naive deep copying is expensive. Structural sharing makes a snapshot proportional to what changed, which is usually tiny — and it is what makes the pattern practical at all.