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 synchronized statement. 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:

PlacementSpelledRunsWaits by
a task on a carrierschedule Work(x); inside concurrent ... endbeside its parent, interleaved or in parallelsuspending; its carrier runs other tasks
a generatorgenerator G(...) : stream of T; with emitinside its consumer’s loop, one value per pullreturning a value to the loop
a task on a processschedule Work(x) on process;in a separate operating-system processits stand-in task suspending on a pipe

The scope tree makes three promises that hold for every placement.

  1. Nothing outlives its block. A concurrent block 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.
  2. Failure travels up. The first task of a block that fails cancels its siblings, and the block’s on fail section - or the activation around it - receives the failure. A worker process that dies is reported as lost to an on lost section the compiler insists on.
  3. 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

WordWhat it isLives in
activationone running call and its storagea stack frame
scope headan activation’s count of live tasks and its first failureits activation’s frame
taska call running beside its caller, on a stack of its owna guard-paged stack the scheduler maps
carrieran operating-system thread that runs tasksthe process
suspension pointa place the compiled code may switch tasksthe code
generator instanceone running copy of a generator bodya record in its consumer’s frame, or a stack of its own
ringthe slots between a stream’s producers and its consumersthe heap
process server, standbyquiet copies of the program that fork workersseparate processes
workerthe process a task placed on a process runs ina separate process
proxythe task that stands in for a task on a processa 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 main returns.

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:

  1. The failing task ends, running its defer statements.
  2. 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.
  3. The block is cancelled: every other task in it is asked to stop, and each stops at its next suspension point.
  4. The parent reaches end, joins, and only then sees the failure. If the block has an on fail section 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 programthe variable’s type cannot know one generator’s record layout, so it holds a one-word handle to a stackful instance
declares failsthe stackful instance keeps the failure word in its control block; the record has no field for it
emits records, arrays or textthe record hands its value over in one word
is nested in another routineits static link would point into a frame that is gone after the return
has a parameter or local wider than a wordthe record keeps one word per variable
takes the address of a localthe address would dangle once the pull returns
uses defera 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 ownits 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 ownthat 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:

  1. It takes a worker slot (section 6.4), makes the two pipes, and hands the worker’s ends to the server (section 6.3).
  2. 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.
  3. 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.
  4. 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 fail section 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 textcopied 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 framerefused: the worker would change only its own copy, so the task must be given what it needs as arguments
receives a C handlerefused: a handle names an object of the program’s process
receives a streamrefused: a stream names a ring or an instance in the program’s memory
has no on lost section in its blockrefused: 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.

GuaranteeKindHow
No task outlives the block that started itprovedevery 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 endedproveda 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 synchronizedprovedthe compiler finds every variable a task or its parent can reach while the block runs, and refuses an unmarked touch
synchronized cannot deadlockprovedlocks 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 stoppedguardedthe first failure is latched, the block is cancelled, and the parent sees the failure only after the join
A cancelled task stops promptlyguardedevery 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 scopeguardedthe scheduler consults its timers even while tasks are ready to run
A task’s stack overflow is caughtguardedevery task stack has an unmapped guard page below it
No task gives back a value another task still readsguardedevery 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 formsproved and testedthe choice reads only the program; the test suite runs the shapes on both forms and compares outputs
An abandoned generator gives back what it holdsproved and guardedthe 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 capacityguardedproducers suspend on a full ring
A task on a process shares nothing with the programprovedthe compiler refuses every reach outside the task’s frame, every handle and every stream among its arguments
A lost worker is always answeredproveda 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 twiceguardedthe server answers each hand-over before it forks, and only an unanswered hand-over is sent again
No server, standby or worker outlives the programguardedthe 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 producersnot givenper producer, values arrive in program order; across producers, only completeness is promised
A worker’s partial worknot givena 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

OperationCost, in words
a pull of a struct-form generatorone call, a few word moves to save and restore its state, one compare
a pull of a stackful generatortwo stack switches, each saving and restoring the callee-saved registers
a task’s schedule and joina 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 ringtwo 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 processa 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 carriernothing
a loop’s poll in a program with tasksa 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.

OperationTime
a pull, generator with a counted loop: struct form / stackful form1.8 ns / 29 ns
a pull through two struct-form generators chained, against the stackful chain8.5 ns / 33 ns
a pull, generator walking an array: struct form / stackful form2.7 ns / 35 ns
a pull, generator walking a text: struct form / stackful form2.6 ns / 35 ns
a task scheduled and joined, a thousand to a block3.1 µs
a value through a ring of 256 slots, one producer and one consumer9 ns
a task placed on a process and its outcome received, one at a time165 µ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.

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 schedule to its block’s end.
  • concurrent block - the statement whose end joins 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 ... in loop.
  • 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

  1. M. E. Conway. “Design of a separable transition-diagram compiler.” Communications of the ACM 6(7), 1963. The first description of coroutines.
  2. C. A. R. Hoare. “Communicating sequential processes.” Communications of the ACM 21(8), 1978.
  3. 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.
  4. R. D. Blumofe, C. E. Leiserson. “Scheduling multithreaded computations by work stealing.” Journal of the ACM 46(5), 1999.
  5. D. Vyukov. “Bounded MPMC queue.” 1024cores, 2010.
  6. M. Sústrik. “Structured concurrency.” 2016.
  7. N. J. Smith. “Notes on structured concurrency, or: Go statement considered harmful.” 2018.
  8. J. Armstrong. Making reliable distributed systems in the presence of software errors. PhD thesis, Royal Institute of Technology, Stockholm, 2003.
  9. ISO/IEC 8652, Ada Reference Manual, section 9 (tasks and synchronization).
  10. ECMA-334, C# Language Specification, the section on iterators.
  11. JEP 444, “Virtual Threads” (Java 21), and JEP 491, “Synchronize Virtual Threads without Pinning” (Java 24).
  12. The Python documentation, multiprocessing: contexts and start methods (forkserver).