◆ Flux

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:

Two rules keep that honest:

The two-level sharing doctrine of the worker substrate 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:

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.

Barrier scheduling on the levels of the graph 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:

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:

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

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:

Budgets across scripts

A per-script node budget is not enough when a chart carries several scripts. Two more bounds apply:

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