std/runtime
The green-task scheduler: cooperative tasks on one OS thread, and the fd waits that make blocking I/O yield instead of stall.
from "std/runtime" import { Task, schedulerCurrent, schedulerYield, schedulerWaitRead, schedulerWaitWrite, schedulerRunToCompletion }Most programs never import this. std/io, std/net, std/fetch, std/timer, std/sync and std/select are built on it and already yield at the right moments — reach for std/runtime when you are writing your own async primitive, or driving a raw fd that no stdlib module wraps. See Concurrency.
Tasks are green, not threads: they never run in parallel, so nothing here needs a lock. Parallelism comes from Promise.blocking, which runs a closure on a worker.
Spawning
Task.spawn gives the task a default stack; Task.spawnWithStack sets it explicitly for a task with deep recursion or large frames. A task's stack does not grow, so a too-small one overflows rather than reallocating.
var t = Task.spawn(() => {
print("in a task")
})
t.join()Driving the scheduler
Milo has Go exit semantics: main returning ends the program whether or not tasks are still running, and main itself is a green task wherever the program can spawn. Call schedulerRunToCompletion when you want the opposite: block until every outstanding task has finished, without threading a WaitGroup through them all.
schedulerCurrent returns the running task, or null when the caller is not on the scheduler. A std API that can be called from either context branches on it.
API reference
Promise
pub struct Promise<T>Promise.await
fn Promise.await(self: &Promise): Result<T>Promise.blocking
fn Promise.blocking(f: move () => T): Promise<T>Run f on a real OS thread for CPU-bound work or blocking FFI — anything that would otherwise starve the single-threaded cooperative scheduler. The caller never blocks here; the result arrives over the channel and is collected with await(), like any Promise. Fan out across N cores by pushing several Promise.blocking handles into Promise.all. @thread: f crosses to a real OS thread — captures must be Send, and the checker rejects unsynchronized mutable globals reached from its body.
f's captures are released BEFORE the result is sent, so once await() hands back a value every destructor the closure owned has already run. Without that the environment lived until the worker's frame was torn down, which is after the send, and a caller could hold a result while a lock, an fd or a reader handle the closure captured was still alive. See runOnce.
Promise.channel
fn Promise.channel(self: &Promise): Channel<T>The result channel, so a Promise can be armed in a Select:
sel.onRecv(p.channel()) // arm 0 sel.onTimeout(1000) // arm 1 match sel.wait() { 0 => { let v = p.await()! } 1 => { ... } }
This is the bridge from a Promise.blocking OS-thread result into an event-driven wait — previously the two tiers couldn't compose, and an event-driven timeout wanted exactly this. Await still owns the fetch: when the arm wins, await() does the recv, so don't recv off this handle yourself.
Returns a reference-counted clone that shares the underlying queue; the Select arms on its raw pointer. The clone frees itself when it drops — the channel is torn down once this clone and the Promise's own handle are both gone.
Promise.run
fn Promise.run(f: move () => T): Promise<T>Task
pub struct TaskA handle to a spawned green task. join blocks the caller until it finishes.
Task.join
fn Task.join(self: &Task): voidBlock until the spawned task finishes. Cooperative contract: call join before the target can run (i.e. before yielding/driving the scheduler), so the registration below always precedes completion. A green caller parks; the main thread drives the scheduler until the done cell is set.
Task.raw
fn Task.raw(self: &Task): *u8The scheduler's pointer to this task, for schedulerPark/schedulerUnpark. Opaque: pass it back to std, never read through it.
Task.scope
fn Task.scope(body: (&mut TaskScope) => void): voidRun body, then wait for every task it spawned on s. Tasks spawned with s.spawn may borrow the enclosing function's locals (&conn), because none of them can outlive this call: the join below runs however body exits, and a panic aborts.
Task.scope((s) => { s.spawn(() => writer(&conn)) reader(&conn) })
The checker holds the rest (checkTaskScopes): s is used only as s.spawn(<closure literal>) directly in body, a borrowing task captures only bindings declared outside body, and a binding a task borrows is either read by everyone in the scope or touched by that one task alone.
Task.spawn
fn Task.spawn(f: move () => void): TaskSpawn a Color.green (M:N) task that runs f on the cooperative scheduler, and return a handle to it. Returns immediately — the task does not run until the scheduler is driven (by a blocking IO op, schedulerYield, or a join). Each task gets its own guard-paged stack. Await completion with task.join().
Task.spawnRaw
fn Task.spawnRaw(fnPtr: *u8, envPtr: *u8, stackBytes: i64): TaskThe closure-free spine of spawnWithStack. schedulerRunMainRaw has a bare function pointer and no captures, and wrapping one in a closure just to unwrap it here would heap-allocate an environment that nothing frees — a leak on every program whose main runs as a green task.
OWNERSHIP: the spawned task takes envPtr. It must be a malloc'd block or null — reapTask frees it. Pass 0 for a bare function pointer.
Task.spawnWithStack
fn Task.spawnWithStack(f: move () => void, stackBytes: i64): TaskSame as spawn, with an explicit stack size. The default 1 MB is generous for ordinary tasks but far too small for a recursive interpreter: milojs budgets its JS call depth against the 8 MB the OS main thread gets, so running it on a green task needs a comparable stack. The mapping is anonymous and lazily committed, so a larger reservation costs address space rather than resident memory.
A task's stack does not grow: one that is too small overflows rather than reallocating.
TaskScope
pub struct TaskScopeThe handle Task.scope passes its body. Its only use is s.spawn(...); the private field is what stops code outside std from building one with no join behind it.
Scoped tasks borrow without a Send/Sync bound because green tasks never leave the OS thread that spawned them (the scheduler is thread-local and nothing migrates a task). If work stealing is ever added, a task spawned here must stay pinned.
TaskScope.spawn
fn TaskScope.spawn(self: &mut TaskScope, f: () => void): voidStart a green task running f; Task.scope joins it before returning. f is a plain closure param on purpose: it may borrow. This fn is the one place a plain closure param is kept past the call (checkNonEscapingParams exempts it by name), which is sound only with checkTaskScopes' rules at every call site. The literal's environment is on the heap (lowering does it for this callee) and the reap frees it.
Functions
defaultStackSize
pub fn defaultStackSize(): i64guardPageSize
pub fn guardPageSize(): i64isWorkerThread
pub fn isWorkerThread(): boolTrue on a thread spawnOsThreadDetached started (a Promise.blocking worker), false on the main thread. Such a thread ends after one closure and runs no destructor for its thread_locals, so a per-thread cache (std/fetch's connection pool) skips it rather than leak what it holds when the thread exits.
kindPlain
pub fn kindPlain(): i64kindSelRead
pub fn kindSelRead(): i64kindSelRecv
pub fn kindSelRecv(): i64kindSelSend
pub fn kindSelSend(): i64kindSelTimer
pub fn kindSelTimer(): i64kindSelWrite
pub fn kindSelWrite(): i64mainTaskStackSize
pub fn mainTaskStackSize(): i64The main task's stack. main keeps running the same code once it becomes a green task, and the OS main thread it used to run on gets 8 MB on darwin and linux — so the 1 MB default green stack would fault a recursive-descent parser that was fine as a plain main. Match the OS instead of the default.
nodeA
pub fn nodeA(): i64nodeArm
pub fn nodeArm(): i64nodeDeadline
pub fn nodeDeadline(): i64nodeFd
pub fn nodeFd(): i64nodeKind
pub fn nodeKind(): i64One layout serves every waiter: a plain parked task on a channel list, and each armed Select arm (channel recv/send, fd read/write, timer). Channel lists (recvWaitHead/sendWaitHead) hold kind 0/1/2; the scheduler's selFdHead holds kind 3/4/5.
nodeNext
pub fn nodeNext(): i64nodeSize
pub fn nodeSize(): i64promiseAll
pub fn promiseAll<T>(promises: Vec<Promise<T>>): Promise<Vec<T>>standalone generic functions to avoid recursive struct monomorphization
promiseRace
pub fn promiseRace<T>(promises: Vec<Promise<T>>): Promise<T>rtReadI64
pub fn rtReadI64(base: *u8, off: i64): i64The i64 at base + off. base must point at a live block that covers it.
rtWriteI64
pub fn rtWriteI64(base: *u8, off: i64, val: i64): voidStore an i64 at base + off. base must point at a live, writable block that covers it.
rtWritePtr
pub fn rtWritePtr(base: *u8, off: i64, val: *u8): voidStore a pointer at base + off. base must point at a live, writable block that covers it.
schedStructSize
pub fn schedStructSize(): i64schedulerBlockMain
pub fn schedulerBlockMain(): voidMain-context blocking driver: advance the scheduler, blocking the poll until a real event arrives instead of the 100ms cooperative spin. Callers loop on their own completion condition (Promise result, join cell, WaitGroup count).
schedulerCurrent
pub fn schedulerCurrent(): *u8The running task, or null when the caller is not on the green scheduler. A std API callable from either context branches on this; copy that check when you write one.
schedulerEnsureInit
pub fn schedulerEnsureInit(): voidschedulerExists
pub fn schedulerExists(): boolTrue once a green scheduler has been created on this thread. Lets a main- context waiter (schedulerCurrent()==0) tell "drive the scheduler" apart from "pure pthread, block on a cond".
schedulerPark
pub fn schedulerPark(): voidSuspend the current task and switch to the scheduler. The task lands on no scheduler list: the caller must have stashed schedulerCurrent() somewhere a future schedulerUnpark can find it, or the task never runs again. Unpark must not precede park on the same thread — cross-thread unparks are safe at any time because they are applied only in scheduler context. @parks: see schedulerYield.
schedulerPollMain
pub fn schedulerPollMain(): voidMain-context driver for blockers whose wake condition is NOT an fd event the poll can observe: a channel buffer filled under the mutex by a green sender that then parked, or by a foreign OS-thread sender that signals a condvar the main thread is not waiting on. A -1 poll would sleep past both. The bounded timeout keeps the scheduler advancing while letting the caller re-check its own condition (the channel buffer) between ticks — forward progress with at most a few ms of latency and no busy-spin.
schedulerRunMainRaw
pub fn schedulerRunMainRaw(fnPtr: *u8): voidRun the program's main as a green task, given its raw entry pointer.
Every blocking std call reads schedulerCurrent() to choose between parking and blocking the OS thread. main was never a task, so that read returned 0 and each such call blocked the one thread the cooperative scheduler runs on — starving the very tasks that would have satisfied it. A server that spawns its workers and then accepts in main is the natural way to write one, and it hung with no error and no timeout.
Fixing it per-call-site does not work (see docs/backlog.md): the fix is to leave the OS stack to the scheduler loop and give main a task of its own, which is Go's g0/m0 split. join from main context already drives the scheduler, so this is the whole change.
Go exit semantics: this returns as soon as main returns, and any task still running dies with the process. No task gets another step after the reap of main (schedulerRunOnce returns on it), main's own locals were dropped by its body on the way out, and stdout is flushed by libc's exit after @main returns. Abandoned tasks' frames are never unwound, so their locals' Drop never runs.
Called only from generated code, and only when the program can actually create green tasks — codegen emits the call after finding Task.spawnWithStack reachable from main. A program that never spawns keeps the plain entry, so it pays nothing and targets without stackful coroutines (wasm) stay buildable.
schedulerRunToCompletion
pub fn schedulerRunToCompletion(): voidDrive the scheduler until every spawned task has finished, then tear it down. Go exit semantics mean main no longer calls this implicitly; it stays as an explicit "run all outstanding tasks to completion" entry for programs that want the old drain-to-quiescence behavior (e.g. spawn a fleet of workers and block main until they all return without a WaitGroup).
schedulerTick
pub fn schedulerTick(): voidschedulerUnpark
pub fn schedulerUnpark(task: *u8): voidMake a parked task runnable (see unparkTask). @unsafe: task must be a pointer schedulerCurrent() or Task.raw() returned for a task that has not finished; anything else is written through.
schedulerWaitRead
pub fn schedulerWaitRead(fd: i32): voidPark until fd is readable. @parks: see schedulerYield.
Returns at once when the caller is not on the scheduler.
schedulerWaitWrite
pub fn schedulerWaitWrite(fd: i32): voidPark until fd is writable. @parks: see schedulerYield.
Returns at once when the caller is not on the scheduler.
schedulerYield
pub fn schedulerYield(): voidGive up the CPU to the other ready tasks and resume on the next scheduler turn.
@parks: this and the three wrappers below (wait-read, wait-write, park) are where a green task leaves the CPU through swapcontext, an extern the checker cannot see through. Everything that reaches one of them (channel send/recv, Select.wait, sleepMs, fd reads that park on EAGAIN, join) is derived as parking transitively, so a caller never repeats the attribute.
sCtx
pub fn sCtx(): i64scheduler field offsets
sCurrent
pub fn sCurrent(): i64sDrainWaiter
pub fn sDrainWaiter(): i64A task parked inside schedulerRunToCompletion, waiting for every OTHER task to finish. Woken by the reaper, not by an fd — "the task list is empty" is not an event the poll can observe.
selectRegisterFd
pub fn selectRegisterFd(node: *u8): voidLink an fd/read-or-write or timer node into the scheduler's select list and, for fd arms, register the fd with the event loop. Scheduler thread only (arms are always set up from the task that owns the Select).
selectStateFree
pub fn selectStateFree(st: *u8): voidselectStateSize
pub fn selectStateSize(): i64selectTryClaim
pub fn selectTryClaim(st: *u8, arm: i64): voidClaim a select for arm (see claimSelect). @unsafe: st must be a live selectStateNew() block.
selectUnregisterFd
pub fn selectUnregisterFd(node: *u8): voidUnlink a node from the select list and deregister its fd. Idempotent: a node that already fell off the list (never happens today, but cheap to guard) is skipped.
selectWaitState
pub fn selectWaitState(st: *u8): i64Task side, called after every arm is registered. Returns the winning arm index, parking until a source fires. See SelectState for the race handling.
Two contexts, and conflating them is what made this return -1 for so long. A green task can park: some other task (or the event loop) will claim an arm and unpark it. The MAIN context cannot — schedulerCurrent() is 0 there, so schedulerPark() no-ops, and the old code fell straight through to read an unclaimed -1 and return it. Callers could see that select had woken but not which arm fired, so the demos worked around it by draining every arm.
Main context takes the same shape channels already use (_schedulerPollMain in std/sync): nobody else drives the scheduler, so poll it a bounded tick and re-check the claim. Parking there, or driving the poll from inside the lock, is what hangs.
sElFd
pub fn sElFd(): i64sNumTasks
pub fn sNumTasks(): i64spawnOsThreadDetached
pub fn spawnOsThreadDetached(f: move () => void): voidRun f on a detached OS thread. Detached so its resources are reclaimed at thread exit with no join — nothing outlives the worker (the plan's no-leak requirement). If main returns first, Go exit semantics abandon it, symmetric with an abandoned green task. @thread: f runs on a real OS thread, so the checker holds its captures to Send and rejects unsynchronized mutable globals reached from its body.
sRunHead
pub fn sRunHead(): i64sRunTail
pub fn sRunTail(): i64ssClaimed
pub fn ssClaimed(): i64ssCond
pub fn ssCond(): i64Signalled by _selectTryClaim. Only the no-scheduler main context waits on it: a green task parks instead, and a main context WITH a scheduler polls it. That leaves the case where the only thing that can fire an arm is a foreign pthread (Promise.blocking with no green task anywhere) — there is no scheduler to drive and no task to unpark, so without this the wait had nothing to block on and returned -1 immediately.
sSelFdHead
pub fn sSelFdHead(): i64select fd/timer waiter list (scheduler thread only)
ssMtx
pub fn ssMtx(): i64Shared by all arms of one Select. claimed records the winning arm (-1 until a source fires); the mutex serializes the claim so at most one arm wins even across a foreign pthread firing a channel. parked gates the unpark so a claim that lands before the task commits to parking never queues a stale wake.
ssParked
pub fn ssParked(): i64ssTask
pub fn ssTask(): i64sWakeupId
pub fn sWakeupId(): i64sXferHead
pub fn sXferHead(): i64sXferMutex
pub fn sXferMutex(): i64cross-thread unpark transfer list (mutex-guarded; drained after each poll)
taskDone
pub fn taskDone(): i32taskOnPanicNote
pub fn taskOnPanicNote(note: &string): voidA line logged after the panic message if the current green task panics: what it was doing (std/http notes the request), so a panic in the journal names its input. At most 512 bytes are kept. No effect off a green task.
taskReady
pub fn taskReady(): i32taskRunning
pub fn taskRunning(): i32taskStructSize
pub fn taskStructSize(): i64taskWaitingIo
pub fn taskWaitingIo(): i32taskWaitingPark
pub fn taskWaitingPark(): i32parked: off every scheduler list; whoever holds the task pointer (e.g. a channel waiter entry) is responsible for a future schedulerUnpark
tClosureEnv
pub fn tClosureEnv(): i64tClosureFn
pub fn tClosureFn(): i64tCtx
pub fn tCtx(): i64task field offsets
tJoinCell
pub fn tJoinCell(): i64tJoiner
pub fn tJoiner(): i64tNext
pub fn tNext(): i64tSched
pub fn tSched(): i64tStack
pub fn tStack(): i64tStackSize
pub fn tStackSize(): i64tState
pub fn tState(): i64tWaitFd
pub fn tWaitFd(): i64tWaitWrite
pub fn tWaitWrite(): i64