Immutability & Concurrency Control

Copy or Share?

Copying buys isolation and pays in memory and copy time. Sharing buys efficiency and pays in synchronization. This is a genuine decision with a crossover point, not a style preference — and the crossover moves with payload size, task count and how much of the data each task actually touches.

The question this answers

The question

Should each task get its own copy of this data, or should they all point at one?

The work

A fan-out that hands a 4 MB parsed document to eight workers, each of which extracts a different section and returns a summary.

What is shared

The parsed document. If shared, one buffer with eight readers. If copied, eight independent buffers with one reader each and nothing shared at all — which is the point.

The invariant — what must stay true under every interleaving

Every worker's summary describes the same document version, and no worker observes a byte that another worker wrote.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The two costs, side by side

Copying is not the naive option. It converts a correctness problem into a resource problem, and resource problems are visible in metrics while correctness problems are visible in postmortems. Eight copies of 4 MB is 32 MB and a few milliseconds of memcpy; eight readers of one buffer is 4 MB and an obligation on every future maintainer never to write to it.

Sharing is not the fast option either, at least not automatically. If the workers only read, sharing is genuinely free after publication. If any worker writes, you have bought a lock, and the lock costs both wait time and the cache-coherence traffic of moving that line between cores — which is frequently larger than the copy you were avoiding.

The crossover has three inputs: how big the data is, how much of it each task touches, and whether anyone writes. A task that reads 5% of a large structure argues for sharing; a task that reads all of a small structure and might mutate it argues for copying.

ApproachMemorySetup costSynchronization neededFails as
Copy per taskN x payloadN x copy or serializeNone — nothing is sharedMemory ceiling; copy time dominating small tasks
Share read-onlyOne payloadOne publicationPublication edge onlyA future write to the "read-only" buffer
Share mutable + lockOne payloadOne publicationFull critical section per accessContention, convoy, lock held across I/O
Share a slice per taskOne payloadN cheap slice objectsDisjointness must be provenOverlapping ranges; false sharing at slice boundaries
The same fan-out, decided four ways.

Sharing without the discipline

The failure that makes teams copy forever is not exotic. One of the eight workers is written six months later by someone who does not know the buffer is shared, and it normalizes whitespace in place before extracting its section. Seven summaries are now computed against a document that the eighth worker edited, and the seven that ran first are fine while the seven that ran second are not — so the bug is a function of scheduling, which means it reproduces once a week in production and never on a laptop.

Note precisely what this is: a race condition on the invariant "every worker sees the same document", not necessarily a data race. If the runtime serializes the writes (a single-threaded event loop, for instance) there is no unsynchronized conflicting access at all and every memory-model tool reports a clean run. The logical corruption is identical.

Copying makes that whole class of bug unrepresentable, which is why the correct default for data crossing a task boundary is *transfer or copy*, and sharing is the optimization you make deliberately with a comment explaining why.

Shared buffer, one worker mutates. The invariant dies without any lock being involved.SIMULATED
Invariant · Every worker summarises the same document bytes.
#Worker 3 (summary)Worker 7 (normalizes in place)Worker 5 (summary)State
1read doc[0..500k]··doc=v0 w3=summary from v0
2·doc.normalizeWhitespace() — writes 190k bytes in place·doc=v0-mutated
✕ The document is no longer the version Worker 3 summarised, and no worker asked for it to change.
3··read doc[500k..1M]doc=v0-mutated w5=summary from v0-mutated
4return summary··result=inconsistent set
Nothing here is a data race under a single-threaded event loop — the writes are serialized by the runtime. It is still wrong. Copying removes the schedule; a lock would only have made the corruption orderly.

The decision, written down

The version that survives review makes the choice explicit at the boundary rather than implicit in a parameter type. Hand a task an owned value or a reference typed as read-only, and let the signature carry the contract. In JavaScript across workers the runtime already forces the issue — you get a structured clone unless you transfer or use a SharedArrayBuffer — which is why worker code has fewer of these bugs and more of the memory ones.

Transfer is the third option worth naming: move ownership rather than duplicating or sharing. A transferred ArrayBuffer costs no copy and leaves the sender with a detached, unusable handle, so the isolation is enforced by the runtime rather than by convention. C++ says the same thing with std::move and a unique_ptr, and Rust says it with the type system.

