Mica Technical Report 3 - first edition, October 2026. States the compiler as of release 7.7.2.
Abstract
A Mica program can run a piece of work in three places. A task runs beside the code that started it, on a thread the runtime calls a carrier. A generator runs inside the loop that consumes it, one value at a time. A task placed on a process runs in a separate operating-system process, which can crash, run out of memory or be killed without taking the program down. The three share one vocabulary and one structure, and this report explains both, for readers who know what a thread and a stack are but have not built a scheduler.
The design rests on four ideas.
- Work has an owner, and the owner waits. Every task belongs to the activation that started it, and that activation does not end until its tasks have ended. The shape of the running work is the shape of the source text: no task outlives the block that started it, so no task can reach memory that has gone away.
- Partition, don’t share. A task receives copies of its arguments.
State that two tasks could both touch is visible to the compiler, which
refuses the program unless every touch stands inside a
synchronizedstatement. A task on a process shares nothing at all, and the compiler refuses whatever would need sharing. - Waiting is a suspension, never a blocked thread. A task that waits for input, for a timer or for another task gives its carrier to other work. Because every wait is a suspension the runtime knows about, every wait can be cancelled, and a deadline is just a cancellation the clock sends.
- One meaning, the cheapest machinery that keeps it. A generator has one meaning and two implementations: a small record in its consumer’s frame for the bodies whose state fits one, and a stack of its own for the rest. The compiler chooses, reports the choice, and the two cannot be told apart by any program.
The report states the model, takes the words one at a time, follows each mechanism down to the runtime, tabulates what is guaranteed and how, and measures what the guarantees cost.
1. Introduction
Most languages offer concurrency as a set of separate tools that were added
at different times: threads, then locks, then thread pools, then futures,
then async functions, then iterators that look like functions but are not.
Each tool has its own rules about lifetime and failure, and the rules do not
compose. A thread can outlive the function that started it, a future can be
dropped while its work continues, and an exception in a worker vanishes
unless someone remembered to look for it.
Mica takes the opposite route. It has one structure for all running work - the scope tree - and three placements within it:
| Placement | Spelled | Runs | Waits by |
|---|---|---|---|
| a task on a carrier | schedule Work(x); inside concurrent ... end | beside its parent, interleaved or in parallel | suspending; its carrier runs other tasks |
| a generator | generator G(...) : stream of T; with emit | inside its consumer’s loop, one value per pull | returning a value to the loop |
| a task on a process | schedule Work(x) on process; | in a separate operating-system process | its stand-in task suspending on a pipe |
The scope tree makes three promises that hold for every placement.
- Nothing outlives its block. A
concurrentblock ends only when every task it started has ended. A generator instance ends with the loop or the variable that holds it. A worker process ends when its task does, and the runtime ends it if the program does. - Failure travels up. The first task of a block that fails cancels its
siblings, and the block’s
on failsection - or the activation around it - receives the failure. A worker process that dies is reported as lost to anon lostsection the compiler insists on. - Cancellation travels down. A block whose owner leaves, fails or runs out of time cancels its tasks, and every wait inside them ends at once.
None of this needs a garbage collector, a runtime type system or a hidden thread pool. The tasks are ordinary compiled code on ordinary stacks; the scheduler is a few thousand lines of C; and the rules that keep tasks apart are checked by the compiler before the program runs.
The rest of the report is organised the way TR-1 is. Section 2 introduces the words. Sections 3 to 6 take the placements in turn: tasks, generators, the streams that tasks feed, and processes. Section 7 states the guarantees in one table, section 8 measures the costs, and section 9 places the design among its neighbours.
2. The words, one at a time
2.1 Activations and scopes
An activation is one running call of a routine, together with the storage that call owns: its parameters, its local variables, the temporaries the compiler needs. TR-1 explains activations in detail; here it matters that an activation has a beginning and an end, and that the end is a point the compiler knows.
A scope is the part of an activation that owns work. When an activation starts its first task, it gets a scope head: a few words in its own frame that count its live tasks and remember the first failure among them. The scope head is what a task belongs to. Because it lives in the activation’s frame, it cannot outlive the activation, and because the activation does not end before its tasks do, no task can outlive the scope head it reports to.
2.2 Tasks
A task is a call that runs beside its caller instead of before it.
schedule Worker(1) evaluates the arguments at once, copies them into the
new task, and returns without waiting; the task’s body runs later, while the
activation that scheduled it carries on. A task has a stack of its own,
so it can call routines, recurse and wait anywhere in its body, exactly as
an ordinary call can. Tasks are uncoloured: a routine does not have to be
marked to be callable from a task, and calling a routine that waits does not
change the caller’s type.
Every schedule of a task stands inside a concurrent block, and the
block’s end is where the scheduling activation waits for all the tasks the
block started. This is the whole lifetime rule: a task lives from its
schedule to its block’s end, never longer. (A stream’s producers, which
section 5 describes, belong to their stream instead, and end with it.)
2.3 Carriers and the scheduler
A carrier is an operating-system thread that runs tasks. A program built
for one carrier - the default - runs every task on its main thread, one at a
time, switching between them at the points the compiler planted. A program
built with --tasking multicore runs a pool of carriers, one per
processor unless told otherwise, and its tasks run in parallel.
The scheduler decides which task a carrier runs next. It keeps the tasks that can run in ready lists - one first-in, first-out list on one carrier, one per carrier in a pool - and a carrier with an empty list takes half of another carrier’s. A task leaves the ready list when it suspends and returns to it when the thing it waits for happens.
2.4 Suspension points, parking and cancellation
A task gives up its carrier only at a suspension point, a place in the
compiled code where the runtime is called and may switch to another task.
The compiler plants them: at a Yield, at a wait for a child, at every call
that waits for input or output, and - in a program that declares any task or
generator - at the back edge of every loop, once every 1,024 rounds, so even
a task that computes for a long time without waiting can be interrupted. A
purely sequential program gets none of these polls, and its loops cost what
they cost before tasks existed.
Parking is how a task waits for a file descriptor - a pipe, a socket, a terminal, a timer. Instead of blocking its carrier in a system call, a task whose descriptor is not ready registers the descriptor with the scheduler and suspends; the carrier runs other tasks; when the descriptor becomes ready, the task returns to a ready list and its call tries again.
Cancellation is a request that a task stop. It is a flag on the task and
on its scope, and it is noticed at the next suspension point: a parked call
returns at once, failing with the code Ecanceled, and the compiled code behind
every suspension point checks the flag and takes the task’s ordinary way out,
running its defer statements as it goes. A deadline is a cancellation
sent by a clock: ScopeDeadline(ms) asks the runtime to cancel the current
activation’s scope when the time runs out.
2.5 Generators, emits and pulls
A generator is a routine whose result is a sequence of values rather than
one value. Its body hands a value to its consumer with emit and then
waits; its consumer asks for the next value with a pull, which is what
every round of a for ... in loop over the generator does. The values are
produced one at a time, as they are needed, and the body’s own end is the
end of the sequence.
One running copy of a generator’s body is an instance. An instance is
created by a for ... in loop over a generator call, or by binding the call
to a variable of a stream type, s := Numbers(5), which makes the
instance a value the program can pull from in several places.
2.6 Streams, rings, producers and consumers
A generator’s values can also be produced ahead of the consumer.
s := schedule Numbers(5) buffer 8 runs the generator’s body as a task, the
producer, whose emits land in a ring - a fixed array of eight slots
used round and round - from which the consumer, the loop over s, takes
them. More producers can feed the same ring (schedule More(3) into s), and
more loops can read it. A producer that finds the ring full and a consumer
that finds it empty suspend, so the faster side waits for the slower one:
this is back pressure, and it is what keeps a fast producer from filling
memory.
2.7 Processes, servers, workers and proxies
A task placed with schedule Work(x) on process runs in a worker, a
separate operating-system process. A worker is not started from the program
directly: the program forks one quiet copy of itself, the process server,
before any of its tasks runs, and the server forks a worker for each such
task. A second quiet copy, the standby, waits beside the server and takes
its place if the server is lost.
In the program, each task on a process is represented by a proxy, an
ordinary task that hands the worker its arguments, waits for its outcome, and
reports that outcome to the scope as the task’s own. When a worker ends before
reporting - a crash, a kill, the machine running out of memory - the proxy
reports the task as lost, and the block’s on lost section says what the
program does then.
2.8 Shared state and synchronized
Two tasks share a variable when both can reach it: a variable of the
program or of an enclosing activation that a task’s body reads or writes.
The compiler finds every such reach. A program that touches shared state from
inside a concurrent block must put the touch inside a synchronized
statement, which takes a lock for the duration of the statement; a program
that does not is refused with error 5337. A task on a process may not reach
shared state at all, because its writes would land in the worker’s copy.
2.9 The whole picture
| Word | What it is | Lives in |
|---|---|---|
| activation | one running call and its storage | a stack frame |
| scope head | an activation’s count of live tasks and its first failure | its activation’s frame |
| task | a call running beside its caller, on a stack of its own | a guard-paged stack the scheduler maps |
| carrier | an operating-system thread that runs tasks | the process |
| suspension point | a place the compiled code may switch tasks | the code |
| generator instance | one running copy of a generator body | a record in its consumer’s frame, or a stack of its own |
| ring | the slots between a stream’s producers and its consumers | the heap |
| process server, standby | quiet copies of the program that fork workers | separate processes |
| worker | the process a task placed on a process runs in | a separate process |
| proxy | the task that stands in for a task on a process | a stack in the program |
3. Tasks
3.1 The task tree
concurrent
schedule Worker(1, 1);
schedule Worker(2, 1);
WriteLn("parent: both workers are ready, neither has run");
end;
WriteLn("parent: past the end, so both have finished");A concurrent block is a statement whose end waits. Each schedule inside
it creates a task, places it on a ready list and returns; the statements
between the schedules run on in the parent; and at end the parent
suspends until the block’s last task has ended. The tasks
tutorial shows the interleaving this produces on one carrier.
The wait at end is called the join, and it is the reason the scope tree
is a tree. A task that schedules tasks of its own owns a scope of its own,
and joins its children before it ends. So the running tasks always form a
tree whose shape is the nesting of the source text, and every node of the
tree is an activation that is still alive. Two consequences follow directly.
- A task never reaches dead memory through its parent. A task can be given access to its parent’s frame (section 3.3), and the parent’s frame outlives the task by construction.
- The program’s end is a join too. The program’s own body is task zero.
It cannot end while a task it started is running, because its blocks joined
them; there is nothing left to stop when
mainreturns.
A routine can also leave a concurrent block early - with leave, with a
failure, or because its own scope was cancelled. Leaving early does not skip
the join. The compiled exit path cancels the block’s tasks and then joins
them, so the guarantee holds on every way out, and the tasks that were
abandoned run their own defer statements before the parent continues.
3.2 What a task receives
cell := 10;
concurrent
schedule Snapshot(cell);
cell := 20;
schedule Snapshot(cell);
cell := 99;
end;The first task prints 10 and the second 20, whatever the order in which they
run: a schedule evaluates its arguments at once and copies them into the
task, exactly as a call copies its arguments into the callee. This is the
value model of TR-1 applied
to tasks. A record, an array or a text passed to a task is the task’s own
copy; the parent may change or release the original the moment the
schedule returns.
Copying is what makes a task independent, and it is cheap for the values tasks are usually given. A task that needs a large structure can be given a pointer to it instead, which section 3.3 treats as sharing.
The values a task’s own calls return live in a result region of the task’s own (TR-1, section 7.4). A walk over a returned array keeps its array there while it runs, and a task may wait in the middle of such a walk while other tasks run; because every task, and every stackful generator instance, keeps its own region and its own marks, on one carrier as on many, no task’s release can give back a value another task is still reading.
3.3 Sharing, and the statement that permits it
A task’s body is ordinary code, and ordinary code can name variables that are not its own: the program’s variables, and through the static link of a nested routine, the variables of an enclosing activation. When such a variable is reachable from a task and from its parent, or from two tasks, it is shared, and an unprotected write to it is a data race.
The compiler computes, for every concurrent block, the set of variables its
tasks can reach, following calls through the whole program. A program in
which any task, or the parent while the block runs, touches one of them
outside a synchronized statement is refused:
synchronized total := total + partial;On a multicore build, synchronized takes a reader-writer lock that lives in
the scope head of every activation whose variables the statement touches,
and it takes them from the outermost activation inward, which is one total
order for the whole program and therefore cannot deadlock. A statement that
only reads takes the locks for reading. On one carrier the statement compiles
to nothing at all - one carrier runs one task at a time and switches only at
suspension points, and the compiler refuses a suspension point inside a
synchronized statement - but the program still has to say it, because the
same source must be correct when it is built for many carriers. The data
races tutorial shows the refusal and the repair.
The rule is deliberately simple to state: what two tasks could both touch, the source says it touches under a lock. There is no shared mutable state that the compiler has not seen.
3.4 Failure
A task’s body may fail - it declares fails with an error domain, and some
call inside it raises one of the domain’s errors. A failure does not unwind
into the parent’s code at an unpredictable moment. Instead:
- The failing task ends, running its
deferstatements. - Its scope head latches the failure. Only the first failure of a block is kept; later ones are discarded, because the first one already decided what happens next.
- The block is cancelled: every other task in it is asked to stop, and each stops at its next suspension point.
- The parent reaches
end, joins, and only then sees the failure. If the block has anon failsection for the failure’s domain, that section runs; otherwise the failure leaves the parent’s activation through its own failure channel, as if the parent had failed itself.
concurrent
schedule Fetch(first);
schedule Fetch(second);
on fail reason do
WriteLn("one fetch failed, and the other was stopped");
end;The order matters. The failure is delivered after the join, so the code in the section never runs beside a task that is still working, and it can read and repair state the tasks used without any lock. This is the first failure rule, and it is the same rule Trio, Kotlin and Swift adopted for structured concurrency: a block’s outcome is known when the block ends, and not before.
3.5 Cancellation and deadlines
Cancellation is cooperative and prompt. It is cooperative because a task is never stopped between two arbitrary instructions - that would leave locks held and invariants broken - but only at a suspension point, where the compiled code expects to be resumed or to leave. It is prompt because every wait is a suspension point and every loop polls, so a cancelled task stops at the next wait it starts, the wait it is in, or within 1,024 rounds of the loop it is computing in.
When a parked call notices cancellation it returns at once, failing with the
code Ecanceled; the compiled code behind the call sees the task’s
cancellation flag and takes the task’s ordinary way out, running its defer
statements. TaskCancelled() asks the flag
directly, for code that wants to stop at a point of its own choosing.
A deadline is cancellation by the clock:
concurrent
ScopeDeadline(500);
schedule Download(url);
end;
if ScopeTimedOut() then
WriteLn("the download took longer than half a second");ScopeDeadline arms a one-shot timer of the operating system’s monotonic
clock and registers it with the scheduler beside the descriptors that parked
tasks wait for. When the timer fires, the runtime cancels the scope, which
ends every wait inside it, and records two facts: that the owner should move
on at its next suspension point, and that the scope timed out, which
ScopeTimedOut() answers for the rest of the activation. A deadline that
fires while every task is busy computing still fires: the scheduler consults
its timers even when tasks are ready to run, so a deadline does not depend on
its tasks being idle.
Every kind of waiting the language offers is a suspension point and can be cancelled this way: reading and writing sockets and pipes, accepting connections, sleeping, and reading and writing a typed file whose stream can wait for another party, such as a pipe, a terminal or a socket. A typed file over a regular file never waits for another party and runs at the storage’s speed. The client road and typed transfers tutorials show deadlines at work.
3.6 Carriers
On one carrier, the scheduler is a first-in, first-out ready list and a switch. The switch saves the running task’s callee-saved registers on its own stack and stores one word - its stack pointer - in the task’s control block; resuming a task loads that word and restores the registers. A task that has never run has a stack prepared to look exactly like a suspended one, so starting a task and resuming one are the same operation. Every task stack is mapped with an unmapped guard page below it, so a task that overflows its stack faults at once instead of overwriting its neighbour, and stacks never move, because Mica has explicit pointers and nothing that could rewrite them.
On many carriers, each carrier owns a ready list of its own. A task scheduled on a carrier joins that carrier’s list; a carrier whose list is empty takes half of another carrier’s, which spreads a burst of tasks across the pool in a few steps. With a pool of one carrier this reproduces the single-carrier order exactly, which the test suite uses to check the two flavours against each other. A carrier with nothing to do does not spin: it waits in the operating system, either for descriptors that parked tasks wait on, or for another carrier to hand it work.
The pool’s size is decided once, at the program’s first suspension point -
its first schedule, its first parked call, or its first Yield. A count written
in the build (--tasking multicore,8) or a carriers=N runtime flag decides
it; without either, the pool takes the number of processors the program may
actually use - the least of the processors online, its affinity mask and its
container’s processor quota. +mica check -mica prints every one of these
facts for the machine the program runs on.
3.7 Parking
A task that would block in a system call parks instead. The runtime’s calls for descriptors - reading, writing, accepting, connecting - first try the operation without blocking. If the descriptor is not ready, the task records the descriptor and the readiness it needs in one operating-system poll set the scheduler owns, leaves the ready list, and the carrier runs other tasks. When nothing is ready to run, the carrier waits in the poll set; readiness puts the parked task back on a ready list, and its call tries again.
Because parking is the one place the scheduler meets the operating system’s readiness machinery, three things fall out at once. A program whose every task waits sleeps in the kernel instead of spinning. Every wait is visible to cancellation, so deadlines reach every wait. And an embedded profile without an operating system replaces this single seam rather than every call.
4. Generators
4.1 The pull
generator Fibonacci(count : int64) : stream of int64;
var
a, b, t : int64;
k : int64;
begin
a := 0;
b := 1;
for k := 1 to count do
begin
emit b;
t := a + b;
a := b;
b := t;
end;
end;
for v in Fibonacci(10) do
WriteLn("fib %lld", v);The loop and the body take turns. The loop’s first round pulls: the body
runs from its beginning to its first emit, which hands the loop 1 and
stops the body where it stands, with a, b and k exactly as they are.
The loop body prints, and the next round pulls again: the body continues from
the statement behind the emit. When the body reaches its own end, the pull
finds no value and the loop ends. There is no concurrency here at all - one
thread, one sequence of steps, decided entirely by the program text - which
is why a generator is a control-flow construct rather than a kind of task,
even though it suspends. The generators tutorial walks
through the order of events line by line.
What makes a generator hard to implement is the stop in the middle: when
emit hands over its value, the body’s locals, its position in its loops and
the place to continue must all survive until the next pull, while the loop
uses the same thread and the same stack for its own work. There are two
classical answers, and Mica uses both.
4.2 Two forms with one meaning
The stackful form gives the instance a stack of its own. A pull switches
from the loop’s stack to the instance’s; emit stores the value and switches
back. Everything the body had - locals, temporaries, return addresses, the
body’s own calls in progress - simply stays on its stack until the next pull.
This is the same machinery tasks use (section 3.6), driven by the consumer
instead of the scheduler: an instance is never on a ready list, because only
its consumer ever decides that it runs. The cost is a stack mapping per live
instance and a full register save and restore on every pull and every emit.
The struct form gives the instance no stack at all. The compiler turns
the body into an ordinary procedure that takes one parameter, a pointer to an
instance record in the consumer’s frame. The record holds a word for each
parameter, a word for each local, a position - zero for a fresh instance,
k when the body stopped at its k-th emit, minus one when it has ended -
and a slot for the emitted value. A pull is a call. On entry the body reads
the position, copies its saved locals back from the record into its frame,
and jumps to the place behind the emit it stopped at, which may be inside a
loop. At an emit it stores the value, copies its locals into the record,
writes its position, and returns. The consumer’s loop reads the position:
minus one means the sequence has ended. This is the transformation C#
applies to iterator methods, with one difference that matters on a small
machine: the record lives in the consumer’s own frame, not on the heap, so
the struct form allocates nothing, ever.
The two forms are indistinguishable to the program. Values arrive in the same
order, an abandoned body behaves the same way, and the test suite runs the
generator shapes on both forms, each beside a twin that a harmless defer
keeps on the stackful form, and compares the outputs. The
compiler chooses the form per generator; the programmer never writes it.
4.3 The choice
The compiler gives a generator the struct form whenever its state fits a record, and the stackful form otherwise. Each reason for the stackful form is something a record cannot hold across a return:
| The body… | Why a record cannot hold it |
|---|---|
| is bound to a stream variable somewhere in the program | the variable’s type cannot know one generator’s record layout, so it holds a one-word handle to a stackful instance |
declares fails | the stackful instance keeps the failure word in its control block; the record has no field for it |
| emits records, arrays or text | the record hands its value over in one word |
| is nested in another routine | its static link would point into a frame that is gone after the return |
| has a parameter or local wider than a word | the record keeps one word per variable |
| takes the address of a local | the address would dangle once the pull returns |
uses defer | a deferred statement needs an activation to run in when the instance is abandoned |
suspends other than at its own emit (a Yield, a parking call, a concurrent block) | the struct body runs on its consumer’s stack, so such a suspension would freeze the consumer |
| holds a device value of its own | its buffer goes back to the card on the way out, which only an activation of its own runs |
| emits inside a loop over a stream whose instance has a stack of its own | that instance cannot live in a record |
The choice reads only the program, never the optimiser’s decisions, so a
generator has the same form at every optimisation level. And the choice is
reported: --verbose prints how many generators took each form and, for the
stackful ones, how many took it for each reason, so a body that quietly costs
a stack is always visible.
4.4 What the record keeps
A loop is where the struct form has to work hardest, because a loop keeps
state the source never names. A counted loop for k := 1 to count evaluated
count once, before its first round, and compares against that captured value
at every round; a walk for x in values keeps its position in values, and
for a text, the text’s data, its length and, under UTF-8, the byte offset of
the next character. When the body stops at an emit inside such a loop and
the next pull jumps back into the loop, all of this has to be there.
The record keeps all of it:
- the declared locals, including variables declared inside a statement
block (
begin var k : int64; ... end), one word each; - the captured bound of every counted loop that holds an
emit, in a word of its own; - the instance record of a pulled generator, when the body walks another struct-form generator and emits inside the walk - the inner record’s fields become fields of the outer record, so a chain of adapters is one flat record in the outermost consumer’s frame;
- the container of a walk over a text, an array or a bitset, in a field of the record typed as the array, the view or the bitset it is, and the walk’s index, bound and cursor in words beside the locals;
- the result-region mark of a walk whose container a function returned (section 4.5), so the walk’s release finds it whichever pull ends the walk.
On the development machine, a pull of a counted loop’s generator costs 1.8 nanoseconds in the struct form against 29 in the stackful form, and a pull of an array walk 2.7 against 35 (section 8).
4.5 Ending early
A loop can stop pulling before its generator has ended - with leave, with a
failure, or because its task was cancelled. The instance is then
abandoned, and whatever its body holds must be given back.
A stackful instance is ended the way a cancelled task is (section 3.5). The
runtime marks it cancelled and resumes it once; the code behind the emit it
stopped at sees the flag, and the body takes its ordinary way out, running its
defer statements and giving back what its activation holds. Its stack is
then unmapped.
A struct-form instance needs less, because a body that holds anything needing
cleanup - a defer, a stack, a device value - has the stackful form by the
choice above. One thing remains: a walk over a value a function returned. A
returned array lives in the result region (TR-1, section
7),
in a window the walk opened before its first round and closes behind its
last. An abandoned body never reaches that close. So a loop that pulls a
struct-form generator able to hold such a window opens a window of its own
before its first pull, and closes it behind its loop, whichever way the loop
ends. The generator’s windows are all newer, and a release gives back
everything newer than its mark, so the consumer’s release gives back what the
abandoned generator held. Abandoning a struct-form instance therefore costs one
store - its position becomes minus one - and, when its body walks returned
values, one release.
4.6 Instances you can name
count := From(10);
firstpass : for v in count do
leave firstpass when v = 12;
secondpass : for v in count do
begin
WriteLn("%lld", v);
leave secondpass when v = 14;
end;Bound to a variable, an instance outlives any one loop. The second loop above prints 13 and 14: the first loop froze the instance when it left, and the second thawed it where it stopped. A loop over a stream variable borrows the instance; it opens nothing and closes nothing. The instance belongs to the variable, and it is closed when the variable’s activation ends or the variable is given a new instance, exactly as the generators tutorial shows. An instance held by a variable always has the stackful form (section 4.3), since the variable holds a handle and no record.
5. Streams that tasks feed
5.1 Pull or push: the bind decides
s := Numbers(5); { pull: the loop drives the body }
s := schedule Numbers(5) buffer 8; { push: the body runs as a task }The same generator can be consumed in two ways, and the difference is written
where the stream is bound. Bound plainly, it is pulled: each round of the
consumer’s loop runs the body to its next emit, as section 4 describes.
Bound with schedule ... buffer N, it is pushed: the body starts as a task,
the producer, and each emit places a value in a ring of N slots
without waiting for the consumer. The consumer’s loop takes values out of the
ring. Producer and consumer now run independently, on one carrier
interleaved and on many in parallel, and the ring between them absorbs the
difference in their speeds. The channel streams
tutorial shows both binds side by side.
5.2 The ring
The ring is a fixed array of slots used round and round, with two counters - the next slot to fill and the next slot to empty - that only ever grow. Each slot carries a sequence number beside its value, and the sequence number says whether the slot is free for the producer whose turn it is, or full and ready for the consumer whose turn it is. A producer claims a slot by advancing the fill counter with one atomic compare-and-swap, writes the value, and publishes it by storing the slot’s new sequence number; a consumer does the same from the other side. This is Dmitry Vyukov’s bounded multi-producer, multi-consumer queue, and its property that matters here is that no producer or consumer ever waits for a lock: one that loses the race for a slot to another simply tries the next.
The capacity is rounded up to a power of two, so a slot is found from a counter with one mask. A value holding text or arrays takes one more word in its slot, for the memory block its text and arrays travel in, and the consumer that takes the value takes that block over with it.
5.3 Back pressure and the end
Waiting sits on top of the ring, never inside it. A producer that finds the
ring full and a consumer that finds it empty suspend through the ordinary
scheduler, exactly as a task waiting for a descriptor does, and try again
when the other side has moved. Nothing spins and nothing is dropped. The
capacity in buffer N is the whole flow-control policy: a producer can run
at most N values ahead of its consumer, so a stream can never fill memory
with values nobody has asked for yet.
Several producers can feed one ring (schedule More(3) into s), and several
tasks can consume one ring when the stream is passed to them as a stream of
parameter; each value is taken by exactly one consumer. The stream ends when
the ring is empty and its last producer has ended, so a ring fed by three
producers is not finished until all three are. Within one producer the order
of values is program order; across producers only completeness is promised,
and the order is the scheduler’s. The MPMC tutorial shows four
consumers sharing one ring of three producers.
A ring owns its producers’ scope: every producer is started into the scope of
the ring, not of the activation that bound it. When the variable that holds
the stream goes away - its activation ends, or it is bound again - the ring
is closed: its
producers are cancelled, each stops at its next emit or wait, the scope is
drained, and only then is the ring’s memory given back. Producer cleanup is
structural; there is no close call to forget.
5.4 The closing word
A producer can fail. A ring has no room for a failure in every slot, and does
not need it: failure ends a producer, so it happens at most once. The failing
producer latches its failure in one word beside the ring - first failure
wins - and closes the ring, so its sibling producers stop at their next
emit. Every consumer meets that word at its own end, after the values that
were already in the ring:
for reading in readings do
begin
total := total + reading;
end
on fail reason do
WriteLn("the readings ended early");The word is read, never taken, so with several consumers each one’s loop learns of the failure. The fallible streams tutorial shows the same clause on a pulled generator, where the failure travels in the instance instead of the ring.
5.5 Choosing among streams
select waits on several rings at once and runs the arm whose ring has a
value; a timeout arm, counted on the monotonic clock, runs if none speaks
in time. A merge with into and a race with select answer different
questions: one stream whose values are one workload, or several sources that
must stay distinguishable.
6. Processes
6.1 Why a task needs a process
A task on a carrier shares its program’s address space. That is what makes it cheap, and it is also its limit: a task that dereferences a bad pointer, runs out of memory or is killed takes the whole program with it. A task placed on a process runs in an operating-system process of its own:
concurrent
schedule Parse(input) on process;
on lost do
WriteLn("the parser's worker was lost");
end;Everything else stays as it was. The task is scheduled inside a concurrent
block, its block joins it, a failure of its error domain reaches the block’s
on fail section, and cancellation and deadlines reach it. What is new is the
on lost section, which the compiler requires: a worker can end before its
task does - a trap, a kill, the machine running out of memory - and that is
not a failure of the task’s own domain, so the program has to say what it does
then. The tasks on processes tutorial shows the
whole road.
6.2 The server and the standby
A worker is not forked from the program while the program runs. By the time a program places its first task on a process, it may have many carriers, and a carrier may be holding a lock - inside the allocator, inside the C library - at the moment another carrier forks. The child would inherit the lock, held, with no thread left to release it. So the compiler plants one call at the very start of a program body that places tasks on processes, before any task runs: it forks a quiet copy of the program, the process server, whose only work is to fork workers on request. A copy of a quiet runtime holds no lock, and neither does anything forked from it.
The server is tied to the program by the operating system: it asks the kernel
to end it when the program ends, even by a signal, so it never outlives the
program. At the start, the server also forks a second quiet copy, the
standby. The standby serves nothing; it waits on a socket of its own
until the program needs it. If the server is lost - killed, or ended by a
worker of its own - the next placement finds out, the standby becomes the
server, and the new server forks a standby of its own. A program survives the
loss of every server it has while a standby stands, as Python’s forkserver
starts a new server when its server has died. With no server left, each
placement is reported lost, and the program’s on lost sections decide.
6.3 The hand-over
To place a task, the program makes two pipes - one for the request, one for the outcome - and sends the worker’s ends of both to the server in one message on a Unix socket, which carries descriptors from one process to another. The server forks a worker and hands it the two ends; the worker’s copy of the server’s socket is closed at once. Placements from several carriers never interleave, because each message is one datagram, sent under one lock.
The server answers each hand-over with one byte the moment it takes the message off its socket, before it forks the worker, and the program waits for that answer. The answer closes a gap the operating system leaves open: a server that has ended can leave its socket open for a moment - a child it had just forked still holds a copy, or, under an emulator, the server’s last thread has not ended yet - and a hand-over sent in that moment is taken by no process. A socket that ends without an answer says the hand-over was never taken, so the program gives it to the standby; a hand-over that was answered is never sent twice, so no placement can ever have two workers. Losing a server costs exactly the workers it was running, and a hand-over it had taken but not yet forked.
6.4 Workers, and how many run at once
A worker asks the kernel to end it when its server ends, and checks that its server is still the process that forked it, so a worker never outlives its server, which never outlives the program. A worker runs exactly one task and then ends; it is reaped by the kernel without anybody waiting for it.
At most so many workers run at once, however many tasks a program places:
the count is the processors the program may use when it starts, unless the
build (--processes N) or a runtime flag (processes=N) names another. A
placement beyond the count waits, suspended, until a worker ends, the way a
process pool’s queue holds work. Without the bound, a thousand placements
would fork a thousand workers at the same moment, which a small machine
answers by running out of memory. The waiting placements wait on one counter
in the kernel, and each finished worker wakes the one placement that has
waited longest.
6.5 The proxy and its frames
In the program, a task on a process is represented by a proxy, an ordinary task in the block’s scope that does the talking:
- It takes a worker slot (section 6.4), makes the two pipes, and hands the worker’s ends to the server (section 6.3).
- It reads the worker’s first frame, started, which carries the worker’s process id. The proxy opens a pidfd for it - a descriptor naming that process itself, which stays valid even if the process id is later reused - so it can end exactly this worker if the task is cancelled.
- It writes the request: the task’s entry and its arguments, with any text and arrays they hold carried as bytes, and any descriptors they hold carried as descriptors the worker receives as its own.
- It reads the outcome: the task’s result word - success, or the failure
of its domain - which it reports to the scope as the task’s own, under the
task’s own entry, so the block’s
on failsection for that domain runs.
Every read and write parks, so a proxy holds no carrier while its worker works. The program flushes its own buffered output before each placement, and a worker flushes its output before it reports, so lines appear in the order the program text implies.
If the proxy’s task is cancelled while the worker runs, the proxy ends the
worker through its pidfd. If the outcome pipe ends before an outcome frame
arrives, the worker ended without reporting, and the proxy reports the
lost word. The scope latches it like a failure, so the block’s other tasks
are cancelled, and after the join the block’s on lost section runs.
6.6 What crosses, and what is refused
A worker shares nothing with the program, so a task on a process receives everything as a copy, and the compiler refuses whatever would need sharing, each by name:
| A task on a process… | Answer |
|---|---|
| receives plain values, records, arrays and text | copied into the request; text and arrays travel as bytes |
| receives a value holding a descriptor (a connection, a listener) | the worker receives a descriptor of its own for the same socket, pipe or file |
| reaches a variable outside its own frame | refused: the worker would change only its own copy, so the task must be given what it needs as arguments |
| receives a C handle | refused: a handle names an object of the program’s process |
| receives a stream | refused: a stream names a ring or an instance in the program’s memory |
has no on lost section in its block | refused: a worker can always be lost |
6.7 Streams on processes
A producer can be placed on a process too
(readings := schedule Sample(sensor) on process). Its elements cross to the
program’s ring in frames; the worker fills a frame with what it has produced
rather than sending one element at a time, so a loop over such a stream is
not one round trip per element. Every loop over the stream carries an on lost section, because the worker can end before the stream does; a select
arm has nowhere to answer a lost worker, so such a stream is consumed by a
loop. A stream’s worker takes no slot among the workers of section 6.4,
because its consumer paces it.
7. The guarantees, stated
As in TR-1, a guarantee here is one of three kinds: proved by the compiler before the program runs, guarded by the runtime while it runs, or not given, and then said so.
| Guarantee | Kind | How |
|---|---|---|
| No task outlives the block that started it | proved | every exit from a concurrent block - its end, leave, a failure, a cancellation - joins its tasks; the compiler plants the join on every path |
| No task reaches a frame that has ended | proved | a task reaches only its own frame, its copied arguments, and variables of activations that join it before they end |
Shared state is touched only under synchronized | proved | the compiler finds every variable a task or its parent can reach while the block runs, and refuses an unmarked touch |
synchronized cannot deadlock | proved | locks are taken in the order of the activations’ nesting, one total order for the whole program, and no suspension point may stand inside the statement |
| A block’s failure is seen after its tasks have stopped | guarded | the first failure is latched, the block is cancelled, and the parent sees the failure only after the join |
| A cancelled task stops promptly | guarded | every wait is a suspension point that cancellation ends, and every loop of a program with tasks or generators polls at least every 1,024 rounds |
| A deadline reaches a busy scope | guarded | the scheduler consults its timers even while tasks are ready to run |
| A task’s stack overflow is caught | guarded | every task stack has an unmapped guard page below it |
| No task gives back a value another task still reads | guarded | every task and every stackful generator instance keeps its own result region and its own marks, on one carrier as on many |
| A generator behaves the same on both forms | proved and tested | the choice reads only the program; the test suite runs the shapes on both forms and compares outputs |
| An abandoned generator gives back what it holds | proved and guarded | the choice gives a body holding anything else the stackful form, whose instance runs its way out on close; a struct-form instance holds nothing but result-region values, which the window its consumer’s loop opens gives back |
| A stream never holds more values than its capacity | guarded | producers suspend on a full ring |
| A task on a process shares nothing with the program | proved | the compiler refuses every reach outside the task’s frame, every handle and every stream among its arguments |
| A lost worker is always answered | proved | a block placing a task on a process, and a loop over a stream fed on a process, must carry an on lost section |
| A placement is never run twice | guarded | the server answers each hand-over before it forks, and only an unanswered hand-over is sent again |
| No server, standby or worker outlives the program | guarded | the first server and every worker ask the kernel to end them with their parent, and check that the parent is still theirs; a standby, which a fork leaves without that request, ends when the program’s end of its socket closes |
| Arrival order across a stream’s producers | not given | per producer, values arrive in program order; across producers, only completeness is promised |
| A worker’s partial work | not given | a lost worker’s effects inside its own process are gone; effects it had on the outside world (a file written, a message sent) are not undone |
8. Costs and measurements
8.1 The cost model
| Operation | Cost, in words |
|---|---|
| a pull of a struct-form generator | one call, a few word moves to save and restore its state, one compare |
| a pull of a stackful generator | two stack switches, each saving and restoring the callee-saved registers |
| a task’s schedule and join | a stack mapped with its guard page, a control block, a place on a ready list, and the stack reclaimed after the task ends |
| a value through a ring | two atomic updates on the ring’s counters and one on the slot; a suspension only when the ring is full or empty |
| a placement on a process | a worker slot, two pipes, a datagram with two descriptors, a fork in the server, and three frames on the pipes |
a synchronized statement on one carrier | nothing |
| a loop’s poll in a program with tasks | a counter increment and a test per round; a runtime call every 1,024 rounds |
8.2 Measured
The numbers below were measured on the development machine - an Apple
silicon laptop running Linux for arm64 in a virtual machine - with programs
built at O2, on one carrier, each the middle value of several runs. They show
proportions, not a benchmark result: the published measurements of Mica are
made by the measurement protocol on pinned
machines.
| Operation | Time |
|---|---|
| a pull, generator with a counted loop: struct form / stackful form | 1.8 ns / 29 ns |
| a pull through two struct-form generators chained, against the stackful chain | 8.5 ns / 33 ns |
| a pull, generator walking an array: struct form / stackful form | 2.7 ns / 35 ns |
| a pull, generator walking a text: struct form / stackful form | 2.6 ns / 35 ns |
| a task scheduled and joined, a thousand to a block | 3.1 µs |
| a value through a ring of 256 slots, one producer and one consumer | 9 ns |
| a task placed on a process and its outcome received, one at a time | 165 µs |
Three proportions are worth reading off. A struct-form pull costs about as much as a short function call, and a tenth to a twentieth of a stackful one, which is why the compiler works to give a generator the struct form. A task costs about a thousand struct-form pulls, which is the price of a stack of its own; a task is the right tool for work that waits or runs in parallel, not for producing a sequence. And a placement on a process costs about fifty tasks, almost all of it the fork; a process is the right tool when the work is large enough, or dangerous enough, that its isolation is worth that price.
9. Related work
Structured concurrency. The rule that a task cannot outlive the block that
started it was named by Martin Sústrik and made popular by Nathaniel Smith’s
Trio library for Python, whose nurseries are Mica’s concurrent blocks.
Kotlin’s coroutineScope, Swift’s task groups and Java’s StructuredTaskScope
(still a preview API) adopted the same shape, including the rule that a failing child cancels its
siblings and the scope reports the failure when it ends. Mica differs in
making the structure the only one: there is no unstructured spawn to fall
back on.
Stackful tasks. Go’s goroutines and Java’s virtual threads are uncoloured
like Mica’s tasks: any function can wait, and a waiting task gives up its
thread. Both move or copy stacks, which their garbage collectors make
possible; Mica’s stacks never move, because Mica has explicit pointers, and
are sized up front with a guard page instead. Java’s virtual threads pin
their carrier during a native call and, before Java 24, inside a
synchronized block; Mica refuses a suspension point inside synchronized
at compile time, so the question does not arise.
Generators. C# compiles an iterator method into a state-machine class, the same transformation as Mica’s struct form, and allocates it on the heap; Mica keeps the record in the consumer’s frame and allocates nothing. Python’s generators keep a heap-allocated frame, and Lua’s coroutines a stack of their own, as Mica’s stackful form does. Mica’s contribution is choosing between the two per generator, by a rule the programmer can read, without changing what the program means.
Channels. Go’s channels and Rust’s bounded channels give back pressure the same way Mica’s rings do; the ring itself is Dmitry Vyukov’s bounded multi-producer, multi-consumer queue. Go lets a program close a channel explicitly; Mica closes a ring when its owner goes away, as part of the scope tree.
Processes. Erlang’s processes share nothing and report a crashed process
to the processes linked to it; Mica’s on lost is a link that the compiler
insists on. Python’s multiprocessing with its forkserver start method forks
workers from an early, quiet server for the same reason Mica does - a fork from
a program with running threads can inherit a lock nobody will release - and
starts a new server when its server has died.
10. Conclusion
Tasks, generators and processes look like three features, and they are documented in three tutorials, but they are one design. Work has an owner and the owner waits, so lifetimes follow the program text. State is partitioned, so the only sharing is sharing the compiler has seen and the source has marked. Every wait is a suspension the runtime knows, so every wait can be cancelled and a deadline is just a clock that cancels. And where one meaning can be implemented two ways, the compiler picks the cheaper one, says which it picked, and makes sure no program can tell.
The price list in section 8 is the practical summary. A sequence produced on demand costs a few nanoseconds per value, a task costs a few microseconds, and an isolated worker costs a fork. A program chooses among them by what the work needs - order, parallelism or isolation - and the rules of the scope tree stay the same whichever it chooses.
Glossary
- activation - one call in progress: its parameters, locals and the storage it owns. Ends at the return.
- scope head - the few words in an activation’s frame that count its live tasks, latch its first failure and carry its cancellation and deadline.
- task - a call that runs beside its caller, on a stack of its own, from
its
scheduleto its block’send. concurrentblock - the statement whoseendjoins every task scheduled inside it.- join - the wait, at a block’s end, for the block’s last task.
- carrier - an operating-system thread that runs tasks; a program has one, or a pool of them when built for multicore.
- ready list - the tasks a carrier can run next, first in, first out.
- suspension point - a place in compiled code where the running task may
give up its carrier: a wait, a
Yield, a join, a loop’s poll. - park - to wait for a descriptor by registering it with the scheduler and suspending, instead of blocking the carrier.
- cancellation - a request, carried by a task and its scope, that a task stop at its next suspension point.
- deadline - a cancellation a monotonic timer sends when a scope’s time is up.
synchronized- the statement that marks a touch of shared state and, on many carriers, takes the locks that make it safe.- generator - a routine that produces a sequence of values with
emit, one per pull. - pull - a consumer’s request for a generator’s next value; one round of
a
for ... inloop. - instance - one running copy of a generator body.
- struct form - the implementation of a generator as a procedure over an instance record in its consumer’s frame.
- stackful form - the implementation of a generator as an instance with a stack of its own, switched to by each pull.
- instance record - the words a struct-form instance keeps between pulls: its parameters, locals, position, emitted value and its loops’ hidden state.
- stream - a sequence of values a program can bind to a variable: a pulled generator instance, or a ring fed by producers.
- ring - the fixed array of slots between a stream’s producers and its consumers.
- producer, consumer - the tasks that put values into a ring and take them out.
- back pressure - a producer’s suspension on a full ring, which keeps it at most a ring’s capacity ahead of its consumers.
- closing word - the failure a producer ended with, latched beside the ring and read by every consumer at its end.
- process server - the quiet copy of the program, forked before any task runs, that forks workers.
- standby - a second quiet copy that becomes the server when the server is lost.
- worker - the process a task placed on a process runs in.
- proxy - the task in the program that stands in for a task on a process and talks to its worker.
- frame (process) - one message on a worker’s pipes: started, request, outcome, or a batch of stream elements.
- lost - the outcome of a task or stream whose worker ended before it reported.
References
- M. E. Conway. “Design of a separable transition-diagram compiler.” Communications of the ACM 6(7), 1963. The first description of coroutines.
- C. A. R. Hoare. “Communicating sequential processes.” Communications of the ACM 21(8), 1978.
- A. L. de Moura, R. Ierusalimschy. “Revisiting coroutines.” ACM Transactions on Programming Languages and Systems 31(2), 2009. The classification of coroutines into stackful and stackless.
- R. D. Blumofe, C. E. Leiserson. “Scheduling multithreaded computations by work stealing.” Journal of the ACM 46(5), 1999.
- D. Vyukov. “Bounded MPMC queue.” 1024cores, 2010.
- M. Sústrik. “Structured concurrency.” 2016.
- N. J. Smith. “Notes on structured concurrency, or: Go statement considered harmful.” 2018.
- J. Armstrong. Making reliable distributed systems in the presence of software errors. PhD thesis, Royal Institute of Technology, Stockholm, 2003.
- ISO/IEC 8652, Ada Reference Manual, section 9 (tasks and synchronization).
- ECMA-334, C# Language Specification, the section on iterators.
- JEP 444, “Virtual Threads” (Java 21), and JEP 491, “Synchronize Virtual Threads without Pinning” (Java 24).
- The Python documentation,
multiprocessing: contexts and start methods (forkserver).