The runtime’s state
slate’s interpreter keeps its state in a Vm struct, and current() answers the one the running line
of execution belongs to. This page says why, what is in it, what is deliberately not, and how to move
the next file.
The struct is now whole and it owns its heap. Every module-level var that was per-VM state has
moved into Vm, the arena moved in with it, and what is still process-wide is marked as such and
stays where it is. A second VM runs a program of its own in the same process today, which is what
A_SECOND_VM_RUNS_A_PROGRAM_OF_ITS_OWN_IN_THE_SAME_PROCESS in tests_vm_state.sysl says. It is here
rather than under reference/ because what it is for is the actors design.
Why
A module-level var is per-process storage. The runtime had 473 of them across fifty files, which is
one interpreter to a process by construction: two of them would have written over each other’s
machine, heap roots, event loop, compiler tables and output. That forecloses two things at once —
- an actor on its own OS thread, which the actors chapter describes as one
Machine, one heap, one libuv loop and one collector per thread; - an embeddable slate, a VM a sysl program holds as a value, which nobody has today because there is nothing to hold.
Gathering the state into a struct a thread owns is what makes both possible. It is also, on its own, the piece of work that has to happen first, whichever of the two comes second.
The struct and the accessor
dev/slatelang/slate/vm_state.sysl holds struct Vm, new_vm() and current().
val v = current()
v.output.push(line)
current() answers a *Vm, so a caller writes through it. A function reaching one subsystem many
times takes a pointer to that subsystem once, which is why converting the instruction loop changed one
line and not a hundred and sixteen:
run_frames(u: *Unit, ...) -> Step
val vm = machine() // ¤t().machine
Every vm.stack, vm.frames, vm.scope below it is unchanged. machine() in execute.sysl and
running_unit() in vm.sysl are the two accessors of that shape; add one where a subsystem earns it.
the_vm in vm_state.sysl is the one module-level var that remains by design, and it is a
*Vm rather than a Vm: the thing current() answers is a pointer somebody can move. It becomes a
thread-local the day sysl has one, and because every other file goes through current(), that change
is a single declaration.
Running a second VM
new_vm(ceiling), enter, leave, dispose_vm — and that is the whole of the surface.
var second = new_vm(4194304)
val was = enter(&second)
val said = out("print(6 * 7)")
leave(was)
dispose_vm(&second)
new_vm(ceiling)mallocs a block ofceilingbytes and lays the VM’sgc.Heapover it.gc.heap(base, cap)has always taken the block it works over, so a second VM is a secondbaseand nothing else.mallocrather thancalloc, becausegc.alloczeroes each payload itself and untouched pages should stay uncommitted — which is the bargain the 256 MiB BSS array it replaced was making. The program’s own VM asks forHeapBytes, so nothing about the default changed.dispose_vmruns every outstanding finalizer and frees the block. The finalizers touch no VM state — each gives back a syslstring, aBufor aMapits object held — so a VM that is not the current one can be disposed safely.heap_ceilingis the block andheap_limitis the setting.set_heap_limitclamps to the VM’s own ceiling, so a VM spawned with a megabyte runs out of its megabyte with the ordinary “this program has run out of memory” and the process’s own VM never hears about it.payloadis what this VM’s values hold OUTSIDE that block, and both the collection schedule and the ceiling read cells plus payload rather than cells alone. A string’s bytes, an array’sBufand a buffer’s storage are all reference-counted memory the collector never sees, so a heap measured in cells said almost nothing about what a program was holding.charge/creditinobj.syslkeep the count between collections and the mark phase recomputes it exactly at each one, which is what bounds a missed charge to a stale schedule.payload_cappedsays whetherheap_limitbounds it: true for a ceiling somebody named, false for the program’s own VM, which takesHeapBytesbecause nothing said otherwise.
the_vm IS @thread_local, so the pointer is per thread and two VMs run at once. Every thread
reads its own copy, which is what makes current() answer the VM the line of execution belongs to
rather than the one the process last entered. enter/leave swap this thread’s copy and are
otherwise untouched: the previous VM travels back through the caller rather than through a stack
here, so nesting on one thread works exactly as it did, and every enter still owes a leave on
every way out — which is why the runtime itself never calls these and a test or an embedder does.
The initializer is null and current() reads that as the program’s own VM. A thread-local’s
copies are stamped from one image in the object file rather than by code that runs per thread, so
the value has to be one the compiler can write down and the address of a module var is not one it
takes. null says the right thing anyway: a thread that has entered nothing belongs to the VM the
process runs its program on.
A THREAD SETS UP ONE THING BESIDE ITS VM, AND IT IS A LOOP. libuv’s default loop belongs to the process and may be turned by one thread at a time, so a VM that is going to run on a thread of its own is built over a loop of its own:
val lp = libuv.new_loop().expect("a loop of this thread's own")
var mine = new_vm(4194304, lp)
val was = enter(&mine)
val said = out("setTimeout(() -> print(\"hello\"), 1)")
leave(was)
dispose_vm(&mine)
lp.close().expect("the thread's loop closes")
Vm.uv_loop is the field, new_vm‘s second parameter defaults to libuv.default_loop() so the
program’s own VM keeps the loop every handle in the process was already on, and event_loop() in
event.sysl is the accessor every site that starts a handle or turns the loop reads — timers, the
drain, TCP, pipes, signals and a spawned process. What still reaches default_loop() directly is
sh.sysl.libuv‘s own synchronous file-system calls and the two calls whose loop parameter sits
behind other defaults, resolve and queue; a thread doing file, DNS or thread-pool work owes that
routing before it can be trusted.
Everything else in the tree learns nothing, because it already goes through current().
What a module-level var is now
Four things, and tests_vm_state.sysl counts each of them:
| class | what it is | how it is recognised | count |
|---|---|---|---|
| the VM | the_vm in vm_state.sysl, a *Vm | by name | 1 |
| B, a native’s id | var n_<name>: NativeFn = 0 | by shape | 367 |
| B/C, process-wide | marked process-wide: in the comment above it | by the marker | 13 |
| A, still owed | anything else | by elimination | 0 |
A new global is none of the first three, so it lands in the fourth and fails the census until its file’s number is raised — which is a decision somebody makes rather than an accident. The fourth is zero now, and stays zero unless a file grows one back.
Class B — process-wide, and why each one qualifies
- The 367
n_<name>: NativeFnids.register(name, go)is append-only and answers the first id for a name it already holds, so an id never moves once handed out. Two VMs in one process would register the same names and agree on every id.nativesandnative_idsinnative_fn.sysl, andnamed_natives/named_natives_builtinnatives.sysl, are the same fact: the table those ids index is immutable in the only sense that matters, which is that an existing entry never changes. - The eight
gc.Kindtables inobj.sysl. Each is a set of function addresses built at startup and never written again; aKind‘s address must not move, and a per-VM copy would hold the same eight function pointers.
Class C — empty, and the arena is why
There is no class C any more. It held exactly two things — storage, a 256 MiB BSS array in
obj.sysl, and slate_heap, the one gc.Heap over it — and both are gone: region, heap_ceiling
and heap are Vm fields, and slate_heap() is a *gc.Heap accessor of the machine() shape, so
every allocation site reads slate_heap() where it read &slate_heap and nothing else moved.
What the arena was doing in module storage was giving the collector’s root function an address,
and that is still the requirement: roots is a top-level function because a hook is reached by
address. It simply reaches the heap through current() now, so one function serves any number of
heaps — gc.collect hands it the heap it is collecting, which is current().heap because that is
what the caller asked for.
The one thing that replaced them is main_vm, the storage for the program’s own VM, marked
process-wide for the plain reason that a process has one and it has to outlive everything, including
a main that has returned into a libuv callback. A second VM is an ordinary local somebody owns.
It is @main_thread, and every thread’s copy of the_vm starts out pointing at it: only the
program’s own thread reads through that starting value, every actor thread entering a VM of its own
first, and a debug build traps a touch of it from any other thread.
Class A — none owed, and the two shapes the 49 turned out to be
Every file that owed a move – async.sysl, generator.sysl, runtime.sysl, check.sysl,
window.sysl, process.sysl, lmdb.sysl, nghttp2.sysl, sqlite.sysl, signals.sysl,
channel.sysl, spawn.sysl, define.sysl, regex.sysl, ast.sysl, gzip.sysl, argon2.sysl,
client.sysl, combine.sysl, http_parse.sysl, packages.sysl, pattern.sysl, redis_parse.sysl,
shape.sysl and tls.sysl – has made its move. tests_vm_state.sysl‘s StateOwed is empty and
StateOwedTotal is 0.
gzip.sysl‘s two needed a decision rather than a plain move, and it was decided this way: the
compressor’s miniz.DEFLATE_BYTES and the decompressor’s miniz.INFLATE_BYTES (each a sizeof plus
its alignment, around 168 KB and 8 KB) are each a heap-allocated Buf[u8] (current().deflate_state,
current().inflate_state), not an inline array on Vm – a struct field that size would be copied
every time a Vm is, and new_vm() allocates each once instead. Passed to
miniz.deflate/miniz.inflate as .view(), which is the slice the binding actually wants.
buf_with_capacity(n, fill) was the wrong tool here and cost a red gate finding it out. Its own
doc comment says why: “a buffer that has already been given room for n elements” is CAPACITY, not
LENGTH – count starts at zero, matching Vec::with_capacity‘s rule rather than a fill. miniz’s
deflate/inflate check storage.len < N, so a capacity-only buffer answers NoRoom on the very
first call: .len() is 0 regardless of how large n was. zeroed_scratch(n) in vm_state.sysl
pushes n zero bytes one at a time instead, which is the one place buf_with_capacity‘s name invites
exactly the mistake it does not do.
Two things cost a rebuild each and are worth knowing before the next VM field:
- A type a new
Vmfield names must be at least as visible as the field.Vmis public, so a field of typeBuf[Slot]orBuf[Option[&Argon2Job]]forcesSlot/Argon2Job(and anything they name, transitively –Slot.arrived: &ArriveddraggedArrived, andArriveddraggedHeldandPiece) out ofprivate. The compiler catches every one of these by name – “a declaration may not be more visible than the types it names” – so it costs a rebuild rather than a silent gap, but expect to walk a small tree of types outward from the field you are adding. - Type names are module-wide even where the declaration is
private. Two files each had their ownprivate struct Slot(http_parse.sysl,shape.sysl) and a third had one too (tls.sysl‘s table, then renamed): fine as long as both stay private, but the first one a field drags public collides with the compiler’s “type ‘Slot’ is already declared” the moment a second file’sSlotneeds to travel the same way.shape.sysl‘s becameShapeSlotandtls.sysl‘s table field becametls_slotsfor this reason – rename on the way out rather than after the collision.
A field behind a feature is gated in Vm exactly as its file is. window.sysl (webview),
lmdb.sysl (lmdb), nghttp2.sysl (http2) and redis_parse.sysl (redis) each declare types
that do not exist without their feature, so the corresponding Vm fields, the matching slice of
new_vm()‘s constructor call, and any import those types need are each wrapped in the same
#if feature_x / #endif the source file uses – #if gates a struct’s field list and a
constructor’s argument list exactly as it gates any other run of lines. Built and checked under both
sysl build . (all features) and sysl build . --no-default-features.
How to move a file
- Take the
varout of its file. Leave a sentence in its place saying where it went —xis nowcurrent().x— because the prose beside a declaration is usually the best thing about it. - Put it in
Vmunder the group that names its subsystem, with the same comment. - Add it to
new_vm(), in field order: the constructor is positional, so a field added in the middle of the struct goes in the middle of the call or the compiler says which type stopped matching. - Replace each read and write with
current().x, or takeval v = current()at the top of a function that touches several. Where a subsystem is reached many times, a*Taccessor beside it —machine(),running_unit()— keeps the sites unchanged. - Lower the file’s number in
tests_vm_state.syslby what you moved, andStateOwedTotalwith it.
Two things the compiler will not tell you:
- A local may shadow an accessor.
val machine = park()ingenerator.syslhad to be renamed beforemachine()could be called in the same function. Pick names that do not collide, and check before adding an accessor rather than after. unitis a type in sysl, which is why the unit accessor isrunning_unit()and notunit()— the refusal is “a ‘unit’ conversion takes exactly one value”, which does not read like a name collision.