When you do share, share the smallest thing: a slice per worker over disjoint ranges gives you one buffer, no copies and no synchronization — provided the ranges really are disjoint, and provided the boundaries do not put two workers on one cache line, which is False Sharing: Different Variables, Same Cache Line.

Shared by default, isolation by convention
1// doc is a plain object every worker can write.
2async function fanOut(doc: ParsedDoc) {
3 return Promise.all(
4 sections.map((s) => summarize(doc, s)), // summarize *may* mutate doc
5 )
6}
The boundary carries the contract
1// Readonly at the boundary; transfer where the payload is large.
2async function fanOut(doc: Readonly<ParsedDoc>) {
3 return Promise.all(
4 sections.map((s) => summarize(doc, s)),
5 )
6}
7// Cross-worker: transfer, do not clone a 4 MB buffer eight times.
8worker.postMessage({ buf }, [buf]) // buf is detached in the sender

Readonly is compile-time only and will not stop a library, but it turns a runtime scheduling bug into a review-time type error for the code you own. The transfer list turns isolation into a runtime guarantee: after postMessage the sender physically cannot touch the buffer.

Key points

  • Copy gives isolation at the price of memory and copy time; share gives efficiency at the price of synchronization. Both prices are real.
  • The crossover depends on payload size, the fraction each task touches, and whether anyone writes — not on which one feels more elegant.
  • Shared read-only data after a correct publication needs no synchronization at all; it is the possibility of a future write that costs you.
  • Transfer is the underrated third option: no copy, and isolation enforced by the runtime rather than by convention.
  • A worker mutating a shared buffer is a race condition even in a runtime where no data race is possible.

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.

How it works
  • Decide whether any task writes. If none does, sharing is a publication problem and nothing more.
  • If some task writes, decide whether writes can be confined to disjoint regions — then slice rather than share the whole.
  • If writes are unavoidable and overlapping, price the lock: hold time, expected waiters, and what the critical section touches.
  • Compare that price against N copies: bytes, copy time, and peak memory at maximum fan-out — not average fan-out.
  • Encode the winner at the boundary: an owned value, a read-only reference, or a transfer, so the next reader of the code cannot get it wrong silently.
Interleavings that matter
  • Shared, all readers: W3 reads, W5 reads, W7 reads — no ordering matters, every schedule is equivalent, no synchronization required.
  • Shared, one writer: W3 reads doc; W7 normalizes doc in place; W5 reads doc — W3 and W5 summarise different documents and neither knows.
  • Copied: W3 reads copy3; W7 mutates copy7; W5 reads copy5 — no schedule can make these interfere, at the cost of 8x memory.
  • Sliced: W3 writes bytes 0..500k; W5 writes bytes 500k..1M — correct by disjointness, but if the boundary lands mid cache line, both cores fight over one line and throughput drops with no lock in sight.
  • Transferred: sender posts buf and continues; sender touches buf — throws immediately because the buffer is detached. The failure is loud and deterministic rather than timing-dependent.
What it guarantees — and does not
  • Copying guarantees complete isolation: no interleaving of any kind can make one task observe another's writes.
  • Copying does NOT guarantee the copies are of the same version — snapshot once and copy from the snapshot, or you have reintroduced skew.
  • Sharing read-only guarantees consistency only after a correct publication; before it, visibility is not promised.
  • Sharing with a lock guarantees mutual exclusion for the region the lock covers, and nothing about regions it does not — a lock on the map does not protect the object the map hands back.
  • A structured clone guarantees isolation but NOT that the copy is cheap, or that every value survives it — functions, class identity and some host objects do not.
  • Transfer guarantees the sender cannot use the buffer afterwards; it does NOT deep-transfer nested buffers you forgot to list.
Where contention appears
  • Copy: contention on the allocator and on memory bandwidth, both of which scale with fan-out and payload size.
  • Share read-only: no contention. The line sits shared in every core's cache.
  • Share mutable: contention on the lock, plus coherence traffic on the written lines — the second is invisible in lock metrics and often larger.
  • Slices: no lock contention, but boundary cache lines can be contended by two cores with no lock involved at all.
