Module: nupp.suspension

Waiting, as an operation with an installable handler.

A library that must wait performs suspend, and does not decide how waiting happens. Where a handler is installed -- a scheduler, a game frame -- the handler answers; where none is, the built-in one drives the registered sources. One call site, no async colouring, and no policy parameter threaded through an API that did not want one.

The shape is tecs's, whose taskruntime has done this by hand inside one library for years: subscribe for a resumption, be resumed once, be cancellable. What is different is that the seam is the language's, so every library gets it rather than each re-implementing the dispatch or being unusable inside a frame.

Three properties the protocol is built to keep, each because losing it is a silent bug rather than a loud one:

  • Resumption has one path. A handler is given no writable state. It parks, it is woken, and it returns; the value only ever arrives through the one-shot resume the runtime made, so the guard against a second resumption cannot be walked around.
  • A park is always cancellable. A subscription that did not resume during the call must answer a cancellation, because a handler that has to abandon a wait -- a cancelled task, a shutdown -- otherwise has no way to tell the library. Only a subscription that already completed may answer nil, having nothing left to cancel.
  • The context is available before subscribing. A library registers its readiness pump with whoever is handling suspensions, which means it has to know who that is at subscription time rather than after.

Measured against tecs's own numbers (bench/suspension-baseline.lua), the row that matters is the ready path -- an await whose subscription completes during the call, which is most of the cost of waiting even when waiting really happens. That path allocates no park and never wakes a handler.

See plans/suspension.md.

Module contents

Types

TypeKindDescription
ContextrecordWhat a library is handed while it subscribes: who is handling suspensions here, and how to give them a readiness pump.
HandlerrecordWhat a host installs to answer suspensions.
InstalledrecordAn installed handler, and the obligation to put back what it displaced.
SourcerecordA readiness pump, and the handle that owns its lifetime.
WaitingrecordWhat a park exposes to the handler driving it.

Functions

FunctionKindDescription
ContextMT.canParkfunction
ContextMT.sourcefunction
ContextMT.usesfunction
SourceMT.releasefunction
allfunctionRuns every body concurrently and answers their values in order.
batchfunctionRuns every body concurrently with at most limit in flight, and answers their values in order.
canSuspendfunctionWhether a suspension performed here would be permitted, handler barriers included.
createfunctionCreates a coroutine that inherits the handler installed where it was created.
gatherfunctionRuns every body concurrently and answers what each of them did, failures included.
handledfunctionWhether a handler is installed here, which is what blocking against cooperative means to a caller deciding before it...
installfunctionInstalls handler for suspensions performed on this coroutine.
pollfunctionDrives every registered pump once.
racefunctionRuns every body concurrently and answers the first one to settle.
sourcefunctionRegisters a readiness pump outside any subscription.
suspendfunctionPerforms a suspension.
WaitingMT.onResumefunction
WaitingMT.readyfunction

Types#

Contextrecord#

What a library is handed while it subscribes: who is handling suspensions here, and how to give them a readiness pump.

Available before the subscription runs, because registering a pump is part of subscribing rather than something to do afterwards.

record suspension.Context
    source: function(
        suspension.Context,
        string,
        integer,
        function(): integer,
        (function(integer): integer)?
    ): suspension.Source
    uses: function(suspension.Context, suspension.Source): nil
    canPark: function(suspension.Context): boolean
end

Methods

source

Registers a pump this wait needs driven. Answers the handle that stops it.

source: function(
        suspension.Context,
        string,
        integer,
        function(): integer,
        (function(integer): integer)?
    ): suspension.Source
Arguments
NameTypeDescription
?suspension.Context
?string
?integer
?function(): integer
?(function(integer): integer)?
Returns
TypeDescription
suspension.Source
uses

Associates a shared source with this wait without taking ownership of it. Shared clients use this so one source may serve several simultaneous parks.

uses: function(suspension.Context, suspension.Source): nil
Arguments
NameTypeDescription
?suspension.Context
?suspension.Source
Returns
TypeDescription
nil
canPark

Whether a suspension may happen here at all. A host with regions of its own -- tecs's barriers -- answers false inside them.

canPark: function(suspension.Context): boolean
Arguments
NameTypeDescription
?suspension.Context
Returns
TypeDescription
boolean

Handlerrecord#

What a host installs to answer suspensions.

record suspension.Handler
    park: function(suspension.Handler, suspension.Waiting, function(): nil): nil
    canPark: function(suspension.Handler): boolean
    shutdown: function(suspension.Handler): nil
end

Methods

park

Waits until waiting:ready(). Returning before that is a broken handler and suspend reports it rather than handing back a value nobody produced.

cancel abandons the subscription. A handler that gives up on a park -- a cancelled task, a shutdown -- must call it, and must then raise rather than return.

park: function(suspension.Handler, suspension.Waiting, function(): nil): nil
Arguments
NameTypeDescription
?suspension.Handler
?suspension.Waiting
?function(): nil
Returns
TypeDescription
nil
canPark

