Concurrency
Flux runs on many cores, and the author never writes a lock, an await, or a thread. That is not
a convenience API hiding the hard parts — it is a consequence of the language: a pure, total,
typed dataflow graph can be scheduled onto any number of workers without changing a single
bit of its output.
This page is the reference for that claim: how the intermediate representation classifies work, what the shared-memory substrate is, what a task is, how the assignment and the barrier run, how the frame budget degrades, and the one place where “there is no aliasing, so there is no race” would have been a fatal shortcut. The proof that one worker and N workers emit the same bytes lives here in full.
New here? Start with Guide §11 — Determinism, replay and trust → — it states 1 ≡ N for the reader who has to rely on it, and links back down to these mechanics.
The scheduler is not the compiler
The compiler produces a topologically sorted graph. A separate scheduler assigns its nodes to workers. Single-threaded and multi-threaded execution share the same graph — one is the degenerate case of the other, not a different mode with a different code path.
That separation is what makes the parallelism auditable: the thing being scheduled is exactly the thing the gate verified.
Three classes of node
Parallelism is not applied uniformly. The intermediate representation classifies every node, and each class has exactly one legal treatment:
| Class | What is in it | How it may be parallelized |
|---|---|---|
| stateless | arithmetic, comparison, logic, select, a projection — everything that reads only this bar |
independent of every other bar, so it is data-parallel in principle. Never with SIMD — a horizontal SIMD reduction reassociates floating-point and changes the bits. |
| stateful | a kernel, a delay, a crossing — everything that carries state cells between bars |
it is a series along time. Parallel only through an associative prefix scan, and only where the operation genuinely is associative. |
| reduction | No operation is in this class today. | The rule is written but governs nothing yet — inert. It applies to the pairwise-tree reductions of matrix and linear-algebra operations, which the analysis prelude does not include. |
Two of those rows need their honest reading spelled out, because a table this tidy invites a generous one.
The reduction class is empty, and that is deliberate. It is not “the class the sums and the
means go in” — a sum, a mean, a stdev carries state cells and is therefore classified
stateful, like every other kernel: each aggregates internally, along time, through its own
state. The reduction class exists so that the rule governing it — a reduction may be parallelized
only through a frozen pairwise tree, never by reassociating its interior — is written down and
enforced before the first operation that needs it, rather than argued about afterwards. The
classifier is total: an operation it does not recognize is classified stateful, which is the
conservative answer, because the failure mode of guessing wrong in the other direction is a silent
data race.
Chunked data-parallelism over stateless sub-graphs was weighed and set aside. The stateless
class permits it, so the design evaluated it directly rather than leaving it open. The deciding
fact is how real programs are shaped: they interleave their stateless operations into the stateful
cone — an ema reads a difference which reads a close — so the independent stateless work
available to harvest is small, while capturing it would need a second emission path through the
compiler. Against that workload the cost model puts the gain below the cost, so the engine does not
do it. This is a decision on the evidence, not a staged feature; the section below says what the
engine does instead.
The rule underneath all three classes: the reduction order is preserved. Parallel floating-point reordering exists only under the opt-in relaxed mode, and it is never the default.
Why we give up the easy speedup. Summing a column with four threads and combining the partials is the first thing anyone tries, and it produces a different last bit. That bit is the difference between a golden that holds and a golden that drifts, between a server that can verify a client’s work and one that can only approximate it. So the parallelism is found where it does not change a value: across independent nodes, across independent groups, across independent scripts — never inside a single reduction.
The substrate
Web Workers, a shared array buffer, and atomics, behind cross-origin isolation — the one path that actually works in a browser. The doctrine on top of it has two levels, and the split is the whole of the memory safety argument:
- Shared, read-only: the input columns. The host writes the bar columns — time, open, high, low, close, volume — once, into a single shared array buffer. Every worker reads them from there. Nothing is serialized through a message, and a few megabytes of history cost a copy measured in fractions of a millisecond rather than a structured-clone round trip.
- Owned, write: everything a task produces. Each task runs its own compiled module instance, with its own linear memory, owned by the worker executing it. Result columns come back by buffer transfer — zero-copy, and the sender loses the buffer as it hands it over — never by sharing.
Two rules keep that honest:
- No atomic ever touches data. Atomics serve only the scheduler: the task-claim cursor, the done counters, the epoch — in a small dedicated buffer of their own.
- Nothing writable is shared. Not “shared with a discipline”. Not shared at all. Which is a stronger property than the one the contract asks for, and it is the reason the per-worker arena rule below holds by construction rather than by review.
Figure — the two-level doctrine in one picture: the six input columns shared read-only through a single shared array buffer, each task’s module owning its private linear memory, sink columns returned by buffer transfer, and atomics confined to the scheduler’s small counter buffer.
Why the module memories are not one shared memory. The eventual design puts one shared compute module against one shared linear memory, with each worker addressing its own region by offset. That is a real design and it is not this one. Today, one module instance per task with its own memory gives the same parallelism, gives zero writable sharing instead of region-disciplined sharing, and asks nothing of the snapshot, the windowing and the verification machinery that are already built against per-module memories. The shared-memory rewrite buys a future the current runtime does not need yet, and it would reopen three subsystems to do it.
The unit of work is a component, not a node
Before a scheduler can assign anything, something has to decide what a task is. Getting that wrong is how parallel runtimes end up slower than sequential ones, and the arithmetic here is brutal: a node in this intermediate representation costs on the order of a nanosecond, while any hand-off between two workers costs on the order of a microsecond. Parallelizing per node, per bar, would spend a thousand units of overhead to save one. This is the concrete form of the “don’t spawn a worker for a tiny node” rule that the shared cost model exists to answer.
So the v1 unit of work is a connected component of the graph, weighted by its measured cost per bar times the number of bars.
That definition earns its keep on the merged graph — the one the optimizer already builds out of the co-active scripts (optimizer). Merge sixteen scripts and the components re-separate along the real data dependencies, not along the file boundaries:
- scripts that share a sub-expression are glued into one component by the shared node — which is exactly right, because shared state must never be split across two workers;
- scripts that share nothing fall apart into independent components and spread across workers;
- a sink binds its dependencies into its component: a task owns every column it produces.
At fifty thousand bars, a component costs milliseconds — three orders of magnitude above the dispatch cost, which is what makes the whole exercise worth doing.
The scheduler
The assignment is longest-processing-time-first. Sort the components by cost, descending, and give each to the least-loaded worker. Ties break deterministically — by component id, then by worker index — so the same graph always produces the same assignment. For independent tasks on identical workers, this is within 4/3 of the optimal makespan, which is the right point on the curve: a schedule nobody has to think about, with a bound nobody has to trust.
The barrier is per topological level. Level k+1 starts after a full barrier on level k;
within a level every node is independent, so any assignment is correct. In v1 batch the task
graph has no cross-task edges at all — components are independent by definition — so there is
exactly one level, and the barrier contract holds trivially. The scheduler still emits its plan
as levels, because that is the shape the future needs: when cross-task edges arrive with the matrix
and prefix-scan pipelines, they slot into the same barrier without a redesign.
Figure — within a level the nodes are independent, so the assignment of nodes to workers is unobservable and cannot change a byte.
Work-stealing — a Chase–Lev deque per worker plus an atomic in-degree counter per node, a worker taking a node the moment its in-degree reaches zero and stealing from a neighbour when it runs dry — is designed but not adopted: the level-synchronous assignment is already correct, and nothing has shown the level barrier to be the cost. It is a swappable assignment policy behind the same plan interface, revalidated by the same stress harness — a change the design admits without touching semantics. What it is not is a semantic question, which is the point of the next paragraph.
Why an upgrade of this kind is safe, precisely. Because the assignment is unobservable. Values are schedule-independent (the graph is pure), and the memory slots are schedule-independent too (the liveness plan is computed from the canonical order, not from the runtime). So moving from a barrier to work-stealing would be a pure change of assignment policy with zero change of value — a latency decision, not a semantics decision, which is exactly what you want a scheduler to be. That is also why it can be parked without hedging: nothing else in the design is waiting on it, and no guarantee is weaker for its absence.
The trap: zero aliasing is not enough
Here is the mistake this design had to not make.
The liveness plan lets two buffers share a memory slot when their lifetimes are disjoint. Disjoint in the sequential canonical order — that is how the plan reads it. But under a dynamic scheduler, the two nodes that own those buffers can execute at the same moment on two different workers. A shared arena would hand them the same address, and a write-write race would follow — one that no value oracle could catch, because the divergence is in which garbage you read, not in the arithmetic.
Two rules close it:
- The scratch arena is per worker. Private, sized to the peak of that worker’s sub-graph. Inter-worker slot aliasing becomes impossible by construction, not merely improbable.
- Every producer → consumer handoff carries a happens-before edge — a release-store of the ready counter, an acquire-load, or a notify/wait — even in the single-writer, single-reader case. “Zero aliasing implies no race” is necessary but not sufficient under the JavaScript and WebAssembly memory models: without the edge, the consumer is not guaranteed to see the producer’s write at all.
1 ≡ N is proven, not asserted
The claim “one thread and N threads produce identical bytes” is not a hope backed by testing. It follows from a list of properties, each of which is enforced elsewhere:
- the graph is pure, so values are schedule-independent;
- the partition never splits the interior of a reduction — nor, in v1, the interior of anything else: a task is a whole component, and a component is never cut;
- the pairwise reduction tree is frozen, and the reference graph adopts the same tree — so parallel and sequential agree by construction rather than by accident. The clause is inert today, and binds the moment the reserved class has a member;
- ordering is canonical on the value of a key, never on the order a worker happened to see it;
- the random generator is counter-based and therefore position-independent;
- the arenas are per worker.
And then it is tested anyway, because a proof about an implementation is a proof about the implementation you think you have. The stress harness ships in v1: it runs the same graph under one worker and under many, with randomized and adversarial assignment, and asserts
- byte-equality of the output, and
- zero concurrent slot writes.
The assignment seed is journaled, so an adversarial failure reproduces exactly.
What the harness is actually hunting. Not the arithmetic. The list above already settles the arithmetic, and no amount of stress would strengthen it. What can genuinely break is the plumbing, so that is what is stressed: that each task is claimed exactly once and never twice; that each sink column is written exactly once; that a module instance never serves two tasks at the same moment, and that reusing one across ticks resets its state; that a transferred buffer is never read after it has been detached. Those are the bugs a pure dataflow language can still have, and they are invisible to a value oracle — a duplicate claim computes the right number, twice.
And it runs twice, on two substrates: first in-process, against simulated workers and seeded adversarial completion orders; then unchanged, on real threads. That ordering is a diagnostic instrument. A failure that reproduces in-process is a bug in the logic — the partition, the demultiplexing, the claim protocol. A failure that appears only on real threads is a bug in the substrate — a transfer, an atomic, a measurement. Running the same assertions in both places is what lets a failure say which of the two it is, before anyone starts guessing.
The browser is not a given
A shared array buffer requires cross-origin isolation, and cross-origin isolation requires two response headers that a page does not always get to have. So the fleet is not a foundation the rest of the design stands on — it is an acceleration that may or may not be available, and the design says so out loud:
- Absent the headers, N = 1, automatically. No fleet, no shared buffer, no degraded mode to test: the ordinary single-module path is the N = 1 case, so a visitor without cross-origin isolation runs the same graph, gets the same bytes, and pays only in latency. There is no second code path to keep correct, which is the only reason this fallback can be trusted.
- The fleet is used only when it pays. Even with the headers, the scheduler takes the fleet path only when there is more than one core and the merged graph’s measured cost clears a threshold. Below it, the merged single-module path is faster, and it is what runs.
- A hung task is a timeout, not a hang. A worker that never comes back cannot be interrupted, so the batch carries a deadline derived from its own cost estimate. On expiry the fleet is torn down and respawned, and the batch is replayed. Purity is what makes that safe: a replayed batch produces byte-identical results, so a retry is not a second answer — it is the same answer, arrived at again. Any fleet error at all falls back to the single-module path for that tick: loud in the statistics, invisible to the user.
Budgets across scripts
A per-script node budget is not enough when a chart carries several scripts. Two more bounds apply:
- a cap on co-active scripts merged into one graph, and
- an aggregate frame budget.
Over budget, the response is a deterministic degradation policy. Tasks are deferred — never killed; the layer above reschedules them — until the remainder fits, and the order they are deferred in is a total order fixed in advance: ascending priority first (the priority comes from the host, and is never derived from the data), then descending cost at equal priority (deferring the biggest frees the most), then ascending index. A task that on its own exceeds the budget is deferred too — the budget is a hard contract, not a suggestion. A free task is never deferred, because deferring it would free nothing.
Read the tie-breaks again and notice what they are for. Every one of them exists to make the answer to “which script gets dropped” a function of the declaration and never of the numbers flowing through it. A degradation policy that consulted the data would make the set of scripts that ran depend on the market, and a chart whose composition changes with the data is a chart nobody can reason about — or reproduce. A frame that drops is a decision, made in advance, in one place.
The instance pool, and why eviction is boring on purpose. Workers keep a pool of module instances with a stable task-to-worker affinity, so a task that runs every tick — a live update, a replay step — finds its instance warm rather than re-instantiating it. The pool’s footprint is accounted for exactly: the sum, per worker, of the peak memory each of its modules plans for, which the memory model already computes at compile time (memory model). And when the pool must evict, it evicts by an explicit priority order — never by what the data happened to touch most recently. Determinism is not a property you can have in the arithmetic and give up in the cache.
What v1 delivers, and what it does not
Multi-worker execution, from the start — built, stress-tested, and shipped, with single-threaded as its degenerate case. Concretely, that is: the node classification, the partition into components, the longest-processing-time assignment over a level barrier, per-worker arenas, atomics confined to the scheduler’s counters, the aggregate budget with its deterministic degradation, and the 1 ≡ N harness that holds all of it in place.
Three things are deliberately not in it, and none of them is load-bearing:
| Not done | Why |
|---|---|
| work-stealing | A latency optimization over an assignment that is already correct; not adopted while nothing shows the level barrier to be the cost — swappable behind the plan interface should that change. |
| chunked data-parallelism over stateless sub-graphs | Weighed and set aside: the class permits it, but real programs interleave stateless work into the stateful cone, so the independent work to harvest is small while capturing it needs a second emission path — the cost model puts the gain below the cost. A decision on the evidence, not a staged feature. |
| the pairwise reduction tree | The rule is written but governs no current operation; it applies to matrix and linear-algebra reductions, which the analysis prelude does not include. |
The one honest deferral in this area that is not about scheduling: network transports that need a raw socket, which the browser cannot open at all.
See also
- Memory model — the liveness plan, and why the arena must be per worker.
- Optimizer — the shared cost model, and the frozen reduction order.
- Verification — the canonical home of the verification harness and reproducible builds this 1 ≡ N stress suite sits beside.
- Compiler and runtime — the gate the scheduled graph is verified by before it runs.
- compute — where the parallelism actually pays: independent groups, independent cells.
- Guarantees — 1 ≡ N stated for the reader who has to rely on it.