How it fails
  • Race condition on a shared buffer when one task mutates it — reproducible only under the schedules production produces.
  • Data race if the mutation is unsynchronized in a language with a real memory model; in C++ that is undefined behaviour, not a stale read.
  • Memory exhaustion at peak fan-out, because the copy design was sized against average concurrency.
  • Version skew when copies are taken from a moving source at different times.
  • False sharing at slice boundaries, presenting as inexplicable throughput loss that scales with core count.
  • Silent data loss through a structured clone that drops functions, prototypes or class identity.
When it helps
  • Copy helps when payloads are small, tasks are many, and any of them might write — the isolation is worth more than the bytes.
  • Copy helps at a trust boundary: never share a mutable structure with code you do not own.
  • Share helps when the payload is large, every consumer reads, and the read set is a small fraction of the whole.
  • Transfer helps for large binary payloads moving in one direction — the common case for worker offload.
When it hurts
  • Copying hurts when the payload is large and the fan-out is wide: 200 tasks over a 50 MB structure is a memory incident, not a design.
  • Copying hurts when serialization is required to copy — a structured clone or JSON round-trip of a deep object can cost more than the work itself.
  • Sharing hurts the moment a write appears, because it converts a free design into a locked one and the lock is usually added under time pressure.
  • Slicing hurts when disjointness is asserted rather than proven; an off-by-one in a range calculation is silent corruption.
How you would know
  • Peak RSS at maximum observed concurrency, not mean — the copy design fails at the tail.
  • Time spent in copy or serialize as a fraction of task duration; above roughly a tenth, the copy is the workload.
  • Lock wait time and acquisition count on the shared path — the number that tells you whether sharing is actually free.
  • Cache-miss or coherence-miss rate when slicing, which is the only signal that shows false sharing.
  • Count of distinct source versions observed within one fan-out, if you version the payload.
Complexity it introduces
  • Copying adds a memory budget you must reason about at peak, and a snapshot discipline so the copies agree.
  • Sharing adds an ownership convention that lives in comments and reviewer memory unless the type system carries it.
  • Slicing adds range arithmetic, which is where off-by-one corruption lives, plus alignment concerns at boundaries.
  • Transfer adds a detached-object failure mode in the sender and a list of buffers that must be kept in sync with the payload shape.
Simpler alternatives
  • Send only what the task needs — the section, not the document. Often the payload was never the right unit and both branches of the decision were wrong.
  • Immutability, which makes sharing free by removing the writer entirely. See Immutability as a Concurrency Strategy.
  • Copy-on-write, which shares until someone writes and copies only then. See Copy-on-Write as a Concurrency Strategy.
  • Keep the data with a single owner and pass messages, so the question does not arise. See Message Passing and The Actor Model.
  • Do it sequentially. Eight sections of one 4 MB document is often faster in one pass than in eight tasks plus eight copies. See Parallel Overhead.

What people believe, and what is true

Claim

Copying is the beginner's option and sharing is what fast code does.

Reality

Sharing is fast only when nobody writes. Once a write exists, the lock plus the coherence traffic frequently costs more than the memcpy you avoided, and it costs it on every access rather than once.

Claim

If the runtime is single-threaded, sharing a mutable object is safe.

Reality

It is free of data races and full of race conditions. An await inside a handler is a yield point; the object can be mutated across it by another task even with no threads anywhere.

Claim

postMessage is slow because it copies.

Reality

It copies unless you transfer. Moving a large ArrayBuffer through the transfer list is O(1) and leaves the sender detached, which is usually what the code wanted anyway.

Go deeper

Overview

Give each task its own copy and nothing can interfere, but you pay memory and copy time. Point them all at one and you pay coordination instead. Pick with numbers.

Practical

Default to copying or transferring across a task boundary. Share only read-only data, and encode that at the signature. If a write appears later, that is a design change, not a small patch.

Advanced

Slice a large buffer into disjoint per-task ranges to get sharing with no synchronization — then check the boundary alignment, because two tasks writing adjacent bytes on one cache line will fight over it invisibly.

Internals

Transfer of an ArrayBuffer moves the backing store pointer and detaches the sender's view; nothing is copied. A structured clone walks the object graph and reconstructs it, which is why cost scales with graph size rather than with the byte count you were thinking of.

Apply it