A task that blocks does not block the program. It leaves the queue, and the carrier picks up someone else.

What happens to everything else when one task calls Read on an empty pipe? Two things answer that: the scheduler, and the seam where descriptor operations reach the operating system.

The scheduler

Mica’s tasks are stackful and uncoloured: a task body is an ordinary body. Each task gets a fixed, non-moving stack with a guard page below it, so an overflow faults at the boundary rather than corrupting a neighbour, and a suspended task’s whole context is one word — its stack pointer.

Ownership is the scope tree. A task belongs to the activation that started it, and that activation’s block exit drains the scope, so no task outlives its declaring scope.

On a single carrier — the default — scheduling is one FIFO ready queue and the suspension points the compiler plants. On multiple carriers, a thread pool starts at the program’s first suspension point. Each carrier owns one futex-guarded FIFO deque: the owner appends at the tail and pops at the head, and a carrier with nothing left steals half of a victim’s entries from the head, visiting victims round-robin. On one carrier that discipline reproduces the single-carrier order exactly. The carrier count is the compiled-in --tasking multicore,N, or the online processor count read once at pool start; what keeps sharing safe across carriers is compile-time data-race freedom.

Two rules keep the pool honest. A task that finds no successor hands the carrier to its own scheduling context rather than waiting where it is — a live context cannot safely publish its own pending park. That context is what blocks: in the poll set when parked work exists and the poller role is free, otherwise on a versioned idle futex that cannot lose a wake. And a suspending task is never published before its context is saved: it records what it is owed, a deque entry or a place on the parked list, and the next context performs it, so a thief cannot resume a half-saved suspension.

When a task would block

Every descriptor operation the contract marks as parking routes to a runtime shim instead of its libc symbol. Each shim is the same three beats: try the operation without blocking; return any completion, success or a genuine error; otherwise park on readiness and retry on wake.

Parking means the task leaves the ready queue, its descriptor joins one scheduler-owned poll set, and the carrier runs someone else. The set is level-triggered, so a wake another consumer got to first simply parks again: the retry loop, not the wake, guarantees the caller’s result. And when every live task is parked, the scheduler waits in the kernel rather than spinning.

Recv and Send pass MSG_DONTWAIT per call, so the try mutates nothing and the descriptor keeps the mode the caller gave it. Accept, Connect, Read and Write have no per-call form and set O_NONBLOCK once instead. Connect parks on writability rather than readability, then reads the handshake’s verdict from SO_ERROR.

Parking is a suspension point like Yield: cancelling a scope wakes its parked members, and a woken cancelled task stops at the park rather than retrying.

The descriptor the poll set will not take

epoll refuses a regular file, with EPERM. Read and Write therefore carry a fall-back arm: when registration reports the descriptor unpollable, the shim clears O_NONBLOCK again and performs a plain blocking call.

That arm almost never runs: a regular file is never blocked on readiness, so its non-blocking read returns straight away. It exists for the rare descriptor that reports EAGAIN yet the poll set will not watch — one that can neither park nor spin.

What it looks like

Two reader tasks park on empty pipes; main feeds them only after both have parked. Verbatim from our test corpus of 1,389 programs and 8,436 runs:

procedure Gather(readA : int32, writeA : int32, readB : int32, writeB : int32);
var
    feed : Bytes;
    written : int64;

    { one reader: parks at Read until main feeds its pipe, then folds the delivered byte into the root
      total - the byte, not the wake order, decides the sum, so the two readers are interchangeable }
    task Reader(readEnd : int32);
    var
        got : int64;
        local : Bytes;
    begin
        got := Read(readEnd, address local[0], 8) on fail use -1;
        if got > 0 then
            synchronized total := total + (local[0] as int64);
    end;

begin
    concurrent
        schedule Reader(readA);
        schedule Reader(readB);

        { let both readers run to their Read-park - their pipes are empty - before feeding, so each Read
          genuinely parks and wakes rather than completing on its first try }
        Yield();

        feed[0] := 30;
        written := FdWrite(writeA, address feed[0], 1) on fail use -1;
        feed[0] := 12;
        written := FdWrite(writeB, address feed[0], 1) on fail use -1;
    end;                              { the block's join drains both readers before Gather returns }
end;

See also