Skip to content

Memory Management

Termina has no general-purpose heap. The language has no malloc or free and performs no implicit allocation, so a program never depends on an allocator whose timing cannot be bounded. When an application does need to allocate and release storage at run time, it does so through memory pools, whose blocks are reserved when the application is built and whose allocation and release take a bounded time. This chapter describes pools, the owned blocks that come from them, and the atomic arrays that provide lock-free shared storage.

Memory pools

A pool is a resource that manages a fixed number of preallocated blocks, all of the same type. It is declared in the application module with the built-in Pool type, the type of the blocks, and how many of them the pool contains:

resource sample_pool : Pool<Sample; 8>;

This pool holds eight blocks, each large enough for one Sample. The blocks exist for the lifetime of the program; allocating one hands it out for use, and releasing it returns it to the pool. Because the number of blocks is fixed and reserved in advance, the memory the application uses is known when it is built; an allocation fails only when all the blocks of the pool are in use.

Allocating from a pool

An entity reaches a pool through an access port of type Allocator<T>. Since the pool may be exhausted, the alloc operation does not return a block directly but sets an Option, which holds the block when one was available and is empty otherwise. The caller must therefore handle both cases:

action tick(&priv self, _current_time : TimeVal) -> Status<i32> {
    var status : Status<i32> = Success;
    var opt : Option<box Sample> = None;
    self->pool_port.alloc(&mut opt);
    match opt {
        case Some(b) => {
            b.value = 7;
            self->out_port.send(b);
        }
        case None => {
            status = Failure(-1);
        }
    }
    return status;
}
Status__i32 CCollector__tick(const termina__event_t * const termina__ev,
                             void * const termina__this,
                             const TimeVal termina__ignored__current_time) {

    (void)termina__ignored__current_time;

    CCollector * self = (CCollector *)termina__this;

    Status__i32 status = { ._variant = Status__Success };

    Option__box opt = { ._variant = Option__None };

    self->pool_port.alloc(termina__ev, self->pool_port._that, &opt);

    if (opt._variant == Option__Some) {

        const termina__box_t b = opt.Some._0;

        (*(Sample *)b.data).value = 7U;

        termina__out_port__send(termina__ev, self->out_port, (const void *)&b);

    } else {

        status._variant = Status__Failure;
        status.Failure._0 = -(1L);

    }

    return status;

}

When the allocation succeeds, the block is delivered as a value of type box Sample. The fields of the block are reached directly through the box, as in b.value = 7, without any explicit dereferencing.

Owned blocks

A box T is an owned handle to a block of memory taken from a pool. Ownership in Termina is linear: a box must be consumed exactly once. It can be released back to its pool, or it can be handed to another component, but it cannot be copied, and it cannot simply go out of scope while still holding a block. The transpiler tracks each box through the body that holds it and rejects any path that would leave a block neither released nor passed on, or that would use a block after it has already been given away. Consequently, the memory managed by a pool is never leaked and never used after it has been freed, and both properties are checked when the program is transpiled.

The life of a block is summarized by the following diagram: it leaves the pool through alloc, is owned by exactly one component at a time, may change hands through a channel, and returns to the pool through free.

The life of a box
The life of a box: allocated from a pool, transferred through a channel, and freed

A block is consumed in one of two ways. The first is to release it explicitly, returning it to the pool with free:

action receive(&priv self, sample : box Sample) -> Status<i32> {
    let status : Status<i32> = Success;
    self->pool_port.free(sample);
    return status;
}
Status__i32 CSink__receive(const termina__event_t * const termina__ev,
                           void * const termina__this,
                           const termina__box_t sample) {

    CSink * self = (CSink *)termina__this;

    const Status__i32 status = { ._variant = Status__Success };

    self->pool_port.free(termina__ev, self->pool_port._that, sample);

    return status;

}

The second is to transfer it. Sending a box through an output port, as the allocating action did with self->out_port.send(b), passes ownership of the block to whoever receives the message. The sender no longer owns the block after the send, and the transpiler will reject any later use of it. On the receiving side, the block arrives as the box argument of the triggered action, and the receiver becomes responsible for consuming it in turn, as the receive action above does by freeing it. Only the handle travels in the message; the block itself is not copied.

Atomic variables and arrays

Pools provide owned, exclusive blocks. For data that several entities must read and update concurrently, Termina offers a different mechanism, one that takes no lock: the atomic variable, and its array form.

An atomic variable holds a single value that is read and written as one indivisible operation. It is declared as a resource of the built-in Atomic type, with its element type and initial value:

resource flag : Atomic<u32> = {
    value = 0
};

An entity reaches it through an access port of type AtomicAccess<T>, and reads and writes it with load and store:

action tick(&priv self, _current_time : TimeVal) -> Status<i32> {
    let status : Status<i32> = Success;
    var v : u32 = 0;
    self->flag_port.load(&mut v);
    self->flag_port.store(v + 1);
    return status;
}
Status__i32 CMonitor__tick(const termina__event_t * const termina__ev,
                           void * const termina__this,
                           const TimeVal termina__ignored__current_time) {

    (void)termina__ev;
    (void)termina__ignored__current_time;

    CMonitor * self = (CMonitor *)termina__this;

    const Status__i32 status = { ._variant = Status__Success };

    uint32_t v = 0U;

    v = atomic_load(self->flag_port);

    atomic_store(self->flag_port, v + 1U);

    return status;

}

An atomic array applies the same idea to a fixed number of elements. It is declared with the AtomicArray type, its element type, its size, and its initial values, and it is reached through an access port of type AtomicArrayAccess<T; N>, whose load_index and store_index operations read and write one element at a time:

resource bank : AtomicArray<u32; 4> = {
    values = [0; 4]
};
self->bank_port.load_index(0, &mut v);
self->bank_port.store_index(1, v + 1);
v = atomic_load(&self->bank_port[0U]);

atomic_store(&self->bank_port[1U], v + 1U);

Each access, whether to a variable or to one element of an array, compiles to a single atomic load or store, with no lock taken. Atomics are therefore suited to small, independent values that are shared across entities and read or written one at a time, where the cost and the blocking of a mutex would be unwarranted. Unlike an ordinary resource, whose procedures run under mutual exclusion, an atomic offers no way to keep several values consistent with one another across a sequence of operations; its guarantee is confined to the atomicity of each individual access.