ferrule

regions

α1
region-kindsplacement-operatordisposalescape-analysisfinalizersdevice-region (α2)shared-region (α2)

regions

regions are the foundational memory model in ferrule. every value lives somewhere: local variables live on the stack, heap allocations live in a region. the compiler tracks which region a value belongs to and ensures it doesn't escape.

why regions

traditional memory management has two extremes:

  • manual (c): free each allocation individually, easy to mess up
  • gc (go, java): automatic but unpredictable, can't control when memory is freed

regions are the middle ground. allocate into a region, dispose the whole region when done. everything inside is freed at once. deterministic, fast, safe.

stack is implicit

local variables live on the stack. no annotation needed:

function process() -> i32 {
    const x: i32 = 42;       // stack
    const buf: Array<u8, 64> = [0; 64];  // stack
    return x;
}

conceptually the stack is a region. the compiler uses this for escape analysis. a view into a stack variable can't outlive the function that owns it.

placement operator

the :: operator places a value into a region:

const arena = region.arena(4096);
defer arena.dispose();

const parser = arena :: Parser { buf: input };  // allocate in arena
const tree = arena :: Vec.new<Node>();           // collection in arena

region :: expr means "allocate the result of expr in that region." the compiler resolves which region at the call site.

region kinds

constructordescription
region.arena(bytes)bump allocator, no individual frees, bulk dispose
region.heap()general-purpose, individual frees possible
region.device(id)device memory (gpu, dma)
region.shared()multi-thread access, requires atomics

each call creates a new independent region. there's no global singleton heap.

creation and disposal

const arena = region.arena(1 << 20);  // 1mb
defer arena.dispose();

const buf: View<mut u8> = arena.alloc<u8>(4096);
// use buf...
// arena disposed when scope exits

disposal:

  • frees all allocations in the region
  • runs finalizers in lifo order
  • returns Unit, never fails
  • errors during finalization are logged but don't propagate

disposing a region invalidates all views bound to it. further access traps.

arenas

arenas are bump allocators. allocation is fast (just increment a pointer), but you can't free individual allocations:

const arena = region.arena(1024 * 1024);  // 1mb
defer arena.dispose();

const a = arena.alloc<u8>(100);   // fast
const b = arena.alloc<u8>(200);   // fast
// can't free a or b individually
// dispose frees everything at once

arenas are great for:

  • parsing (allocate ast nodes, dispose when done)
  • request handling (allocate everything, dispose when request finishes)
  • game frames (allocate per-frame data, reset each frame)

heap regions

heap regions allow individual frees but are slower:

const heap = region.heap();
defer heap.dispose();

const ptr = heap.alloc<u8>(100);
// can use heap.free(ptr) for individual frees
// or let dispose clean everything

escaping data

values can't escape their region. returning a view into a disposed region is a compile error:

function bad() -> View<u8> {
    const arena = region.arena(1024);
    defer arena.dispose();

    const buf = arena.alloc<u8>(100);
    return buf;  // error: buf escapes its region
}

to return data, copy or clone it to a longer-lived region:

function process(out: Region) -> View<u8> effects [alloc] {
    const arena = region.arena(1024);
    defer arena.dispose();

    const buf = arena.alloc<u8>(100);
    // ... work with buf ...
    return view.copy(buf, to: out);  // ok: copied to caller's region
}

or clone to an implicit output region:

return out :: tree.result.clone();

region-aware collections

collections are allocated into a region using the placement operator:

const heap = region.heap();
defer heap.dispose();

const items = heap :: Vec.new<Item>();
const lookup = arena :: Map.new<String, i32>();

the collection's internal storage lives in the specified region and is freed when the region is disposed.

resource management via regions

unique resources (file handles, sockets, etc.) register cleanup with their region. there is no separate resource type kind. resources are values that use finalizers:

const heap = region.heap();
defer heap.dispose();

const file = heap :: fs.open(path);
// file registers a finalizer with heap via region.on_dispose(...)
// when heap is disposed, the file handle is closed

region.on_dispose(fn) registers a finalizer. finalizers run lifo when the region is disposed. finalization cannot throw.

transfer between regions

moving data between regions is explicit:

const dst = region.heap();
const moved: View<mut u8> = view.move(src, to: dst);

moving invalidates the source view. using it after transfer is a compile error.

operationsource afterrequirement
view.copy(src, to:)remains validelement type is copyable
view.move(src, to:)invalidatedelement type has valid move policy

shared regions

memory in region.shared() may be accessed from multiple threads. mutation requires:

  • atomics effect
  • atomic types or synchronization primitives
const shared = region.shared();
// use with atomic operations only

non-atomic concurrent mutation is undefined behavior.

alloc effect

invoking allocators requires the alloc effect:

function grow_buffer(r: Region, want: usize) -> View<mut u8> effects [alloc] {
    return r.alloc_zeroed<u8>(want);
}

this makes allocation visible in function signatures.

uninitialized memory

ferrule forbids reading uninitialized memory. allocation apis define zero-init policy:

apibehavior
alloc_zeroed<T>(...)returns zeroed memory
alloc_uninit<T>(...)returns View<Uninit<T>>

uninitialized views must be fully initialized before use:

const un: View<Uninit<u32>> = region.heap().alloc_uninit<u32>(4);
// initialize all elements...
const init: View<u32> = view.assume_init(un);  // only legal when fully initialized

this prevents undefined behavior from reading garbage.

device regions

region.device(id) exposes device memory:

const gpu = region.device(gpu_id);
const gpu_buf = gpu.alloc<f32>(1024);

// host access may be illegal without mapping
device.copy_to_host(gpu_buf, host_buf);
host.copy_to_device(host_buf, gpu_buf);

transfers can fail and return errors.

example: request-scoped allocation

function handleRequest(req: Request, cap io: Io) -> Response
    error RequestError
    effects [alloc, io]
{
    const arena = region.arena(1 << 20);  // 1mb for this request
    defer arena.dispose();

    const body = check parseBody(req, arena);
    const result = check process(body, arena);
    const response = check serialize(result, arena);

    // copy response to caller's region before returning
    return response.clone();
}
// arena disposed here, all request memory freed

On this page