regions
α1regions
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 arenaregion :: expr means "allocate the result of expr in that region." the compiler resolves which region at the call site.
region kinds
| constructor | description |
|---|---|
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 exitsdisposal:
- 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 oncearenas 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 everythingescaping 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 closedregion.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.
| operation | source after | requirement |
|---|---|---|
view.copy(src, to:) | remains valid | element type is copyable |
view.move(src, to:) | invalidated | element type has valid move policy |
shared regions
memory in region.shared() may be accessed from multiple threads. mutation requires:
atomicseffect- atomic types or synchronization primitives
const shared = region.shared();
// use with atomic operations onlynon-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:
| api | behavior |
|---|---|
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 initializedthis 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