Shared<T> reference counting
Shared<T> is Axle’s refcounted-pointer type. Use it when a
single object needs more than one live owner — a long-lived
collection, a cache reachable from several worker threads, a
graph node referenced by multiple parents, anything where the
escape-analysis tiers (stack / arena / heap-owned) don’t fit.
It’s the only memory model in Axle that costs you an explicit
keyword. The compiler will never silently insert refcount ops
behind your back; you write Shared<T> or new shared T(...) exactly where you want them.
TL;DR
class Cell {
pub n : i32;
constructor(v : i32) {
self.n = v;
}
}
fn main() : i32 {
let p = new shared Cell(42); // refcount = 1
println(p.n);
return 0;
} // scope exit → refcount = 0
// → destructor (if any) → free Three things to remember:
- Allocate with
new shared T(...). - Annotate with
Shared<T>when the type isn’t inferred (fields, return types, generic args). - Drop is automatic — the compiler inserts the decrement at every scope exit; the runtime frees on the last drop.
(The pub on every field below is what makes Cell’s state readable from main. An unmarked class field belongs to the declaring class alone, so
the examples say what they mean — see Visibility and member modifiers.)
What is a reference count?
The other three tiers each have exactly one owner, so the compiler always knows the single moment to free. But sometimes a value has several owners with no single “last” one decided at compile time — a cache three threads hold, a graph node two parents point at. Who frees it, and when?
Reference counting answers that at runtime with a small
integer stored alongside the object: how many owners are alive
right now. Every new owner adds 1 (inc); every owner that goes
away subtracts 1 (dec); when the count hits 0, nobody is left
and the object is freed — immediately, deterministically, no
scan, no pause.
new shared Cell(42) let q = p scope end of q scope end of p
count = 1 ──► count = 2 ──► count = 1 ──► count = 0
┌───────┬───────┐ inc ┌───────┬───────┐ dec ┌────┬────┐ dec ┌──────────┐
│ rc=1 │ 42 │ │ rc=2 │ 42 │ │rc=1│ 42 │ │ FREED │
└───────┴───────┘ └───────┴───────┘ └────┴────┘ └──────────┘
p ────────────────────► p, q ──────────► p ─────────► (destructor
runs here) The count is what makes multi-owner cleanup deterministic without
a garbage collector: the free happens the instant the last owner
drops, at a point in the code you can predict. The cost is that
every inc and dec is a real operation — and because owners can
live on different threads, they must be atomic, which is why Shared<T> is the one tier you opt into by hand.
Axle inserts every inc and dec for you: an inc at each new
binding that aliases the handle, a dec at each scope exit. You
never write the arithmetic — you just decide, by reaching for Shared<T>, that this value has more than one owner.
Why Shared<T> is a type and not a prefix
The other ownership keywords — mut, own, the default borrow —
are prefixes on parameter names. They share the same machine
representation (one pointer per parameter); only the calling
convention contract differs.
Shared<T> is fundamentally different. The runtime allocates a 16-byte header in front of every shared object:
[ AtomicU32 strong | AtomicU32 weak | u64 size ] [ user payload, 16-B aligned ]
^
you receive this pointer That header is real memory. The strong count is atomically
incremented every time a new owner takes a reference and decremented
every time one drops out of scope; when it hits zero the runtime runs
the destructor (if the class declared one) and drops the payload. The weak count follows the std::sync::Arc / Weak model: the strong
references collectively hold one implicit weak, so the backing block
itself is freed only once the weak count reaches zero — which, with no Weak<T> alive, happens at the same moment as the last strong drop.
Because Shared<T> has a representation that’s distinct from
plain T, it’s spelled as a real type — capital S, generic
brackets — not a keyword qualifier. You’d write ArrayList<Shared<T>> the same way you’d write ArrayList<Future<T>>: a wrapper that changes both the storage
shape and the lifecycle.
Allocating a Shared<T>
The constructor sugar new shared T(args) produces a fresh
shared allocation with refcount = 1:
let cache = new shared Cache(); // type inferred: Shared<Cache>
let p : Shared<Cell> = new shared Cell(42); // type written explicitly You can drop the annotation when the right-hand side is itself a new shared — the type is inferred from the allocation. You
must write the annotation when:
- the binding gets its value from somewhere other than a
newexpression (a function return, a field load, a method call); - the binding is a field, parameter, or return type, where Axle has no inference at the declaration site.
Always construct a shared value with
new shared(orShared::wrap) — never a plainnew. AShared<T>object carries a 16-byte refcount header (see below); thenew sharedallocator is what reserves it. A plainnew T(...)allocates without that header, so a handle built from it would run its refcount ops over memory that was never set aside for them. The compiler rejects the mismatch: binding or returning a plainnew T(...)where aShared<T>is expected is a compile error (E0034), whose hint tells you to allocate itnew shared T(...)— or to wrap an already-built object withShared::wrap. The wordsharedappears at every construction site.
Using a Shared<T>: fields and methods
A Shared<T> is a refcounted handle in front of the payload, and the handle is the payload’s address — the count sits just before the object. So the
object is used through the handle exactly as it is used directly:
- its fields read and write on the handle —
cache.hits,cache.hits = 1; - its methods are called on the handle —
cache.record(). Aselformut selfmethod borrows the object for the call while the handle keeps it alive.
class Cache {
hits : i32;
constructor() { self.hits = 0; }
pub fn record(mut self) : void { self.hits = self.hits + 1; }
pub fn count(self) : i32 { return self.hits; }
}
fn main() : i32 {
let c = new shared Cache();
c.record(); // a `mut self` method, through the handle
return c.count() - 1; // → 0
} The one method a handle cannot call is an own self one: it would take the
object away from every other handle still holding it (E0504). Declare it self or mut self, or call it on an owned object.
A mutation through a handle is an ordinary memory access — see the warning in Passing Shared<T> across threads before
two threads write through handles to one object.
Sharing an array
An array is shared by allocating it in the shared tier:
let s : Shared<i32[]> = Shared::wrap(malloc<i32>(4096));
s[0] = 7; // read and write through the handle
let n : i64 = s.length; s[i], s[i] = v and s.length work on the handle exactly as on the array,
and the handle is passed, stored and released like any other Shared<T>: the
last owner frees the buffer. Two rules follow from the buffer being shared
from its allocation on (E0766 otherwise):
- the allocation is written inside the call —
Shared::wrap(malloc<T>(n)), notShared::wrap(a)for an array allocated earlier, which belongs to a tier with its own release; - the elements own nothing — numbers,
bool,char, or a struct of those. The last owner frees the buffer and nothing inside it; to share strings or objects, share a class holding the array. Nor may they hold a SIMD vector wider than 16 bytes: the elements sit at a 16-byte alignment.
A shared array is not an array: pass s where a T[] is expected and it is a
type error. Index it, or read its length, through the handle.
Storing Shared<T> in fields
Shared<T> composes with the regular type system. The canonical
use case is one object with several long-lived owners:
class Cache {
pub hits : i32;
constructor() {
self.hits = 0;
}
}
class Service {
pub cache : Shared<Cache>;
constructor(own c : Shared<Cache>) {
self.cache = c;
}
pub fn hit(self) : i32 {
return self.cache.hits;
}
}
fn main() : i32 {
let c = new shared Cache();
let s1 = new Service(c);
let s2 = new Service(c);
return s1.hit() + s2.hit();
} A field store takes one reference, so the parameter feeding the field is
declared own — a default (borrowed) parameter may not be stored into a
field (E0513). own on a Shared<T> hands the callee one more
reference, not the object: the caller’s handle stays live, which is why c is handed to both services above. The same holds for a Shared<T> field of self handed on (new Service(self.cache)). Each Service holds its own
count on the cache; destroying a Service releases its reference, and the
last drop frees the Cache.
Passing Shared<T> across threads
The refcount itself is safe across threads: sending a Shared<T> through spawn is exactly what makes its class thread-crossing, so the
compiler counts it with atomic operations here — incrementing and
decrementing the strong/weak counts cannot corrupt the counter or cause
a double-free, no matter which thread ends up dropping last; the
destructor still runs exactly once. (A Shared<T> whose class never
leaves its thread uses non-atomic counts instead — see Refcount and
transitions.)
This does not make the payload thread-safe. The atomicity covers
only the 16-byte header (strong/weak/size) — reading or writing
the object’s own fields (cache.hits, and any other field) is an
ordinary, unsynchronized memory access. If two threads hold the same Shared<T> and at least one of them mutates a field, that is a data
race: the compiler does not detect it, and nothing on the spawn path
inserts a lock. The record_hit function above is only safe to call
from one thread at a time; calling it concurrently from two spawned
tasks on the same cache loses updates.
Read-only sharing — every participant only reads a field, as in the lookup example below — is fine as-is. The moment any participant writes a field, guard the access explicitly with synchronized over
a lock object of your choosing; synchronized is a plain user-supplied
lock, never injected automatically:
class Cache {
pub hits : i32;
constructor() { self.hits = 0; }
}
class Guard {
pub tag : i32;
constructor() { self.tag = 0; }
}
fn record_hit_locked(c : Shared<Cache>, lock : Shared<Guard>) : void {
synchronized (lock) {
c.hits = c.hits + 1;
}
} class Cache {
pub hits : i32;
constructor() { self.hits = 0; }
}
// Read-only sharing: every participant only reads `cache.hits`, so no
// synchronization is needed beyond the atomic refcount.
fn lookup(cache : Shared<Cache>, key : i32) : i32 {
return cache.hits + key;
}
fn worker(cache : Shared<Cache>, key : i32) : i32 {
return lookup(cache, key);
}
fn main() : i32 {
let cache = new shared Cache();
let h : Task<i32> = spawn worker(cache, 0); // one live ref crosses to the thread
let mine = lookup(cache, 0); // main holds another live ref
let got = h.join();
return got + mine;
} // main and worker both released
// → refcount drops to 0 → free This is the one tier that crosses thread boundaries. Arena
allocations are per-thread (a value allocated in thread A’s
arena cannot be referenced from thread B); heap-owned has no
synchronization on the drop side. If you need to share, use Shared<T>.
Returning a Shared<T> (transfer of ownership)
A function that returns a Shared<T> hands its refcount to the
caller. The compiler does not dec the local at function
exit when the return value is exactly that binding — the caller
inherits the existing count.
class Cache {
pub hits : i32;
constructor() { self.hits = 0; }
}
fn build_cache() : Shared<Cache> {
let c = new shared Cache(); // refcount = 1
c.hits = 1; // populate its fields directly
return c; // count NOT dec'd here —
// ownership transferred
}
fn report(c : Shared<Cache>) : void {
println(c.hits);
}
fn main() : i32 {
let cache = build_cache(); // still refcount = 1
report(cache);
return 0;
} // dec → 0 → drop If you instead return c.field or any derived expression, the
binding the field belongs to stays alive until after the read —
the compiler proves the dec doesn’t fire before the value is
materialised.
Destructors
When the refcount reaches zero, the runtime invokes the class destructor (if the class has one) before freeing. The destructor runs exactly once.
Destructors are synthesised by the compiler: a class that
owns heap fields — a dynamic array T[], an owned object field,
a Shared<U> handle — gets a destructor that releases each owned
field. You never write the cleanup, and you cannot forget it:
class Holder {
pub data : i32[], // owned heap buffer
constructor(n : i32) {
self.data = malloc<i32>(n);
}
}
fn main() : i32 {
let h = new shared Holder(64);
h.data[0] = 7;
return h.data[0] - 7;
} // last drop → the synthesised
// destructor frees h.data,
// then the Holder itself Writing a destructor body of your own uses the reserved drop() member (no parameters); it runs first, then the compiler appends the
cleanup it can prove — owned fields, refcounted handles. A class with
no drop() relies entirely on that synthesised cleanup. For external
resources (files, sockets), put the release in drop() or pair the
object with a defer … close().
Owned ↔ Shared transitions
You can promote a uniquely-owned value into a Shared<T> and —
when you hold the last reference — try to recover unique
ownership back.
class Cache {
pub hits : i32;
constructor() {
self.hits = 0;
}
}
fn promote() : Shared<Cache> {
let owned = new Cache(); // uniquely owned (heap)
return Shared::wrap(owned); // → Shared<Cache>, refcount = 1
}
fn main() : i32 {
let s = promote();
let unique = Shared::try_unwrap(s); // sole owner → value, else null
if (unique != null) {
return unique.hits; // unique ownership recovered
}
return 1;
} Shared::wrap(x)takes a uniquely-owned value and always succeeds. The source binding is consumed — reading it after the wrap is a compile-time error (E0501).Shared::try_unwrap(s)returns the unique value if you held the sole strong reference,nullotherwise — and consumesseither way: the call releases your reference, and readingsafterwards is a compile-time error (E0501). On thenulloutcome the surviving holders keep the object alive and free it at their own scope exits. The strong-count check is atomic, so the outcome is correct under concurrency.
Both transitions preserve the object’s identity bit-for-bit — no constructor re-runs, no fields are reinitialised. They are metadata moves, not deep copies.
Both transitions are fully implemented — the recovery’s scope-exit cleanup frees the recovered value exactly once, with no double-free. See Refcount and transitions for the header copy and atomic ordering.
Lifecycle patterns
The everyday single-owner-plus-aliases patterns work: allocate, alias, pass around, transfer through a return, and let the scope-exit decrements free the object exactly once.
Single-binding lifecycle
class Cell {
pub n : i32;
constructor(v : i32) {
self.n = v;
}
}
fn main() : i32 {
{
let c = new shared Cell(42);
println(c.n);
} // dec → free
{
let c : Shared<Cell> = new shared Cell(7);
println(c.n);
} // dec → free
return 0;
} Allocation, scope-exit drop, destructor + free — the whole pipeline fires correctly.
Transfer through return
class Cell {
pub n : i32;
constructor(v : i32) {
self.n = v;
}
}
fn make() : Shared<Cell> {
return new shared Cell(42); // refcount stays at 1
}
fn main() : i32 {
let c = make(); // caller inherits the count
return c.n - 42;
} // dec → free Aliasing
class Cell {
pub n : i32;
constructor(v : i32) {
self.n = v;
}
}
fn main() : i32 {
let p = new shared Cell(42);
let q = p; // alias — refcount rises to 2
let total : i32 = p.n + q.n; // both reads see 42
{
let e = p; // third alias — refcount 3
println(e.n);
} // inner dec → back to 2
println(p.n + q.n);
return total - 84;
} // two decs → 0 → free, exactly once A second binding to a Shared<T> bumps the refcount; each
binding’s scope-exit decrement brings it back down, and only the
last drop frees the object. Aliasing within a scope, passing the
handle as a parameter, and rebinding in a loop all keep the count
balanced.
Cycles, and Weak<T>
A reference cycle is two (or more) objects that each hold a strong reference to the other:
┌─────────┐ next (strong) ┌─────────┐
│ Node a │ ────────────────► │ Node b │
│ strong=1│ ◄──────────────── │ strong=1│
└─────────┘ back (strong) └─────────┘ Neither strong count can ever reach 0: a keeps b alive and b keeps a alive, even after every outside owner drops. So a
pure-strong cycle leaks its backing storage. Axle does not
detect cycles automatically — strong refcounting fundamentally
cannot.
The fix is a Weak<T> reference: a non-owning observer that
does not keep the payload alive. Make one edge of the cycle
weak, and the count on that side no longer props the object up, so
the strong owners can reach 0 and free it. You mint a weak
reference from a strong one and recover a usable handle when the
payload is still alive:
Shared::downgrade(s)→ aWeak<T>. Non-owning; it bumps only the weak count, never the strong count.Weak::upgrade(w)→Shared<T> | null. Returns a fresh strong reference if the payload is still alive, ornullonce the last strong owner has dropped it.
class Cell {
pub n : i32;
constructor(v : i32) {
self.n = v;
}
}
fn main() : i32 {
let a = new shared Cell(42);
let w = Shared::downgrade(a); // Weak<Cell> — does not keep a alive
let p = Weak::upgrade(w); // Shared<Cell> | null
if (p != null) { // a still alive → non-null
return p.n - 42; // → 0
}
return 1;
} The weak count keeps only the 16-byte header block alive — so an upgrade can safely observe “gone” and return null — while the
payload itself is reclaimed the moment the strong owners drop.
Weak edges and nullable strong links in fields
Breaking a real cycle means storing the weak edge as a field
(parent : Weak<Node>), and building the cycle in the first place
means a nullable strong link (next : Shared<Node> | null). Both shapes
are first-class: a Weak<T> field, parameter, or return type, and a Shared<T> | null / Weak<T> | null field, all name the same handle the
allocator and downgrade produce.
Store the weak edge in a field and recover a strong handle on demand — the weak slot does not keep the payload alive:
class Cell {
pub n : i64;
constructor(v : i64) { self.n = v; }
}
// A back-reference that does NOT keep the Cell alive.
class Observer {
pub watch : Weak<Cell>;
constructor(c : Shared<Cell>) {
self.watch = Shared::downgrade(c); // store a weak edge in a field
}
pub fn read(self) : i64 {
let c = Weak::upgrade(self.watch); // recover a strong handle if alive
if (c != null) { return c.n; }
return -1; // Cell already freed
}
}
fn main() : i32 {
let cell = new shared Cell(42);
let obs = new Observer(cell);
return obs.read(); // → 42, cell still alive
} The forward strong link is a nullable Shared<T> field, which accepts a new shared value or null:
class Node {
pub v : i64;
pub next : Shared<Node> | null;
constructor(val : i64) { self.v = val; self.next = null; }
}
fn main() : i32 {
let a = new shared Node(7);
let head = new Node(1);
head.next = a; // nullable strong link accepts a Shared<Node>
let n = head.next;
if (n != null) { return n.v; } // → 7
return 0;
} Model a cyclic or parent/child graph with a nullable Shared<T> field on
the owning edge and a Weak<T> field on the back-edge, so the strong
counts can still reach zero and free the graph.
Performance notes
- The count goes atomic only when the object can cross a
thread. When the compiler proves a
Shared<T>class is thread-local, inc/dec are a plainload/add/store. When an instance can reach another thread — throughspawnor an FFI boundary — every inc/dec becomes alock-prefixed atomic, and the cache line holding the count is invalidated across cores. This is decided per class, automatically; see Refcount and transitions. Even the plain form is a read-modify-write per touch, so for a single-owner value that never needs sharing the default tiers are still leaner. - Header overhead is 16 B per object. For very small payloads (a few bytes) that’s a real overhead ratio. Bundle small objects into a single shared container instead of refcounting each one.
- The destructor + free path is branch-predicted false on
every dec except the last. The drop block only enters when
the runtime’s
i32return signals “you just hit zero”, so in steady state you pay one cmp + one branch in addition to the atomic op.
If you find yourself reaching for Shared<T> to avoid thinking
about ownership, stop and check: does the value actually outlive
its caller? Does it really cross thread boundaries? If not, the
default escape analysis is faster and just as correct.
See also
- Memory model — the four storage tiers and how the compiler picks between them.
- Language tour — the ownership prefixes on parameters that pair with this page.
- Reading compiler errors — E0501–E0508 cover
the move/use diagnostics that interact with
Shared<T>bindings. - Multithreading — the
primary user of
Shared<T>across thread boundaries. - Refcount and transitions — the 16-byte header, atomic ordering, and the
Shared::wrap/try_unwraplowering. - Concept index — every shared-memory concept cross-linked.