Whether a suspension may happen here. False inside a host's own barrier, which is the run-time backstop for what nosuspend checks while compiling.

canPark: function(suspension.Handler): boolean
Arguments
NameTypeDescription
?suspension.Handler
Returns
TypeDescription
boolean
shutdown

Told when a handled extent ends, so a handler owning parks or pumps can abandon them deterministically rather than at collection.

shutdown: function(suspension.Handler): nil
Arguments
NameTypeDescription
?suspension.Handler
Returns
TypeDescription
nil

Installedrecord#

An installed handler, and the obligation to put back what it displaced.

record suspension.Installed
    co: any
    previous: any
    handler: suspension.Handler
    restored: boolean

    released: boolean
    parks: any
    @drop

    function release(self: suspension.Installed): nil
    end
end

Methods

release
release: function release(self: suspension.Installed): nil
Arguments
NameTypeDescription
selfsuspension.Installed
Returns
TypeDescription
nil

Fields

NameTypeDescription
coany
previousany
handlersuspension.Handler
restoredboolean

Whether the slot this displaced has been put back. Separate from released, which additionally means every park it accepted has finished: a release that could not drain its parks has restored the slot and is still retryable.

releasedboolean
parksany

Parks this extent accepted and has not seen finish. Held per installation, not per handler: one handler may be installed twice, and keying on it would let a nested extent cancel the enclosing one's parks on its way out.

Sourcerecord#

A readiness pump, and the handle that owns its lifetime.

Returned rather than named, so two libraries registering "io" do not collide and neither has to invent a unique string. Releasing is idempotent.

record suspension.Source
    release: function(suspension.Source): nil
    name: string
    priority: integer
    wait: (function(integer): integer)?
end

Methods

release

Stops the pump being polled. Idempotent.

release: function(suspension.Source): nil
Arguments
NameTypeDescription
?suspension.Source
Returns
TypeDescription
nil

Fields

NameTypeDescription
namestring

What this is, for diagnostics and for ordering ties.

priorityinteger

Where in a pass it runs, lowest first.

wait(function(integer): integer)?

Optionally blocks for at most the supplied milliseconds. Only the built-in driver calls this; a host drives poll and therefore never sleeps here.

Waitingrecord#

What a park exposes to the handler driving it.

Deliberately no writable fields. A handler waits and is woken; it never supplies the value, because the value has exactly one path and that path is guarded.

record suspension.Waiting
    ready: function(suspension.Waiting): boolean
    onResume: function(suspension.Waiting, function(): nil): nil
    operation: string
end

Methods

ready

Whether the subscription has resumed.

ready: function(suspension.Waiting): boolean
Arguments
NameTypeDescription
?suspension.Waiting
Returns
TypeDescription
boolean
onResume

Registers what to run when it does. One waker; the handler installing a second replaces the first, which is what re-parking means.

onResume: function(suspension.Waiting, function(): nil): nil
Arguments
NameTypeDescription
?suspension.Waiting
?function(): nil
Returns
TypeDescription
nil

Fields

NameTypeDescription
operationstring

What is being waited for, which is what a stuck host reports.

Functions#

ContextMT.canParkfunction#

function ContextMT.canPark(self: any): boolean

Arguments

NameTypeDescription
selfany

Returns

TypeDescription
boolean

ContextMT.sourcefunction#

function ContextMT.source(self: any, name: string, priority: integer, poll: function(): integer, wait: (function(integer): integer)?): any

Arguments

NameTypeDescription
selfany
namestring
priorityinteger
pollfunction(): integer
wait(function(integer): integer)?

Returns

TypeDescription
any

ContextMT.usesfunction#

function ContextMT.uses(self: any, source: any): nil

Arguments

NameTypeDescription
selfany
sourceany

Returns

TypeDescription
nil

SourceMT.releasefunction#

function SourceMT.release(self: any): nil

Arguments

NameTypeDescription
selfany

Returns

TypeDescription
nil

suspension.allfunction#

Runs every body concurrently and answers their values in order.

A branch that fails decides the whole call, but not until every branch has settled: unwinding while siblings are still parked would strand their subscriptions, and a caller who asked for all of them has no use for some of them.

function suspension.all<T>(bodies: {function(): T}): {T}

Type parameters

NameDescription
T

Arguments

NameTypeDescription
bodies{function(): T}

what to run

Returns

TypeDescription
{T}

each body's value, indexed as bodies was

Raises

  • the first error any branch raised

suspension.batchfunction#

Runs every body concurrently with at most limit in flight, and answers their values in order.

all with a ceiling, for fanning out over more work than the thing underneath will take at once -- child processes against cores, requests against a pool. A branch finishing is what lets the next one start.

function suspension.batch<T>(bodies: {function(): T}, limit: integer): {T}

Type parameters

NameDescription
T

Arguments

NameTypeDescription
bodies{function(): T}

what to run

limitinteger

how many may be in flight at once; below one is treated as one

Returns

TypeDescription
{T}

each body's value, indexed as bodies was

Raises

  • the first error any branch raised

suspension.canSuspendfunction#

