nupp.tasks

An application task scope.

nupp.suspension's combinators own a family that is complete when the call is written: all cannot be handed a fourth body once it is running. create makes a coroutine that inherits a handler and says nothing about its result, failure or lifetime. Neither is what a server, a loading pipeline or a scene needs, which is a place to put children discovered over time and one answer to what happens when the body returns, a child fails, or the whole thing is cancelled.

nupp.workers has that shape already for CPU work, and its terminal cleanup cannot suspend, so leaving its scope blocks the thread until unawaited children finish. Correct for a worker scope, wrong inside a frame. A task scope owns one lazily and closes it through the suspension-aware path before the task scope returns.

The scheduling contract is with the host, not with each child. A host sees one aggregate and decides when it runs; this decides which of its children run then, in FIFO order, up to a bounded number of activations per host turn. Nesting divides that bound rather than multiplying it, because the token belongs to the turn rather than to a scope.

local greeting = ""
with scope = nupp.tasks.open() do
    const hello = scope:spawn(function(): string
        return "hello"
    end)
    const world = scope:spawn(function(): string
        return "world"
    end)
    greeting = hello:await() .. " " .. world:await()
end

assert(greeting == "hello world")

A scope is opened with open, held by a with, and settled when the block ends: every child has run, been cancelled, or unwound by then. open takes a limit, which parks spawn and fork while that many children are live, and a deadline. A running child cooperates by calling checkpoint, so it can stop promptly when the scope is cancelled.

local total = 0
with scope = nupp.tasks.open(deadline = 1000) do
    const sum = scope:spawnNamed("sum values", function(): integer
        local answer = 0
        for value = 1, 1000 do
            nupp.tasks.checkpoint()
            answer = answer + value
        end
        return answer
    end)
    total = sum:await()
end

assert(total == 500500)

Module contents

Types

TypeKindDescription
CancellationrecordWhat a cancelled task raises.
ScoperecordThe scope a run body is handed.
TasktypeThe handle one spawn answers.

Functions

FunctionKindDescription
ForkMT.awaitfunction
ForkMT.cancelfunction
ForkMT.isDonefunction
ForkMT.statusfunction
TaskMT.awaitfunction
TaskMT.cancelfunction
TaskMT.isDonefunction
TaskMT.statusfunction
checkpointfunctionRaises where the current task has been cancelled or has run out of time.
deadlinefunctionThe effective deadline of the current task, or nil where there is none.
gatherfunctionRuns every body concurrently and answers what each of them did, failures included.
isCancelledfunctionWhether a caught value is a task cancellation.
openfunctionOpens a scope, to be held by a with.
settlefunctionSettles an opened scope, which is what leaving its with does.

Types#

Cancellationrecord#

record tasks.Cancellation
    operation: string
    reason: string?
end

What a cancelled task raises.

Nominal rather than a string, so isCancelled recognizes it without matching text, and so a program that catches everything still sees something it can ask about. tostring renders it because an uncaught one reaches a human.

Fields

operation#
operation: string

What was cancelled, as it was named when it was started.

reason#
reason: string?

Why, where a caller supplied a reason.

Scoperecord#

record tasks.Scope
    spawn: function<F>(
        borrows self: tasks.Scope,
        takes body: F,
        ...: unpackof Parameters(F)
    ): tasks.Task<F> borrows (self)
    spawnNamed: function<F>(borrows self: tasks.Scope, name: string, takes body: F): tasks.Task<F> borrows (self)
    fork: function<F is Submittable>(
        borrows self: tasks.Scope,
        F,
        ...: unpackof nupp.runtime.services.workers.Submitted(F)
    ): tasks.Task<F> borrows (self)
    cancel: function(borrows self: tasks.Scope, reason: string?): nil
end

The scope a run body is handed.

Not affine, and deliberately: run owns the extent, so there is no obligation for a body to discharge and no way for one to end the scope early. What the body can do is add children to it and reach the worker scope it owns.

Methods

spawn#
spawn: function<F>(
    borrows self: tasks.Scope,
    takes body: F,
    ...: unpackof Parameters(F)
): tasks.Task<F> borrows (self)

Starts a child under this scope: scope:spawn(arguments..., f).

Arguments
NameTypeDescription
borrows selftasks.Scope
takes bodyF
...unpackof Parameters(F)
Returns
TypeDescription
tasks.Task<F> borrows (self)
spawnNamed#
spawnNamed: function<F>(borrows self: tasks.Scope, name: string, takes body: F): tasks.Task<F> borrows (self)

Starts a named child under this scope.

Arguments
NameTypeDescription
borrows selftasks.Scope
namestring
takes bodyF
Returns
TypeDescription
tasks.Task<F> borrows (self)
fork#
fork: function<F is Submittable>(
    borrows self: tasks.Scope,
    F,
    ...: unpackof nupp.runtime.services.workers.Submitted(F)
): tasks.Task<F> borrows (self)

Starts a child on a worker lane: scope:fork(arguments..., f).

Arguments
NameTypeDescription
borrows selftasks.Scope
?F
...unpackof nupp.runtime.services.workers.Submitted(F)
Returns
TypeDescription
tasks.Task<F> borrows (self)
cancel#
cancel: function(borrows self: tasks.Scope, reason: string?): nil

Requests cancellation of every child.

Arguments
NameTypeDescription
borrows selftasks.Scope
reasonstring?
Returns
TypeDescription
nil

Tasktype#

type tasks.Task<F> = TaskType(F)

The handle one spawn answers.

