ADR-057: Locked[T] Is Decided Per Value, Not Per Build Setting

Status: Superseded by ADR-281 — its D1-D3 are D5-D7 there, and D4 is D9 Supersedes: Part II 12.2’s per-setting split (“similar to a RefCell with a reentrancy check” at no, “a real OS-level Mutex” at yes), which is the normative sentence this displaces Related: ADR-037 D6 and D7 (the count: an atomic floor with a per-value optimisation on top — the shape this copies), ADR-039 D8 (the re-entrancy check as a build switch), ADR-005 D6 (access takes a sync lambda at every setting — the rule this does not touch), mutex-floor.md (the measurements)

1. Context

Locked[T] cannot be emitted. Everything behind it waits on one question — what it is in the machine — and Part II 12.2 answers that per build setting: a cheap borrow flag at user_parallelism = no, an OS mutex at yes.

Per setting is what blocks it. A value’s ability to cross a thread then depends on the switch of whoever builds the program, not on the program, so a library cannot know across its own boundary what its type is. And Arc<RefCell<T>> is not Send: under the cheap shape a Shared[Locked[T]] cannot cross at all.

The investigation in mutex-floor.md put the obvious alternative — an OS mutex everywhere, a lower bound under both settings — and measured it: 11.3 ns per acquisition at no with no system call, against Shared[Locked[T]] becoming crossable at all.

Both answers are wrong in the same way. Per setting decides a property of a value somewhere other than where the value is. A lower bound under both settings charges every value for a capability almost none of them use — and at no it charges exactly what the switch exists to save, which makes it the switch working against itself.

And there is a third answer that was already in the building. The reference count asks this question per value today, and answers it. ADR-037 D6 makes the atomic count the floor; D7 lets contracts::sharing lower a particular value to a plain count where it proves nothing crosses a thread with it. Run, on one program with user_parallelism = yes:

atomic  `crossing`  crosses: a `spawn` body uses it, and a task runs on a thread of its own
plain   `local`     nothing crosses a thread with it, so the count is lowered
let crossing: std::sync::Arc<Conn> = …
let local:    std::rc::Rc<Conn>    = …
let t = nikaia_std::task::TaskHandle::start(async move { crossing.id });

Two values of one written type, two shapes, one program, compiled and run. The question this record asks is already asked and already answered — for the count.

2. Decisions

D1 — The safe shape is the floor, and the cheap one is an optimisation on top of it.

Where it is not proved that nothing crosses a thread with a value, that value’s Locked is the OS mutex. This is ADR-037 D6’s polarity and it is not negotiable for the same reason: being wrong towards the mutex costs nanoseconds, being wrong towards the flag costs correctness.

D2 — At user_parallelism = no, every Locked is the cheap shape.

Nothing a user writes can cross a thread there. One user thread (ADR-037 D2), and the runtime’s I/O thread is not the user’s and cannot reach a user lock — rt::worker’s Op carries no closure, and on_io_worker() asserts the boundary at every public entry of rt::io (mutex-floor.md §2.1).

So at no the mutex buys nothing and costs 11.3 ns per acquisition — which is what the switch exists to save. And it keeps a diagnostic: re-entering one lock is the only failure reachable at no, the cheap shape says so and the mutex hangs for ever without saying anything, taking ADR-006 D5’s shutdown deadline with it.

D3 — At user_parallelism = yes, it is decided per value, by the analysis that already decides the count.

A Locked is only reachable from two places through a shared handle, so the answer contracts::sharing gives that handle is the answer for the lock inside it. One analysis, two decisions, no second pass — and every one of its seven named reasons to decline carries over unchanged.

D4 — The two shapes must behave identically, and that is what the mutex shape pays for.

Rc and Arc differ in cost and in nothing else, which is what makes a per-value choice invisible to a reader. A borrow flag and a mutex do not: one says so when a program re-enters a lock, the other hangs. Two behaviours for one written type, picked by an analysis the reader cannot see, would be worse than either thing this record replaces.

So the mutex shape carries an owner check — measured at +2.0 ns, ×1.15 (mutex-floor.md §4.3) — and re-entering says the same thing whichever shape a value got. One behaviour, two prices.

Under ADR-039 D8 that check is a build switch, on by default, and its role is unchanged here: self-control of a rule, not error handling. D2 makes it free at no, and D3 makes it fall only on the values that actually cross, so the set it is paid on is smaller than the switch has ever had to cover.

3. What this does not decide

4. What it costs

The seed list carries twice the weight. contracts::sharing’s own words: “every crossing is a seed, and a missed seed is unsoundness rather than a missed optimisation”. Hanging the lock on the same answer means that list must be right for two things. It has already learnt once — the overlap the compiler itself chooses became a seed when it became one — and since spawn lowers, the crossing side can finally be exercised end to end rather than reasoned about.

And one normative sentence is displaced, which is why this is a record and not a patch: Part II 12.2 decides both settings in plain words and no ADR did.

5. What is built

The shape, end to end. Not the surface it is reached through.

Built:

Not built, and each is named rather than left to be found:

This site is open source. Improve this page.