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
resumethe 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
| Type | Kind | Description |
|---|---|---|
Context | record | What a library is handed while it subscribes: who is handling suspensions here, and how to give them a readiness pump. |
Handler | record | What a host installs to answer suspensions. |
Installed | record | An installed handler, and the obligation to put back what it displaced. |
Source | record | A readiness pump, and the handle that owns its lifetime. |
Waiting | record | What a park exposes to the handler driving it. |
Functions
| Function | Kind | Description |
|---|---|---|
ContextMT.canPark | function | — |
ContextMT.source | function | — |
ContextMT.uses | function | — |
SourceMT.release | function | — |
all | function | Runs every body concurrently and answers their values in order. |
batch | function | Runs every body concurrently with at most limit in flight, and answers their values in order. |
canSuspend | function | Whether a suspension performed here would be permitted, handler barriers included. |
create | function | Creates a coroutine that inherits the handler installed where it was created. |
gather | function | Runs every body concurrently and answers what each of them did, failures included. |
handled | function | Whether a handler is installed here, which is what blocking against cooperative means to a caller deciding before it... |
install | function | Installs handler for suspensions performed on this coroutine. |
poll | function | Drives every registered pump once. |
race | function | Runs every body concurrently and answers the first one to settle. |
source | function | Registers a readiness pump outside any subscription. |
suspend | function | Performs a suspension. |
WaitingMT.onResume | function | — |
WaitingMT.ready | function | — |
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
endMethods
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.SourceArguments
| Name | Type | Description |
|---|---|---|
? | suspension.Context | |
? | string | |
? | integer | |
? | function(): integer | |
? | (function(integer): integer)? |
Returns
| Type | Description |
|---|---|
suspension.Source |
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
endMethods
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.
Arguments
| Name | Type | Description |
|---|---|---|
? | suspension.Handler | |
? | suspension.Waiting | |
? | function(): nil |
Returns
| Type | Description |
|---|---|
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.
Arguments
| Name | Type | Description |
|---|---|---|
? | suspension.Handler |
Returns
| Type | Description |
|---|---|
boolean |
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
function release(self: suspension.Installed): nil
end
endMethods
Fields
| Name | Type | Description |
|---|---|---|
co | any | |
previous | any | |
handler | suspension.Handler | |
restored | boolean | Whether the slot this displaced has been put back. Separate from |
released | boolean | |
parks | any | 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)?
endMethods
release
Stops the pump being polled. Idempotent.
Arguments
| Name | Type | Description |
|---|---|---|
? | suspension.Source |
Returns
| Type | Description |
|---|---|
nil |
Fields
| Name | Type | Description |
|---|---|---|
name | string | What this is, for diagnostics and for ordering ties. |
priority | integer | 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 |
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
endMethods
ready
Whether the subscription has resumed.
Arguments
| Name | Type | Description |
|---|---|---|
? | suspension.Waiting |
Returns
| Type | Description |
|---|---|
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.
Arguments
| Name | Type | Description |
|---|---|---|
? | suspension.Waiting | |
? | function(): nil |
Returns
| Type | Description |
|---|---|
nil |
Fields
| Name | Type | Description |
|---|---|---|
operation | string | What is being waited for, which is what a stuck host reports. |
Functions#
ContextMT.canParkfunction#
function ContextMT.canPark(self: any): booleanArguments
| Name | Type | Description |
|---|---|---|
self | any |
Returns
| Type | Description |
|---|---|
boolean |
ContextMT.sourcefunction#
function ContextMT.source(self: any, name: string, priority: integer, poll: function(): integer, wait: (function(integer): integer)?): anyArguments
| Name | Type | Description |
|---|---|---|
self | any | |
name | string | |
priority | integer | |
poll | function(): integer | |
wait | (function(integer): integer)? |
Returns
| Type | Description |
|---|---|
any |
ContextMT.usesfunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any | |
source | any |
Returns
| Type | Description |
|---|---|
nil |
SourceMT.releasefunction#
function SourceMT.release(self: any): nilArguments
| Name | Type | Description |
|---|---|---|
self | any |
Returns
| Type | Description |
|---|---|
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
| Name | Description |
|---|---|
T |
Arguments
| Name | Type | Description |
|---|---|---|
bodies | {function(): T} | what to run |
Returns
| Type | Description |
|---|---|
{T} | each body's value, indexed as |
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
| Name | Description |
|---|---|
T |
Arguments
| Name | Type | Description |
|---|---|---|
bodies | {function(): T} | what to run |
limit | integer | how many may be in flight at once; below one is treated as one |
Returns
| Type | Description |
|---|---|
{T} | each body's value, indexed as |
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(): booleanReturns
| Type | Description |
|---|---|
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...): threadType parameters
| Name | Description |
|---|---|
A | |
R |
Arguments
| Name | Type | Description |
|---|---|---|
body | function(A...): R... | what the coroutine runs |
Returns
| Type | Description |
|---|---|
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
| Name | Description |
|---|---|
T |
Arguments
| Name | Type | Description |
|---|---|---|
bodies | {function(): T} | what to run |
Returns
| Type | Description |
|---|---|
{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(): booleanReturns
| Type | Description |
|---|---|
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.
Arguments
| Name | Type | Description |
|---|---|---|
handler | suspension.Handler | what answers suspensions inside the scope |
Returns
| Type | Description |
|---|---|
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(): integerReturns
| Type | Description |
|---|---|
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
| Name | Description |
|---|---|
T |
Arguments
| Name | Type | Description |
|---|---|---|
bodies | {function(): T} | what to run |
Returns
| Type | Description |
|---|---|
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.SourceArguments
| Name | Type | Description |
|---|---|---|
name | string | what this is, for diagnostics and ordering ties |
priority | integer | where in a pass it runs, lowest first |
poll | function(): integer | answers how many things it settled |
wait | (function(integer): integer)? |
Returns
| Type | Description |
|---|---|
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()?)): TType parameters
| Name | Description |
|---|---|
T |
Arguments
| Name | Type | Description |
|---|---|---|
operation | string | what is being waited for, which is what a stuck host reports |
subscribe | function(function(T), suspension.Context): (function()?) | hands over the resumption and the context, answers a cancellation |
Returns
| Type | Description |
|---|---|
T | whatever |
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): nilArguments
| Name | Type | Description |
|---|---|---|
self | any | |
waker | function(): nil |
Returns
| Type | Description |
|---|---|
nil |
WaitingMT.readyfunction#
function WaitingMT.ready(self: any): booleanArguments
| Name | Type | Description |
|---|---|---|
self | any |
Returns
| Type | Description |
|---|---|
boolean |