← back

0x0 Shared Memory
Jul 24, 2026

In my last post about, I ended with a deliberately narrow release statement.

Then I wrote the caveats:

Cross-thread shared ownership is unsupported. Reference counting is non-atomic. Shared graphs must be immutable and acyclic. There are no weak references or cycle collector.

It was also an excellent list of things that would bother me until I either implemented them or stopped pretending I wanted a multithreaded runtime.

So I kept going.

The Linux x86-64 direct-ELF profile now has registered native threads, checked Send and Share transfer, atomic strong and weak ownership, synchronized mutable graphs, weak references, bounded cycle collection, generated ownership adapters for foreign calls, and direct-artifact instrumentation.

The release soak ran for 86,400 active seconds. It completed 139,255 batches, peaked at 8,484 KiB of RSS, kept its longest collector pause below 1.7 milliseconds, ended with no candidate backlog, and returned every tracked ownership and resource counter to its starting value.

The less pleasant lesson is that making a reference count atomic is not the same thing as making a runtime thread-safe.

Not even remotely.

Imagine two threads own the same object.

An atomic increment can stop them from losing a retain. An atomic decrement can decide which thread observed the final strong reference.

That still leaves several questions:

An atomic counter answers one question:

Who won this counter transition?

A managed runtime needs to answer the rest of the protocol.

That changed how I thought about this work. The goal was not "add threads." The goal was to define every transition that becomes ambiguous when another thread can observe it.

The design ended up in three decisions:

The decisions matter because these three subjects cannot safely be designed independently. Object layout determines what a weak handle can preserve. Thread publication determines what the collector can see. Finalization determines what foreign code is allowed to resurrect.

The previous Linux-v1 memory profile was intentionally small.

One owner slot represented one complete allocation arena. Shared ownership used one non-atomic strong count for the whole immutable, acyclic graph. It worked for the five released value families and made region-style cleanup cheap.

It could not represent independently shared nodes.

It also could not represent a weak handle whose bookkeeping survives after the payload dies. Reusing the old slot would have made v1 and v2 handles ambiguously valid, which is exactly the kind of compatibility shortcut that becomes a debugging hobby later.

ABI-v2 therefore uses a separate handle domain. A v2 handle sets its high bit and points to a generation-checked, 128-byte control block.

The control records:

The payload is stable and non-moving. It can die before the control block, which is what makes a weak reference possible.

The lifecycle is explicit:

allocating
    |
    v
live
    |
    v
destroying
    |
    v
payload-dead
    |
    v
control-dead

Publication moves an object from allocating to live only after its descriptor and initialized children validate. The thread that wins strong zero destroys the payload exactly once and releases its implicit weak reference. The thread that later wins weak zero recycles the control exactly once.

Stale generations, mixed v1/v2 handles, invalid descriptors, counter overflow, double release, and partial construction all fail closed.

This is a larger object header than v1. It is supposed to be. Unique values and regions do not pay for it unless they are promoted into managed shared ownership.

Runtime bookkeeping cannot repair ownership that the compiler never understood.

The old direct backend had late cleanup logic that inferred lifetimes from source names. That was enough for a narrow single-threaded profile. It was not enough for control-flow joins, moved values, closure captures, thread roots, or collector safepoints.

Bindings now carry typed ownership records before native layout:

The syntax can expose that contract where needed:

(ƒ mutable-map-worker
  (∷ (→ I64 I64))
  (cap pure)
  (param-own node borrowed managed-v2 send share)
  (node)
  ...)

A control-flow join preserves a value only when its incoming states agree. If one branch released a value and another kept it, later use fails closed instead of guessing.

Interprocedural summaries carry owned returns through helpers. Cleanup is inserted in typed IR. The old source-name lifetime walkers are gone.

Every direct ELF artifact also contains a versioned root-map record. User calls have compiler-emitted safepoints. When the collector requests a stop, a registered thread acknowledges the current generation and waits on a futex until the world resumes.

That is a lot of machinery for a small language.

It is also the minimum shape of an answer to:

Where are the live managed values right now?

The runtime uses registered Linux clone threads with guarded stacks, generation-checked handles, thread-local controls, and typed join outcomes.

A thread attaches before it can hold managed roots. It detaches only after roots, resources, pins, callbacks, and local caches have closed.

Sharing and moving are different operations.

A shared argument is retained before publication. A unique Send value moves its existing owner into the worker:

(≔ ((resource (managed-v2-resource-open "README.md"))
    (thread
      (managed-thread-spawn-move "unique-handoff-worker" resource))
    (value
      (managed-thread-outcome-value-or
        (managed-thread-join-outcome thread)
        0)))
  value)

The source object cannot remain secretly usable after the move. Borrowed stack values, unsafe captures, non-Send fields, and foreign callback state are rejected with a path explaining which part blocked transfer.

Normal return, panic, cancellation, failed spawn, and join all converge on the same cleanup obligations. Worker cleanup closes the published root before the kernel clears the join futex.

