A build with no collector
zeno build --no-collector compiles a Zeno program to C that has no
garbage collector in it. Not a collector that is switched off: the code is
not in the file.
cargo run -- build --no-collector --memory examples/buffer.zeno
./buffer
sent 44400 samples, skipped 13 quiet frames
refused 33 frames, the first on channel 84
[3, 3, 3, 3]
4
memory: 1691 blocks made (2646299 bytes), at most 13336 bytes in use at once, no collector, 0 blocks left at the end
pool: 25384 bytes taken, in a pool of 32768
text: at most 53 bytes in use at once, in a room of 1024
The program made 1691 blocks and gave every one of them back, at a line
you can find in buffer.c. Open that file and search for zeno_collect,
zeno_roots or setjmp. None is there. Nor is malloc: the blocks come
from a pool of a fixed size that is part of the program
(pool.md).
It is a firmware build (firmware.md) with one more
promise, so --no-collector turns --firmware on by itself.
Why
The collector (memory.md) is the right default. It lets you
write let names = text.split(",") and never think about it again. It
also has three habits that firmware cannot always live with:
- It pauses the program, for a time that depends on how much memory is in use. A control loop with a deadline cannot plan for that.
- It reads the C stack to find out what is still in use. That works on every mainstream compiler, and no C standard describes it.
- Nobody can say when a block is freed, so nobody can say how much memory the program needs in the worst case.
Owned values (ownership.md) answer all three for the
values that are owned. This build is what you get when every value is.
The files
src/emit_c.rs, the parts headed "With no collector to help" and "What a statement owns":hold,text_begin,text_end,stays,belongs. The refusals are where each kind of value is made: search forwithout_collector.src/runtime.c: the parts between#ifdef ZENO_NO_COLLECTORand#endif. As withZENO_FIRMWARE, the compiler keeps one side when it pastes the runtime in (runtimeinemit_c.rs).src/checker.rs:Rules { one_owner }, which makes every list and object owned for this build (one-owner.md), and three small sets,fresh,madeandlent, which tell the compiler which lists start life owned and which owned values are only looked at.
Where memory comes from now
With no collector, memory that is taken must be given back by a line of the program. There are exactly five ways to get memory in this build, and each has such a line.
| What | Where it lives | When it is given back |
|---|---|---|
| An owned list or object | the pool, a fixed array | when its owner's block ends |
| A list or object that is made and given to nobody | the pool | when the statement that made it is over |
| Text built while the program runs | the text room, a fixed array | when the statement that built it is over |
| Text that is kept | the pool | when the variable, field or list that keeps it is done with it |
| An error that was raised | the pool | when the except block that caught it ends |
Numbers, bools, quantities, enum cases without data and text written in
the program ("idle") need no memory at all, here as in every build.
Anything else that would take memory is refused when you build, with the line that does it:
error: this list has no owner. Give it one, as in `let items: owned list[int] = [1, 2, 3]`. `zeno build --no-collector` leaves the collector out, so nothing would free it. docs/no-collector.md lists what such a build accepts
--> buffer.zeno:58:26
|
58 | var refused: list[int] = []
| ^
That is the whole idea of the build. It does not find leaks while the program runs. It refuses to build a program that could have one.
Owned lists and objects
In this build every list and every object is owned, by whoever received
it. let xs = [1, 2] owns its list and class Task is an owned class,
with no word to write. one-owner.md is the walkthrough
of that, and of lists that own their items.
Nothing new happens when one is freed, and that is the point. Stage 2 already wrote a
free at the end of every owner's block and on every early way out. What
changes is what the free is. With a collector, zeno_free takes the
block out of the collector's table and hands it to the C library's
free. Here there is no table and no C library heap. zeno_alloc takes
a block from the pool, one array whose size is fixed when the C is
compiled, and zeno_free puts it on a list of spare blocks of its size:
block->head.asked = 0;
block->next = zeno_pool_spare[block->head.step];
zeno_pool_spare[block->head.step] = block;
pool.md is the walkthrough of the pool: how it is laid out,
why it cannot break up into unusable pieces, what it costs, and how to
choose its size.
Text: one statement, one fixed room
Firmware prints. print(f"sent {sent} samples") has to work, and the text
it builds has to live somewhere for a moment.
It lives in the text room: one array, zeno_text_room, whose size is
fixed when the C is compiled. zeno_text_top says how much of it is in
use. Building text writes at the top and moves the top up. The compiler
notes the top before a statement that builds text, and puts it back after:
size_t m2 = zeno_text_top;
zeno_print(1, zeno_join(5, "sent ", zeno_str_int(z_sent), " samples, skipped ", zeno_str_int(z_quiet), " quiet frames"));
zeno_text_top = m2;
Giving text back is one assignment. There is nothing to search and nothing to fragment.
A function called in the middle of a statement does the same for its own statements, above the caller's half-built text. So the room is used like a stack, and its size is the most text that can be in the making at one moment. The report's second line tells you what that was. Compile the C by hand to choose the size:
cc -O2 -DZENO_TEXT_ROOM=256 -o buffer buffer.c -lm
If a statement needs more than the room has, the program stops and says so. That is a fault, like an index past the end of a list.
Text that outlives its statement is copied. The room is emptied when the statement ends, so text that is kept cannot stay there:
print(f"channel {channel}") # built, printed, given back
if command.strip().upper() == "ON" { } # the same
let line = f"channel {channel}" # `line` keeps a copy, in the pool
Whatever keeps text (a variable, a field, a list) has a copy of its
own and frees it when it is done. Text is never changed once made, so
a copy cannot be told from the original, and there is no rule to
learn. text.md is the walkthrough: where the copies are
made and freed, how a function gives text back, and what it costs.
(Until that stage, keeping built text was refused.)
The compiler tracks which text is in the room with one flag on each
compiled value (fixed on Val): a literal and a variable that
cannot change are fixed, and everything else is in the room and is
copied to be kept.
An if or while whose condition builds text takes its answer first,
gives the text back, and then runs its body. A for gives it back
before the first trip, and a match as soon as a case is chosen.
Errors
raise Overload(channel, peak) makes an object, and an error to carry it.
Neither has an owner in the usual sense: the error leaves the function
that made it. So this build gives it one rule of its own. The except
block that catches an error frees it when the block ends, however the
block is left:
if (zeno_is(zeno_caught_1, "Overload")) {
z_Overload *z_overload = (z_Overload *)zeno_caught_1->object;
zeno_list_append(z_refused, (zeno_slot){.i = z_overload->z_channel});
zeno_error_drop(zeno_caught_1);
}
Inside the block the error can be read, printed, asked for its fields
and lent to a borrow parameter. It cannot be kept, given away or
raised again. Copy out what you need. The other half of the rule: an error can only be made by raise, as
in raise ValueError("too hot"). let e = ValueError("too hot") is
refused, because no except would ever free it.
The error's message is often built text (raise ValueError(f"{n} is too
big")), which would be gone before the except runs. So in this build
the error keeps its own copy of the message, in the same block as itself.
An error that nobody catches stops the program. There is nothing to free after that.
A value between two owners
Four times, a value belongs to nobody for a moment. With a collector that
does not matter. Here each needs an answer, and all four get the same
one: the compiler holds the value (hold in emit_c.rs), which means
that any early way out of the block frees it, just as it frees what the
block's owners hold.
- A list being filled.
[check(n) for n in range(size)]: ifcheckfails on the fourth item, the first three are in a list that has no name yet.
zeno_list *t24 = zeno_list_new(0);
for (int64_t z2_n = 0; z2_n < t25; z2_n++) {
int64_t t26;
if (!z_check(z2_n, &t26)) { zeno_list_drop(t24); return false; }
zeno_list_append(t24, (zeno_slot){.i = t26});
}
-
An owned value on its way into a call. In
join(a, b, check(n)),aandbhave left their variables by the timecheckruns. If it fails, the failure frees them. -
The copy a
forloop walks (decision 3.10a). Areturnor a failed call inside the loop frees it on the way out. -
A value that is made and given to nobody, as in
print(count(line.split())). It belongs to the statement, which frees it when it is over, and a failure half way through the statement frees it too.temporaries.mdis the walkthrough.
The first and third were small leaks to the collector in stages 1 and 2. They are now fixed in every build.
What is refused
Each of these makes memory that no line gives back. Each error names the line and says what to write instead where there is something.
| Refused | Why |
|---|---|
A list or object used after it was given away (let b = a, then a) |
it has one owner (one-owner.md) |
A list or object inside an or none |
taking it out would leave a hole |
| An error that is kept, given away, or raised again | its except frees it |
An error made anywhere but in raise |
no except would free it |
| An enum case that carries values | it sits in a block of its own |
| A function written where a value goes | so does it, with the variables it uses |
A list of int or none |
each item sits in a block of its own |
Six things you might expect on this list are not. A plain list or
object is fine: whoever receives it owns it. A list of objects or of
lists is fine: the list owns its items
(one-owner.md). A named function
used as a value (items.reduce(add, 0)) is fine: it takes no memory.
Text that is kept is fine: the keeper has a copy
(text.md). A list that is made and only looked at is fine,
whatever makes it (x in [1, 2], count(line.split()),
len(a + b)): the statement frees it. And a list that a for loop
walks is fine (for pin in [2, 3, 5], for word in line.split(),
for ch in text): the loop frees it. Both are in
temporaries.md.
Most of these are provisional. An enum value and a function value could be owned by whoever receives them, as lists and objects now are. Those are the next steps.
What this does not claim
- Memory is still taken and given back while the program runs. It
comes from a fixed pool and not from the heap, so MISRA C:2012 Rule
21.3 (no
malloc,calloc,reallocorfree) is met. Directive 4.12 is wider: it is against dynamic memory of any kind, and a pool is still that.pool.md, "What this does not claim", says what the pool does and does not answer. - It does not bound memory for you. A loop that appends to an owned
list for ever fills the pool, and the program stops there.
--memorytells you what a run needed. - The text room is a fixed size that you choose. Too small, and the program stops at the statement that overflowed it.
- The rest of the list in
firmware.mdstands:printfandexit, variable argument lists in the runtime, the GCC builtins for checked arithmetic, and the forwardgoto.
The same, through LLVM
cargo run -- build --llvm --no-collector --memory examples/buffer.zeno
zeno build --llvm --no-collector is this design written in LLVM IR.
An owner is a slot that is set to null when it gives its value away
and dropped where its block ends. The text room's top is a global the
IR loads before a statement that builds text and stores back after it.
An except drops the error it caught. The runtime file beside the IR
is the one from this page, and what is refused is refused in the same
words. llvm.md, "Owned values" and "With no collector",
walks through the IR.
The two builds do not only print the same. They make the same number of blocks, hold the same number of bytes at most, and fill the text room to the same height, and the tests require it.
How it is tested
Every test program for this build (no_collector_as_interpreter in
emit_c.rs) is checked four ways:
- It prints exactly what
zeno runprints. - Its memory report ends with 0 blocks left at the end. With no collector, a block that no line frees is never freed, so this number is the test.
- The C file does not contain
zeno_collect,zeno_rootsorsetjmp, normalloc,calloc,reallocor the library'sfree. - The same program still agrees with the interpreter in the two builds that do have a collector.
- Built through LLVM with no collector, it prints the same, and its memory report is the same as the C build's, line for line.
The programs aim at the hard places: an error raised inside a loop over a
list, inside another except, half way through filling a list, and while
owned values are on their way into a call; return, break and
continue out of except blocks; and two thousand trips round a loop
that builds text, which must leave the text room as it found it. A table
of 14 small programs checks that each refusal says what it should, and
that each of them still builds where there is a collector.
They were also run by hand with gcc and clang at several optimisation
levels, and, while memory still came from the heap, under
AddressSanitizer with its leak check on. pool.md lists the
tests of the pool itself.
Things to try
- Build
examples/buffer.zenowith--memory, then with--no-collector --memory. The second makes fewer blocks. Which ones are gone? (Look at where the text went.) - In
buffer.zeno, take the wordownedoffrefusedand build with--no-collector. Nothing changes: in this build the list has one owner either way. Then try keeping an error:var last: Overload or none = noneandlast = overloadin theexcept. Read the error. - Compile
buffer.cby hand with-DZENO_TEXT_ROOM=32. Which statement stops the program? What is the smallest room it runs in, and does the report agree? let line = f"plain text"copies the text into the pool, though nothing in it is built. Find whereformat_stringmakes its value, and when every part is a literal make it the literal itself. How does the report for such a program change?- This exercise used to be "replace
callocandfreewith a pool". That is done (pool.md), so here is what is left of it: print, fromzeno_memory_report, how many blocks of each size were ever cut from the pool. Which sizes doesbuffer.zenoask for, and which size holds most of the pool? print(42)builds the text"42"and then prints it. Give the runtime azeno_put_intthat prints a number with no text room, and make the compiler use it whenprinthas one argument.look([1, 2, 3])used to be refused, wherelooktakes aborrow list[int]. It builds now: find the line in the C that frees the list, then makelookfail (give it araises) and find the second line that does.temporaries.mdsays how both got there.raise einsideexcept ... as eis refused. It could be a move: the error goes back tozeno_raisedand the block no longer frees it. What would the checker have to know?
This page is docs/no-collector.md in the language repository.