Whether a suspension performed here would be permitted, handler barriers included.

The run-time backstop for what nosuspend checks while compiling, for the calls static analysis could not see.

function suspension.canSuspend(): boolean

Returns

TypeDescription
boolean

suspension.createfunction#

Creates a coroutine that inherits the handler installed where it was created.

Stock coroutine.create gives the new thread no handler, so a library that spawns one -- a worker, a pipeline stage -- would find itself blocking inside a frame that was handling suspensions perfectly well. Inheritance is what makes a handled extent mean the work started inside it rather than only the frames literally below it.

Inherited at creation rather than at resumption. What answers is the handler that was in force where the coroutine was made, which is the lexical reading and the one a reader can point at; a resumption-time rule would make a coroutine's behaviour depend on who happened to resume it.

function suspension.create<A..., R...>(body: function(A...): R...): thread

Type parameters

NameDescription
A
R

Arguments

NameTypeDescription
bodyfunction(A...): R...

what the coroutine runs

Returns

TypeDescription
thread

the coroutine

suspension.gatherfunction#

Runs every body concurrently and answers what each of them did, failures included.

all for a caller who wants the failures rather than the first of them. Both arrays are indexed as bodies was, and exactly one of them holds an entry per branch.

function suspension.gather<T>(bodies: {function(): T}): {T?}, {any}

Type parameters

NameDescription
T

Arguments

NameTypeDescription
bodies{function(): T}

what to run

Returns

TypeDescription
{T?}

each body's value where it returned, and each body's error where it raised

{any}

suspension.handledfunction#

Whether a handler is installed here, which is what blocking against cooperative means to a caller deciding before it commits to waiting.

function suspension.handled(): boolean

Returns

TypeDescription
boolean

suspension.installfunction#

Installs handler for suspensions performed on this coroutine.

A scope rather than a callback, deliberately. Wrapping a body would make the extent a closure boundary -- which costs the caller its multi-value results and puts the resource model's closure rules in the way of something that has nothing to do with them -- so this hands back the obligation and lexical cleanup discharges it:

do
    local handling = suspension.install(scheduler)
    runFrame()
end

The extent is dynamic and per-coroutine: a suspension at any depth, through any library, reaches the innermost handler installed on the coroutine performing it.

function suspension.install(handler: suspension.Handler): suspension.Installed

Arguments

NameTypeDescription
handlersuspension.Handler

what answers suspensions inside the scope

Returns

TypeDescription
suspension.Installed

the installation, which must be released

suspension.pollfunction#

Drives every registered pump once. Answers how many things settled.

Public because a host with its own loop drives the same pumps rather than a private copy of them.

function suspension.poll(): integer

Returns

TypeDescription
integer

suspension.racefunction#

Runs every body concurrently and answers the first one to settle.

The losers are abandoned rather than forgotten: each is resumed once so its park cancels and its branch unwinds. A loser that had not started never starts.

function suspension.race<T>(bodies: {function(): T}): T?, integer?

Type parameters

NameDescription
T

Arguments

NameTypeDescription
bodies{function(): T}

what to run

Returns

TypeDescription
T?

the winner's value, and which body won

integer?

Raises

  • the winner's error, when the first to settle settled by failing

suspension.sourcefunction#

Registers a readiness pump outside any subscription.

Inside one, prefer the context's source: a pump registered there belongs to the handler answering that wait, and a handler that owns its pumps can shut them down.

function suspension.source(name: string, priority: integer, poll: function(): integer, wait: (function(integer): integer)?): suspension.Source

Arguments

NameTypeDescription
namestring

what this is, for diagnostics and ordering ties

priorityinteger

where in a pass it runs, lowest first

pollfunction(): integer

answers how many things it settled

wait(function(integer): integer)?

Returns

TypeDescription
suspension.Source

the handle that stops it

suspension.suspendfunction#

Performs a suspension.

subscribe is handed a one-shot resume and the context of whoever is handling suspensions here, and answers a cancellation. It may answer nil only when it resumed during the call, having nothing left to cancel; otherwise a cancellation is required, because a handler that has to abandon the wait needs a way to say so.

function suspension.suspend<
    T
    >(operation: string, subscribe: function(function(T), suspension.Context): (function()?)): T

Type parameters

NameDescription
T

Arguments

NameTypeDescription
operationstring

what is being waited for, which is what a stuck host reports

subscribefunction(function(T), suspension.Context): (function()?)

hands over the resumption and the context, answers a cancellation

Returns

TypeDescription
T

whatever resume was given

Raises

  • when the subscription resumes twice, answers no cancellation for a real park, suspends where the handler forbids it, or is handled by one that returns without resuming

WaitingMT.onResumefunction#

function WaitingMT.onResume(self: any, waker: function(): nil): nil

Arguments

NameTypeDescription
selfany
wakerfunction(): nil

Returns

TypeDescription
nil

WaitingMT.readyfunction#

function WaitingMT.ready(self: any): boolean

Arguments

NameTypeDescription
selfany

Returns

TypeDescription
boolean