Handing another thread a reference to an object you just finished building is a classic hazard in shared-memory concurrency. Why is that hazard a property of the shared-memory model rather than of the object, and how do message-passing designs — actor mailboxes, CSP-style channels, ownership transfer — remove it by construction?
answer
- Two unordered paths: the pointer and the fields
- The transfer IS the edge
- Copy / move / convention-only spectrum
- Retained alias re-opens the race
- Shared memory = opt-in edge on every path
basics
~20 sShared memory lets a reference reach another thread with no ordering edge, so its fields can look unwritten. Message passing makes the send/receive pair itself the edge and the only route to the value; copy or move semantics also remove aliasing.
solid answer
~50 sThe hazard belongs to the model, not the object. In shared memory every thread can name every location, so the reference and the fields it points at are two independent memory paths; assigning the reference carries no ordering, and you must supply an edge by hand on **every** path that leaks the object. Message passing narrows reachability to one primitive that already carries the edge: a channel send or mailbox delivery is specified as a release-acquire pair, so everything written before the send is visible after the receive, and the receiver has no other route to the value. **The transfer is the edge.** The strongest variants also remove aliasing — Erlang copies messages, Rust moves ownership, Pony uses reference capabilities — so the sender cannot race with the receiver afterwards. Convention-only channels (Go, most JVM actor libraries) still let you send a mutable pointer and keep it; do that and you are back in shared memory.
code
text · 6 linesThread A Thread B
obj.x = 42 (1) r = shared (3)
shared = obj (2) use r.x (4)
No edge links (2) and (3).
B may observe (2) without (1) -> r.x reads its default, not 42.go deeper
Recall the core contrast: with shared memory another thread can reach the object through a plain reference with nothing ordering the constructor's writes first; with a channel, the only way to get the value is to receive it, and receiving is itself the synchronization.
Explain the mechanics — the reference store and the field stores are separate operations that can be observed out of order, while send/receive is defined as a release-acquire pair. Mention that some runtimes copy the message and some move ownership.
Frame it as opt-in-per-path versus one-safe-path-only, name the enforcement spectrum (copy / move / convention), and call out the leak: convention-only channels still let a mutable pointer escape. Then describe how to reproduce the guarantee in shared-memory code via confinement plus a single handoff queue.
Treat it as an architectural isolation call: choosing a model decides whether publication safety is a review obligation or a structural invariant, and that choice pulls in copy cost, payload size limits, distribution transparency, failure isolation, and mailbox/backpressure design. Say where you would enforce ownership at the type or module boundary versus where you would accept convention plus tooling such as race detectors.
## The premise, in one clause A reference is published safely only when an ordering edge links the writes that built the object to the read that finds it — that is the definition, and the interesting question is *which concurrency models make you supply that edge by hand.* ## Why the hazard belongs to the model, not the object A data race needs three ingredients: two threads touching the same location, at least one of them writing, and no ordering edge between them. Shared memory satisfies the first ingredient **by default** — every thread can name every address in the process — so the model's baseline is *racy unless you add an edge*. Concretely, publishing an object involves two independent groups of memory operations: the stores that fill the object's fields, and the single store that makes the reference reachable. Nothing about writing a pointer into a global, a field, or an array slot orders it after the constructor's stores. Compilers reorder, CPUs reorder, store buffers and cache-coherence traffic let another core observe the two groups in either order. A reader can therefore hold a perfectly non-null reference to an object whose fields still read as defaults. Notice what is *not* wrong: the object may be flawless — no mutable state, correct invariants, no shared counters. The defect is in the reachability mechanism. That is why "make the class thread-safe" is the wrong diagnosis, and why the same object is unbreakable in a model that hands it over differently. ## What a channel send or mailbox delivery actually provides Three things arrive bundled: 1. **A specified ordering edge.** Send is a release; the matching receive is an acquire. Everything the sender wrote before the send is visible to the receiver after the receive. This is part of the model's contract — it holds whether the queue is implemented with a mutex, a lock-free ring, or a kernel pipe. 2. **A single route in.** The receiver's only way to reach the payload is by taking it out of the channel. There is no second, unsynchronized path — no global, no field the sender also writes. In shared memory the discipline is opt-in *per path* and there may be a dozen paths; here there is one, and it is the safe one. 3. **An aliasing discipline.** The sender is supposed to stop touching the value after the send. Systems differ in how hard they enforce that (next section). The slogan: **the transfer is the edge**, so you cannot obtain the value without having traversed it. Safety stops being a review item and becomes a consequence of the only available operation. ## The enforcement spectrum - **Copy-on-send** — Erlang/Elixir processes, browser workers with structured clone, most cross-process queues. Aliasing is physically impossible because the receiver gets a distinct copy. Cost is O(size) per message; systems mitigate with off-heap refcounted blobs for large payloads. Bonus: per-process GC, isolated crash/restart, transparent distribution. - **Move / linear ownership** — Rust channels (values are moved; `Send`/`Sync` are compiler-checked), Pony's `iso` reference capabilities. Zero copy, and the type system makes "keep using it after sending" a compile error. - **Convention only** — Go channels, most actor libraries on the JVM or CLR. The channel still supplies the ordering edge, but nothing stops you from putting a mutable pointer in the message and retaining it. The moment you do, you have shared memory with none of the discipline — which is exactly why such ecosystems ship race detectors. ## What message passing does not fix - **Escaping aliases in the payload.** Send a mutable object and keep mutating it, and only the writes made *before* the send are ordered; later ones race. - **Side channels.** Actors that also read and write a shared cache or global are racing there, mailbox or not. - **Liveness.** Deadlock, livelock, starvation and mailbox growth are orthogonal; publication safety says nothing about them, and unbounded queues trade a race for unbounded memory. - **Global ordering.** Mailboxes give ordering per channel/sender pair, not a total order across the system. ## Getting the same guarantee inside shared-memory code If you cannot adopt a message-passing runtime, you re-create its properties deliberately: - **One handoff path.** Publish only through a concurrent queue or blocking handoff whose enqueue/dequeue pair supplies the edge — that is literally re-implementing the channel. - **Confinement before handoff.** Build the object thread-locally; never let a reference escape mid-construction (including from the constructor itself). - **Retain no alias.** Drop your reference after handing it over; if you must keep one, the payload should be deeply immutable. - **Immutability.** With no post-construction writes there is nothing left to order. - **Explicit edges where a plain field is unavoidable.** A release-store paired with an acquire-load, or a lock held on both the writing and the reading side. ## How to frame it in an interview Say the hazard is structural: shared memory's default reachability is unsynchronized and discipline is opt-in on every path; message passing narrows reachability to a single primitive that already carries the edge, and the strongest variants also delete aliasing so the sender cannot race afterwards. Then name the leak — convention-only channels still let a mutable pointer through — and show you know the fix is ownership discipline, not a faster queue.
- Go's proverb says "do not communicate by sharing memory; share memory by communicating." What does a Go channel actually guarantee, and what does it not?A send on a channel happens-before the corresponding receive completes, so every write the sender made before the send is visible to the receiver afterwards — the ordering edge is real and free. What it does not do is stop you from sending a pointer and continuing to mutate the pointee, or from touching shared globals outside the channel. The discipline is convention, which is precisely why Go ships a race detector.
- Erlang copies every message between processes. What does that buy beyond publication safety, and what does it cost?Copying makes aliasing physically impossible, so a process's heap is private: it can be garbage-collected independently, it can crash and be restarted without corrupting anyone, and the same send works over a network link unchanged. The cost is an O(size) copy per message, mitigated for large binaries by refcounted off-heap storage, and it makes very large payloads a design concern rather than a free pointer pass.
- You are stuck in a shared-memory codebase. How do you get the same by-construction guarantee without adopting an actor framework?Confine construction to one thread, then hand the object over through a single concurrent queue or blocking handoff whose enqueue/dequeue pair supplies the ordering edge, and retain no alias afterwards. Prefer deeply immutable payloads so there are no post-construction writes left to order. That is re-implementing the channel — the difference is that the invariant is enforced by code review rather than by a type system, so it must be a stated rule, not folklore.
Shared memory is pinning a house's address to a public board while builders are still inside — anyone can walk in early. A channel hand-off is transferring the deed through a notary who will not process it until construction is signed off, and once it is processed you no longer hold a key.
saying these in an interview costs you the question
- Claiming actors or channels are immune to visibility bugs "so you can send anything" — a retained mutable alias re-creates an ordinary data race.
- Diagnosing the problem as "the object isn't thread-safe" — the object can be flawless; the defect is the unsynchronized reachability path.
- Asserting the edge comes from a mutex inside the queue — the guarantee is specified by the model and holds for lock-free channels too.
- Thinking copying is the only reason message passing is safe, and missing move/linear-ownership systems that are zero-copy and still safe.
- Assuming the constructor finishing in program order means other threads see it that way — source sequencing implies nothing about cross-thread ordering.