GuideThe Silo HandbookLifecycle: Modes, Dispose, and Memory

Chapter 16 Lifecycle: Modes, Dispose, and Memory

Source

Silo has no garbage collector. Memory is managed by a combination of region allocation (heap values live in a region that frees as a unit when the region goes away) and Perceus reference counting (per-value RC, elided and in-placed by the compiler wherever possible). The programmer writes ordinary code; the runtime handles the bookkeeping.

Alongside the allocator there are two opt-in disciplines that let the type system enforce stricter lifetime invariants: locality modes and :dispose. This chapter covers all three.

Modes (locality)

A mode is a tag on a type that restricts where its values can go. Modes are declared in parentheses after the keyword that introduces the type, next to visibility:

:record(pub local)  ReadGuard t  ...  :end
:record(pub unique) Transaction k     :end
:record(pub linear) CriticalResource  :end

Four modes, in order of increasing strictness:

Mode Escape scope? Aliasable? Implicit drop?
global yes yes yes
local no yes yes
unique no no yes
linear no no no

global is the default — ordinary values that behave the way you'd expect, with the compiler managing RC.

local

A local value cannot escape the scope it was created in. It can't be returned, can't be stored in a longer-lived container, can't cross a thread boundary via .spawn. A textbook case is an RAII guard that must not outlive its region of acquisition:

:record(pub local) ReadGuard t
  .lock (RwLock t)
:end

:fn use-guard ( (RwLock Int) -> Int ) +Concurrent
  .read-lock pop-> guard         # guard is local
  guard .get                      # OK — used on the stack
:end

Trying to :ret a ReadGuard, store it in a longer-lived record, or pass it to .spawn is a compile error. Because the mode is known at the type level, the compiler catches every escape route.

unique

unique adds "no aliasing" to local's constraints. You can't peek-> a unique value (which would create an alias); you can only pop-> it, which consumes it. There is no implicit duplication — any shared use has to be an explicit pattern match or consumption.

:record(pub unique) Transaction k :end

:fn use-tx ( (Transaction k) -> ) +Database
  pop-> tx
  tx "SELECT ..." .query pop-> tx pop-> cursor
  cursor .fetch drop
  tx .commit
:end

tx peek-> tx2 would be a compile error here — Transaction is unique and can't be aliased.

linear

linear adds "must be consumed" on top of unique. A linear value is not allowed to be dropped implicitly; the compiler checks every control-flow path and requires an explicit :dispose method to finish it off:

:record(pub linear) CriticalResource :end

:impl CriticalResource
:dispose
  .release ( Self -> ) +IO ... ;
  .abort   ( Self -> ) +IO ... ;
:end

No _ default in the :dispose block means the linear discipline has no implicit exit — the compiler refuses to let a CriticalResource leave scope without a .release or .abort call on every path.

:fn process ( CriticalResource -> ) +IO
  .use .release                   # explicit consumption — OK
:end

Forgetting .release (or .abort) is a compile error.

:dispose

:dispose blocks live inside an :impl and declare how a value can be consumed at the end of its scope. There are two kinds of :dispose method:

  • Default_ ( Self -> ) body ;. Called by the compiler when a value reaches end-of-scope without being explicitly disposed. At most one default per :dispose block.
  • Named.method-name ( Self -> ) body ;. Ordinary methods you can call to consume the value explicitly.
:impl ReadGuard
  .get ( Self -> Self t ) ... ;
:dispose
  _ ( Self -> ) unlock ;         # default: unlock on scope exit
  .release ( Self -> ) unlock ;  # explicit alternative
:end

For ReadGuard, either dropping it at scope end or calling .release unlocks — both go through the same underlying unlock word.

For a Transaction, there is no default; every exit has to be named:

:impl Transaction
  .query ( Self Str -> Self (Cursor k t) ) +Database ... ;
:dispose
  .commit   ( Self -> ) +Database ... ;
  .rollback ( Self -> ) +Database ... ;
:end

The lack of a default (_) pairs naturally with the linear mode: the type says "must be disposed", and the :dispose block says "here are the ways to do it".

Memory management

Underneath modes and dispose, ordinary values are managed by a combination of regions and Perceus.

A region is a chunk of memory allocated and freed as a unit. Each function call gets its own region; when the function returns, the region is freed wholesale. Faster than per-value free, and immune to use-after-free on values that stay within the region.

Perceus is the reference-counting discipline. Most heap values carry an RC, but the compiler optimises it aggressively:

  • Uniqueness fast path: when the compiler can prove RC=1 at a mutation site, the mutation happens in place. A .push on an unshared Vec does not allocate — it extends the existing allocation.
42 my-vec .push              # mutates in-place when my-vec is unshared
  • Copy-on-write when shared: when RC>1, the same mutation allocates a fresh value so the shared version is preserved:
my-vec peek-> shared         # RC becomes 2
42 shared .push              # allocates a fresh Vec; my-vec unchanged
  • No user-facing dup/drop. Silo has stack-shuffler words dup/drop/swap/over/rot/nip (and their /2 variants, chapter 2); what it does not have is a user-facing way to manipulate reference counts. The compiler inserts the RC operations where they're needed, elides them where it can prove they're unnecessary, and the programmer never sees them.

The practical upshot: functional-looking code runs with imperative-looking performance most of the time. If the compiler can see your Vec isn't shared, .push mutates; if it can't, .push copies. Either way the semantics are pure-functional.

When to reach for modes

Most code is global. The compiler handles everything — you're writing ordinary functional-looking code.

Reach for local when a value genuinely must not escape (RAII guards, scoped capabilities, read-only borrows that shouldn't outlive the lock).

Reach for unique when sharing would break an invariant (file descriptors, database transactions, anything where two aliases could step on each other).

Reach for linear when forgetting to clean up is a bug that shouldn't be caught at runtime (resources with explicit commit/rollback, protocols with a mandated close step).

The type system carries the discipline; there is no runtime tax.

Key points

  • Four locality modes: global (default), local (can't escape), unique (can't alias), linear (must consume explicitly).
  • :dispose blocks list the exit paths for a value. _ ( Self -> ) body ; is the implicit-drop default; named methods are explicit consumers.
  • Linear types have no default _ — the compiler requires an explicit named consumer on every control-flow path.
  • Memory is region-allocated; per-value RC is Perceus-style with in-place mutation when RC=1 and copy-on-write when shared.
  • No user-facing dup/drop on references. Stack shufflers exist for primitive reordering; RC is the compiler's job.

Next: concurrency — tasks, threads, sync primitives, and how the modes from this chapter enforce thread safety.