sysl

The ring module

sysl.container.ring — Ring[T], a queue of a fixed capacity over storage the caller supplies: no allocator, a full ring that answers rather than grows, and overwrite for a window over an unbounded stream.

Every declaration in sysl.container.ring, with its signature: the generated API page. This page is the argument — what the module is for, and how its pieces fit; that one is the list.

Ring[T] is a queue cheap to take from at either end, laid over a slice the caller hands it. It is a module of its own beside sysl.container rather than a sixth type inside it, because it is the one container in the library that declares @no_alloc, and a capability is a property of the whole module.

import sysl.container.ring.ring

var slots: [4]int = [0; 4]
var r = ring(slots[..])

print(r.capacity(), r.len(), r.is_empty())

r.push_back(1)
r.push_back(2)
r.push_front(0)

print(r.len(), r.first(), r.last())
print(r.pop_front(), r.pop_front())
print(r.pop_front(), r.pop_front())
4 0 true
3 Some(0) Some(2)
Some(0) Some(1)
Some(2) None
ring(slots)an empty ring over storage the caller supplies
r.capacity()how many values it can hold, which is the length of that slice
r.len(), r.is_empty(), r.is_full()how many are in it, and the two questions about the ends of that
r.push_back(v), r.push_front(v)add at either end; false where the ring is full
r.pop_front(), r.pop_back()take from either end, None where it is empty
r.first(), r.last(), r.get(i)look without taking, as an Option
r[i], r.at(i), r.set(i, v)the ith value from the front, panicking past the end
r.overwrite(v)add at the back, dropping the oldest to make room, and answer what was dropped
r.clear(), r.walk()forget every value; walk from the front to the back

The reason it exists beside Deque is the storage, not the shape

A Deque[T] is this same ring with a growth path under it: a head index, both ends wrapping, and a Buf underneath that doubles when the elements no longer fit. So it owns its elements, and owning them means being able to widen them, and widening them means a heap.

This one cannot grow. Its capacity is the length of the slice it was handed, a full ring answers rather than doubles, and @no_alloc on the file is the whole of the claim — which is what makes it reachable from a program that has given the allocator up:

@no_alloc
@no_os

import sysl.container.ring.ring

var slots: [3]int = [0; 3]
var r = ring(slots[..])

r.push_back(1)
r.push_back(2)

print(r.len(), r.first(), r.last())
2 Some(1) Some(2)

The Deque written the same way is refused, and it is refused at the push_back rather than at the import:

@no_alloc

import sysl.container.{Deque, deque}

var work: Deque[int] = deque()

work.push_back(1)

print(work.len())
which makes heap storage, and this module declared '@no_alloc'

That is not a defect in Deque and no version of it could avoid it — growing is the whole of what a Deque is for, and the same refusal falls on Buf for the same reason. What it means is that a board, an interrupt handler and a signal handler — the three places a bounded queue is wanted most — had no queue in this library at all until this module, and now have one whose bound is a number the caller chose.

A full ring is a state, not a failure

push_back and push_front answer a bool, where Deque‘s push_back answers nothing at all. The asymmetry is the point: a growing queue cannot be full, so there is nothing for it to report; a bounded one being full is the state it exists to have.

import sysl.container.ring.ring

var slots: [2]int = [0; 2]
var r = ring(slots[..])

print(r.push_back(1), r.push_back(2))
print(r.is_full(), r.push_back(3))
print(r.len(), r.last())
true true
true false
2 Some(2)

So a refused push is an ordinary answer a caller reads, and is_full() is what it asks beforehand where it would rather not build the value at all.

overwrite is what makes it a ring rather than a bounded queue

Everything above is a queue with a ceiling. overwrite is the operation that turns the ceiling into a window: it adds at the back, drops the oldest value where there is no room, and answers whatever it dropped.

import sysl.container.ring.ring

var slots: [3]int = [0; 3]
var r = ring(slots[..])

print(r.overwrite(1), r.overwrite(2), r.overwrite(3))
print(r.overwrite(4), r.overwrite(5))
print(r[0], r[1], r[2])
None None None
Some(1) Some(2)
3 4 5

A caller cannot compose this out of the other members, which is why it is a member. The obvious spelling — pop the front, then push the back — is wrong exactly when the ring is not full, where it would throw away a value nothing was competing with. What it is for is the last n of something unbounded: log lines, samples, a scrollback, a moving average. There the newest value is the one that matters and the oldest is what a bounded queue would have refused the newest for.

A ring with no storage at all drops what it was given rather than treating it as an edge case: there is no slot for the value, so overwrite evicts it at once and answers Some(v).

The subscript counts from the front, whatever the ring is doing underneath

r[i] is the ith value from the front, not the ith slot, and the wrapping is invisible to a caller — which is the order a for walks a ring in as well, through the cursor its walk() answers.

import sysl.container.ring.ring

var slots: [3]int = [0; 3]
var r = ring(slots[..])

r.push_back(1)
r.push_back(2)
r.push_back(3)
r.pop_front()
r.pop_front()
r.push_back(4)
r.push_back(5)

for v in r
    print(v)
3
4
5

That run has wrapped: 3 is in the last slot of the storage and 4 and 5 are in the first two. r[0] is still 3. Subscripting reaches the bounds-checked members rather than the storage, so it cannot read a slot the ring is not currently using — the slot exists, the element does not.

The length is not rounded up to a power of two, unlike Deque‘s, and that is a consequence of the capacity being the caller’s number rather than one this module chose: a program that asked for six slots gets six. So wrapping is a compare and a subtract instead of a mask.

A cursor holds the storage rather than the ring, so pushing while one is live leaves it reading the run it started on.

The storage is a pointer, and that is the crossing rule

The slots are held as a *T and a count rather than as a []T, and it is not a preference. A struct holding a view may not reach another concurrency domain at all, because a view owns its elements through a count that is not atomic — so a ring that kept the slice would be refused at the spawn that shares it. A raw pointer carries no count, so it crosses; what it costs is the same trust *p already asks for, and the caller writes a slice at ring(…) and never sees the pointer.

That is why Channel[T] can hold one. A channel is a bounded queue two threads hand values across, so it is a ring by construction and was four fields and its own wrapping arithmetic before this module existed; what is left in that file now is the lock and the closed flag. A Ring[T] written over a view would have made the channel unshareable one line before the first send, which is the only thing a channel is for.

The storage has to outlive the ring

This is the one thing the type asks of a caller that Deque does not, and it follows from the same sentence: what a Ring[T] stores is an address and a length, so the slice behind that address has to still be there. A module-level static var, a local of a scope that outlives every holder, or a region something else owns — all fine; a slice over a local of a function that has returned is not.

It is the same contract channel(slots[..]) already has, and the same one every C binding in the organisation makes when it hands a library a buffer to work in.

Search

Esc
↑↓ to navigate ↵ to open Esc to close