Fixed buffer
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
SystemAllocator | Arena | FixedBuffer | |
|---|---|---|---|
| Memory comes from | the operating system | blocks from a backing allocator | storage you pass in |
| When it runs out | the system refuses | it takes a bigger block | OutOfMemory |
Deallocate of one block | gives it back | rewinds only the most recent | rewinds only the most recent |
| Everything at once | — | Reset, and the destructor | Reset |
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// 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
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.
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.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
- Make
storage100 bytes, and passsizeof(byte[100])as the capacity. How many points fit, and how many bytes are left over? - Before the loop, allocate one
int32withLayout::ForValue<int32>(). How many points fit now, and where did the rest of the bytes go? - Move the loop into
func CountPoints(allocator: Allocator) -> int64and call it with the fixed buffer's handle. The function never learns which allocator it was given.