Memory · Lesson 15.12

Fixed buffer

Source
Allocate from storage you already own with FixedBuffer, which refuses with OutOfMemory once the storage is full.
You'll need: Allocator, Arena, Outcome, Loop

Every allocator so far could, in the end, ask the operating system for more. Sometimes that is exactly what you must not do: on a small device with no system allocator at all, in code that has to run within a fixed budget, or when one part of a program must never be able to use up memory the rest needs. A FixedBuffer is the allocator for those places. It hands out memory you already have, and when that runs out, it says no.

Storage you already own

Here the storage is a 64-byte array on the stack. The buffer is built over its first byte and its size:

var storage: byte[64];
var buffer = FixedBuffer(@storage[0] as *var opaque, 64);
var handle = buffer.Handle();
let allocator: Allocator = handle;

Like an arena, the buffer hands that memory out piece by piece from a moving marker, and is used through a Handle() that implements Allocator. Unlike an arena, it never takes a new block from anyone.

The storage belongs to you, not to the buffer. It must outlive the buffer and everything handed out of it — here all three live in Main, so that holds.

Asking until it says no

A Point is two int64s, 16 bytes, so four fit in 64 bytes. The loop keeps asking and stops at the first refusal:

match allocator.Allocate(layout) {
    .Success(block) => {
        made += 1;
        let point = block as *var Point;
        *point = Point { x: made, y: made * made };
        PrintLine("point {} at ({}, {}), {} bytes left",
            made, point.x, point.y, buffer.BytesRemaining());
    },
    .Failure(error) => {
        let full = error == AllocError::OutOfMemory;
        PrintLine("point {} refused, out of memory: {}", made + 1, full);
        break;
    }
}

This time the failure is not passed on with ?. It is the expected way out of the loop, so a match takes it apart directly, as in Outcome. Code written against Allocator cannot tell a fixed buffer from any other allocator — it just sees a refusal sooner.

Reset

As with an arena, Reset makes the whole buffer available again, and invalidates every address it gave out before:

buffer.Reset();

The next request then starts at the front of storage again, which the last line of the output confirms.

Three allocators side by side

SystemAllocatorArenaFixedBuffer
Memory comes fromthe operating systemblocks from a backing allocatorstorage you pass in
When it runs outthe system refusesit takes a bigger blockOutOfMemory
Deallocate of one blockgives it backrewinds only the most recentrewinds only the most recent
Everything at once—Reset, and the destructorReset

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// A `FixedBuffer` is an allocator over memory you already have: here, a 64-byte array on the
// stack. It hands that memory out piece by piece, like an arena, but it never asks anyone for
// more. When the buffer is full, `Allocate` fails with `AllocError::OutOfMemory`.
//
// That makes it the allocator for places where real allocation is not allowed or not
// available, and a way to put a hard limit on how much some piece of code may use. Code
// written against `Allocator` does not know the difference; it just sees a refusal sooner.
//
// The storage belongs to you, not to the buffer, so it must outlive the buffer and everything
// handed out of it. As with an arena, `Reset` makes the whole buffer available again and
// invalidates every address it gave out before.
import Allocator::{ AllocError, Allocator, FixedBuffer, Layout };
import Io::PrintLine;

struct Point {
    x: int64;
    y: int64;
}

func Main() -> ! AllocError {
    var storage: byte[64];
    var buffer = FixedBuffer(@storage[0] as *var opaque, 64);
    var handle = buffer.Handle();
    let allocator: Allocator = handle;

    // Each point needs 16 bytes, so four fit.
    let layout = Layout::ForValue<Point>();
    var made: int64 = 0;
    loop {
        match allocator.Allocate(layout) {
            .Success(block) => {
                made += 1;
                let point = block as *var Point;
                *point = Point { x: made, y: made * made };
                PrintLine("point {} at ({}, {}), {} bytes left",
                    made, point.x, point.y, buffer.BytesRemaining());
            },
            .Failure(error) => {
                let full = error == AllocError::OutOfMemory;
                PrintLine("point {} refused, out of memory: {}", made + 1, full);
                break;
            }
        }
    }

    // After a reset the next request starts again at the front of `storage`.
    buffer.Reset();
    PrintLine("after reset, {} bytes left", buffer.BytesRemaining());
    let again = allocator.Allocate(layout)?;
    PrintLine("starts at storage[0]: {}", (again as uint) == (@storage[0] as uint));
}

Besides Io, its Rux.toml lists Allocator under [Dependencies].

Run it

cd Examples/Memory/FixedBuffer
rux run
point 1 at (1, 1), 48 bytes left
point 2 at (2, 4), 32 bytes left
point 3 at (3, 9), 16 bytes left
point 4 at (4, 16), 0 bytes left
point 5 refused, out of memory: true
after reset, 64 bytes left
starts at storage[0]: true

Common mistakes

Storage that dies before the buffer.
If the array lives in a function that returns while the buffer, or anything allocated from it, is still in use, every address points into a stack frame that no longer exists. Nothing reports it. Declare the storage where it outlives everything that uses it.
A capacity that does not match the storage.
The buffer believes the number you give it. FixedBuffer(@storage[0] as *var opaque, 128) over a 64-byte array compiles, and happily hands out the 64 bytes that come after it. Use sizeof of the storage rather than typing the number twice.
Expecting every byte of BytesRemaining to be usable.
Each request is aligned first. With 63 bytes remaining after a one-byte request, a 63-byte request at alignment 8 is still refused with OutOfMemory: aligning it would push it past the end.

Try it yourself

  1. Make storage 100 bytes, and pass sizeof(byte[100]) as the capacity. How many points fit, and how many bytes are left over?
  2. Before the loop, allocate one int32 with Layout::ForValue<int32>(). How many points fit now, and where did the rest of the bytes go?
  3. Move the loop into func CountPoints(allocator: Allocator) -> int64 and call it with the fixed buffer's handle. The function never learns which allocator it was given.

Learn more

  • Arena — the same marker-based approach, with room to grow
  • Outcome — matching .Success and .Failure directly
  • Allocator — AllocError and what each case means