Its await result pack is the body's, which is why this is derived from the function type rather than declared once over any.

Type parameters

NameDescription
F

Functions#

ForkMT.awaitfunction#

function ForkMT.await(self: any)

Arguments

NameTypeDescription
selfany

ForkMT.cancelfunction#

function ForkMT.cancel(self: any, reason: string?): boolean

Arguments

NameTypeDescription
selfany
reasonstring?

Returns

TypeDescription
boolean

ForkMT.isDonefunction#

function ForkMT.isDone(self: any): boolean

Arguments

NameTypeDescription
selfany

Returns

TypeDescription
boolean

ForkMT.statusfunction#

function ForkMT.status(self: any): string

Arguments

NameTypeDescription
selfany

Returns

TypeDescription
string

TaskMT.awaitfunction#

function TaskMT.await(self: any)

Arguments

NameTypeDescription
selfany

TaskMT.cancelfunction#

function TaskMT.cancel(self: any, reason: string?): boolean

Arguments

NameTypeDescription
selfany
reasonstring?

Returns

TypeDescription
boolean

TaskMT.isDonefunction#

function TaskMT.isDone(self: any): boolean

Arguments

NameTypeDescription
selfany

Returns

TypeDescription
boolean

TaskMT.statusfunction#

function TaskMT.status(self: any): string

Arguments

NameTypeDescription
selfany

Returns

TypeDescription
string

tasks.checkpointfunction#

function tasks.checkpoint(): nil

Raises where the current task has been cancelled or has run out of time.

The one authored cancellation point, and the only thing that reaches a body computing without parking. It never suspends, so a nosuspend region and a worker lane can both call it, and outside any task it does nothing.

for index = 1, #items do
    tasks.checkpoint()
    consume(items[index])
end

Returns

TypeDescription
nil

Raises

  • the cancellation, where one has been requested

tasks.deadlinefunction#

function tasks.deadline(): number?

The effective deadline of the current task, or nil where there is none.

So a body can size its work rather than discover the bound by being cancelled part way through it.

Returns

TypeDescription
number?

an absolute monotonic reading, as nupp.time.now answers

tasks.gatherfunction#

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

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

The fail-soft family. Both arrays are indexed as bodies was, and exactly one of them holds an entry per branch, so a caller who has to see every outcome sees them beside each other. A scope is the fail-fast answer to the same question: use one where the first failure should end the rest.

const values, errors = nupp.tasks.gather({
    function(): string return fetch(primary) end,
    function(): string return fetch(mirror) end,
})

Type parameters

NameDescription
T

Arguments

NameTypeDescription
bodies{function(): T}

what to run

Returns

TypeDescription
{T?}

each body's value, where it returned

{any}

each body's error, where it raised

Raises

  • the enclosing deadline, where one passed before the family settled

tasks.isCancelledfunction#

function tasks.isCancelled(value: any): boolean

Whether a caught value is a task cancellation.

The one question a pcall around task work has to be able to ask, because cancellation is not a failure and should usually be re-raised rather than reported.

const ok, problem = pcall(work)
if not ok and not tasks.isCancelled(problem) then
    report(problem)
end

Arguments

NameTypeDescription
valueany

whatever was caught

Returns

TypeDescription
boolean

whether this is a cancellation

tasks.openfunction#

function tasks.open(limit: integer?, deadline: number?): affine(tasks.Scope, tasks.settle)

Opens a scope, to be held by a with.

The block is the scope's body. Children started in it with spawn and fork are its family, and leaving the block -- normally, by break or return, or by an error -- settles them: every one has run, been cancelled, or unwound before the block is left. A child's failure is the scope's from the moment it happens, cancels its siblings, and is raised where the block is left, whether or not anything awaited that child.

With a limit, spawn and fork park while that many children are live, so a loop that fans out over a source is bounded by the loop itself: nothing is pulled from the source until there is room to run it. With a deadline, in milliseconds of the monotonic clock, expiry requests ordinary cancellation. A scope opened inside another takes the earlier of the two deadlines: a child may bound itself more tightly than its parent did, and may not extend what its parent already promised.

Both are named arguments: open(), open(limit = 8), open(deadline = 500), or open(limit = 8, deadline = 500).

const sizes: {integer} = {}
with scope = nupp.tasks.open(limit = 8) do
    for index, url in ipairs(urls) do
        scope:spawn(sizes, index, url, storeSize)
    end
end

Arguments

NameTypeDescription
limitinteger?

how many children may be live at once, or nil for no bound

deadlinenumber?

how long the whole scope may take, in milliseconds, or nil

Returns

TypeDescription
affine(tasks.Scope, tasks.settle)

the scope, settled when its with ends

Raises

  • where the limit is not a positive integer or the deadline is not a finite non-negative number

tasks.settlefunction#

function tasks.settle(takes scope: tasks.Scope): nil

Settles an opened scope, which is what leaving its with does.

Every child has run, been cancelled, or unwound when this returns, and the worker scope, where one was opened, has been closed through the suspension-aware path. It is a settling terminal: it parks until that is so, and so is refused inside a nosuspend region. Idempotent, so a scope settled by hand before its block ends settles once.

A failure the block itself raises does not cancel the children: they run to completion before it propagates. Call cancel first where that is not wanted.

with scope = nupp.tasks.open() do
    scope:spawn(function(): nil work() end)
end

Arguments

NameTypeDescription
takes scopetasks.Scope

the scope open answered

Returns

TypeDescription
nil

Raises

  • the first failure a child had, or the cancellation a deadline caused