The atomic workload is deliberately boring:

(≔ ((cell (managed-atomic-i64-new 0))
    (one (managed-thread-spawn "atomic-ring-worker" cell))
    (two (managed-thread-spawn "atomic-ring-worker" cell))
    (three (managed-thread-spawn "atomic-ring-worker" cell))
    (four (managed-thread-spawn "atomic-ring-worker" cell)))
  (do
    (managed-thread-join one)
    (managed-thread-join two)
    (managed-thread-join three)
    (managed-thread-join four)
    (managed-atomic-i64-load cell (atomic-order-acquire Unit))))

Each worker performs 1,024 increments. The useful part is not the final number. The useful part is that publication, atomic operations, joins, root closure, and terminal counters all agree about how the number got there.

The compiler provides checked atomic load, store, exchange, compare-exchange, fetch-add, fetch-sub, and fence operations. The x86-64 path uses locked read-modify-write instructions and explicit fences. An independent assembly oracle checks the emitted instruction behavior.

But atomic reference counting protects lifetime only.

It does not make an arbitrary payload safe to mutate.

Safe shared mutation goes through synchronization-aware cells or collections. There is no plain mutable escape hatch that becomes safe because the owner count happens to be atomic.

Replacing a shared edge follows a specific order:

  1. retain the replacement;
  2. acquire the runtime lock;
  3. publish the new edge;
  4. record the possible cycle candidate;
  5. release the lock;
  6. transfer or release the old edge.

Readers clone the published owner while synchronized. Writers cannot expose a half-updated graph.

The mutable-map workload runs four workers, each performing 1,024 edge replacements. It records synchronization operations and contention, then requires all roots and pending cycle candidates to close.

Unsynchronized shared cells are compile-time failures.

I like this rule because it separates two ideas that are often collapsed:

The first needs atomic ownership. The second needs a synchronization protocol.

A weak reference owns a generation-checked control, not the payload.

It cannot read the payload directly. It must upgrade:

(≔ ((strong (managed-v2-new 73 (managed-descriptor-i64 Unit)))
    (weak (managed-v2-weak-new strong))
    (upgrade (managed-v2-weak-upgrade weak)))
  (if (managed-v2-weak-upgrade-present? upgrade)
    (managed-v2-weak-upgrade-read upgrade)
    0))

Upgrade uses compare-and-exchange from a positive strong count. It linearizes either before final release or after strong zero. It cannot resurrect a dead payload.

The race gate runs one, two, four, and eight worker lanes with different release delays. Every attempt must become either a successful or failed upgrade. Payload destruction must happen once. Payload, control, weak-owner, thread, and root counters must close.

The negative cases matter as much as the successful one:

There are no weak-death callbacks in this release. Observation has a smaller, more predictable contract: upgrade succeeds or it does not.

Two objects can each have a positive strong count while nothing outside their component can reach them.

Reference counting sees two live objects.

The program sees garbage.

The first 0x0 cycle collector uses bounded stop-the-world trial deletion. Synchronized edge updates record candidates through descriptor-aware write barriers. A collection starts only after every registered managed thread has acknowledged the current safepoint generation.

For a candidate component, the collector copies strong counts into trial counts and subtracts internal edges without modifying the real counts. A component whose trial counts reach zero and which has no registered root is disconnected and can be reclaimed.

Weak edges do not contribute to reachability.

The workload makes the problem explicit:

(≔ ((first (managed-graph-node-new value))
    (second (managed-graph-node-new (+ value 1))))
  (do
    (managed-graph-edge-store first second)
    (managed-graph-edge-store second first)
    (managed-v2-release first)
    (managed-v2-release second)
    (managed-cycle-collect 64)))

The collector is bounded by candidate work, object work, and pause time. Exhausting a bound defers an intact component. A failed handshake, bookkeeping allocation, cancellation, or shutdown drain resumes stopped threads and leaves the real graph valid.

Runtime-owned resource destruction is deterministic and exactly once. User-visible finalizer resurrection is rejected.

This is not a moving collector. It is not a concurrent collector. It is a small, bounded policy for the graph semantics 0x0 currently publishes.

The earlier native memory release had direct evidence for five families: Unit, Bool, I64, Text, and List.

The managed release no longer promotes a few hand-picked composite types. One versioned ABI schema generates descriptor identities, runtime constructors, compiler validation, and common lifetime tests for every managed direct value family in the support matrix.

That includes sums, errors, maps, bytes, paths, linear handles, protocols, actors, runtime state, process and socket handles, closures, host buffers, and hardware evidence.

The generated matrix is only the common floor. Family-specific tests still exercise:

A row is not promoted because a tagged-list model exists somewhere else in the repository. It needs direct native positive, negative, leak, and stress evidence.

The old release required hand-written ownership adapters at foreign boundaries.

The new FFI schema makes ownership part of the declaration. The supported modes are:

Declarations also name the ABI, destructor, retain and release operations, error convention, thread affinity, callback policy, and pin direction.

A generator turns the canonical table into 0x0 adapter source and metadata. The runtime operations handle temporary borrows, longer scopes, transferred values, retained sharing, weak observation, pins, resources, callback roots, and collector-visible foreign roots.

Callbacks entering from a foreign thread attach it, publish temporary roots, contain panic at the no-unwind boundary, close pins and roots, and detach after terminal accounting.

Unknown ownership remains explicitly unsafe. It cannot quietly acquire safe Send, Share, weak, or collector behavior.

There is a C fixture in the FFI gate. It is an independent SysV x86-64 oracle, not a production implementation or bootstrap dependency. A self-hosted 0x0 object builder emits the 0x0 side of the C→0x0→C round trip so register, relocation, callback, and ownership behavior can be checked from both sides of the boundary.

The dangerous border became small, generated, and inspectable.

Raw direct ELF executables do not automatically become ASan or TSan programs. They do not link Clang's sanitizer runtime, and saying "sanitizer-backed" without explaining the lanes would hide an important distinction.

The release uses several complementary profiles.

The exact production bytes run normally and under qemu-x86_64 -strace. Their hash must remain identical. The external lane observes mappings, unmappings, thread creation, futexes, syscalls, and signals without replacing the artifact.

Separate compiler-instrumented direct ELF profiles check:

Hosted ASan, UBSan, LSan, and TSan programs calibrate the negative detector oracles. They are test-only artifacts, not the thing being shipped.

The positive instrumented and production programs must agree. The negative matrix must detect stale handles, double release, bounds errors, incomplete construction, division by zero, logical leaks, invalid atomic orders, missing barriers, and stale collector roots.

This does not pretend the raw ELF is secretly a Clang-instrumented binary.

It gives each kind of evidence an honest name.

Fast loops catch obvious leaks and races. They do not prove that a bounded runtime remains bounded after scheduler variation, repeated collection, callbacks, cancellation, and allocator reuse.

The release soak is identity-bound to the compiler source, compiler artifact, runtime, workloads, soak sources, and artifact manifest. Changing any of them makes the evidence stale.

It is also restart-safe. Completed batches are durable; interrupted partial batches do not count. A lock prevents two writers from claiming the same state.

The accepted run reported:

MeasurementResult
Active duration86,400 seconds
Completed batches139,255
First peak RSS8,236 KiB
Final peak RSS8,444 KiB
Maximum peak RSS8,484 KiB
Maximum collector pause1,697,507 ns
Maximum candidate backlog0
Maximum synchronization operations24,594
Maximum contention events7,069,550

The terminal counters matched their baselines:

The main managed thread remains registered, which is why the thread baseline and final value are both one. The important property is no drift.

The collector's largest observed pause was about 3.4% of its 50-millisecond release ceiling.

After the run completed, the release decision could move from implemented-awaiting-evidence to pass.

A runtime feature is not complete if adding it quietly breaks the language's bootstrap proof.

The final gate rebuilt three native compiler generations. All three produced the same SHA-256 artifact:

a0f77d7afb178b17eb516cf96c9010accee2136388e8f51fda180e660f4fe936

Their measured peaks were 125,444 KiB, 125,496 KiB, and 125,264 KiB under a 256 MiB per-process ceiling.

The C-free assembly-seed recovery also remained below its independent 512 MiB ceiling. The managed runtime did not turn C back into a hidden first-root dependency.

The release gate joins the runtime evidence with compiler succession, seed recovery, retained v1 regressions, documentation, and every Phase 0 through 8 ledger.

The source-owned version of a release policy checker initially exhausted itself by recursively searching the entire compiler source. Fixing the runtime exposed a checker that could not safely inspect the larger runtime.

The search was changed to bounded chunks. That then exposed previously masked parenthesis errors in several policy checkers.

The proof machinery had to survive the same growth it was trying to prove.

At the end, the live state was still exactly where it started.

Shared memory is not a permission slip.

It is a protocol.

Glossary

Managed object: a value whose independently tracked lifetime is represented by an ABI-v2 control block and descriptor.

Strong reference: ownership that keeps a managed payload alive.

Weak reference: ownership that keeps the control block observable without keeping the payload alive.

Send: the property that permits ownership of a value to move across a thread boundary.

Share: the property that permits shared ownership of a value across threads.

Safepoint: a compiler-emitted location where a registered thread can acknowledge a collector stop request.

Root map: metadata describing managed values that must remain reachable while a function is active.

Trial deletion: a cycle-collection algorithm that subtracts internal edges from temporary counts to find components with no outside owners.

Write barrier: runtime bookkeeping performed when a managed graph edge changes.

FFI: Foreign Function Interface, the contract used when 0x0 code and foreign code call each other.

Linearizable: behaving as if a concurrent operation took effect at one well-defined instant between its start and completion.