sysl

Async and tasks

Async functions, the Task they hand back, await, the executor contract, frames on the heap, cancellation by drop, and when a task may cross a concurrency domain.

async written on a function makes calling it hand back a Task[T] instead of running it, and await t runs that task to the T it ends with. An async function may await another, so a chain of them reads as straight-line code while every await is a place the whole chain can stop and be picked up again later.

async read_sensor(channel: int) -> int = channel * 100 + 17

async average(a: int, b: int) -> int
    val x = await read_sensor(a)
    val y = await read_sensor(b)
    (x + y) / 2

print(block_on(average(1, 3)))
217

The language supplies the words and the contract, and nothing that schedules. block_on above runs one task to its end on the calling thread; anything that interleaves tasks — an event loop, a run queue, a timer wheel — is an ordinary program written against four builtins (the executor contract). No executor lives in the language or the standard library (where the executors live).

Calling an async function makes a task, and runs nothing

A task is lazy. The call copies its arguments into a frame of its own and stops before the first statement of the body; nothing in the body runs until something drives the task.

async read_sensor(channel: int) -> int
    print(s"reading channel $channel")
    channel * 100 + 17

val t = read_sensor(2)

print("task made")
print(block_on(t))
task made
reading channel 2
217

Task[T] is a type a program may write, and a task held in a binding is awaited whenever the body gets to it. An async function with no result is a Task[unit], and its await is a statement:

async sample() -> int = 21

async log_tick()
    print("tick")

async run() -> int
    val pending: Task[int] = sample()

    await log_tick()

    val beat: Task[unit] = log_tick()

    await beat
    await pending * 2

print(block_on(run()))
tick
tick
42

await pending * 2 is (await pending) * 2 — await binds tighter than arithmetic.

Task takes one type argument

The argument is the type the task ends with, and there is no second one for an error: a task that can fail ends with a Result.

async check(t: Task[int, string]) -> int = 0
'Task' takes one type argument — the type the task ends with — and 2 were written

A program’s own Task is the nearer name

Task is a name the compiler supplies, not a reserved word, so a program that declares a type called Task gets its own wherever it writes the word — and an async function’s call still hands back the compiler’s task, which the program then awaits as usual:

struct Task
    id: int
    priority: int

async read_sensor(channel: int) -> int = channel * 100 + 17

async schedule(t: Task) -> int = await read_sensor(t.id) + t.priority

print(block_on(schedule(Task(2, 5))))
222

await runs a task to its result

await is a prefix on an expression whose value is a task. Inside an async function it runs that task until it ends and is the value it ended with; each time the awaited task stops partway, the awaiting one stops with it, so a stop anywhere in a chain travels out to whatever is driving the outermost task.

Where await binds

await takes the operand’s calls, member selections and indexes, and stops at the first ? — so the ? applies to what was awaited, which is the order a fallible async call wants:

writtenread as
await dev.read(512)?(await dev.read(512))?
await f()?.x((await f())?).x
await xs[0]await (xs[0])
await f() + 1(await f()) + 1
1 + await f()1 + (await f())
-await f()-(await f())

Nothing written without await reads differently for it: -a? is still -(a?) and a.b?.c is still (a.b?).c.

struct Dma
    channel: int

    async read(self, n: int) -> Result[int, string] =
        if n > 512 then Err(s"$n bytes is more than one transfer") else Ok(n)

async fill(d: Dma, first: int, second: int) -> Result[int, string]
    val a = await d.read(first)?
    val b = await d.read(second)? + 1
    Ok(a + b)

show(r: Result[int, string]) -> string = r match
    Ok(n) -> s"moved $n"
    Err(why) -> s"refused: $why"

val dev = Dma(3)

print(show(block_on(fill(dev, 256, 128))))
print(show(block_on(fill(dev, 256, 4096))))
moved 385
refused: 4096 bytes is more than one transfer

A task where its value was wanted

Leaving out await is the mistake the type system sees first, and where the position wanted the very type the task ends with, the refusal says what was left out:

async read_sensor(channel: int) -> int = channel * 100 + 17

async run() -> int
    val raw: int = read_sensor(1)
    raw / 2

print(block_on(run()))
cannot initialize 'raw': declared int but the value is Task[int] — calling an 'async' function hands back its task rather than running it, so write 'await' in front to run it and take the int it ends with

The same sentence is added at an argument, at a return, and at a body’s last expression:

async read_sensor(channel: int) -> int = channel * 100 + 17

celsius(raw: int) -> int = raw / 10

async run() -> int
    celsius(read_sensor(1))

print(block_on(run()))
'raw' of 'celsius' is int, but Task[int] was given — calling an 'async' function hands back its task rather than running it, so write 'await' in front to run it and take the int it ends with

The repair is the one the message names, at the binding or in the argument itself:

async read_sensor(channel: int) -> int = channel * 100 + 17

celsius(raw: int) -> int = raw / 10

async run() -> int
    val raw: int = await read_sensor(1)
    celsius(raw) + celsius(await read_sensor(2))

print(block_on(run()))
32

The hint is given only where await would actually fix it. A task where some other type was wanted is an ordinary mismatch, and says nothing about await:

async read_sensor(channel: int) -> int = channel * 100 + 17

async run() -> int
    val label: string = read_sensor(1)
    1

print(block_on(run()))
cannot initialize 'label': declared string but the value is Task[int]

Where await may be written

Only in the body of an async function. A stop has to have somewhere to stop to, and an ordinary function has no task of its own to suspend:

async read_sensor(channel: int) -> int = channel * 100 + 17

scaled(channel: int) -> int
    await read_sensor(channel) * 2

print(scaled(1))
'await' may only be written in the body of an 'async' function, and the function it stands in is not one — write 'async' in front of its name to make it one

Not in the entry file’s statements, which are the program’s main — C calls it, and C has no way to drive a task. That is what block_on is for, as in every program on this page:

async read_sensor(channel: int) -> int = channel * 100 + 17

print(await read_sensor(1))
'await' may only be written in the body of an 'async' function, and this is the entry file's body — the program's 'main', which C calls directly and cannot drive a task through. Move the work into an 'async' function

Not in a closure, even one written inside an async function. A closure is a function of its own and is never async, so there is no task for it to wait in:

async read_sensor(channel: int) -> int = channel * 100 + 17

async sweep() -> int
    val reader = (c: int) -> await read_sensor(c)
    reader(1) + reader(2)

print(block_on(sweep()))
'await' cannot be written in a closure: a closure is not itself async, even inside an 'async' function, so there is no task of its own for it to wait in. Await in the enclosing function's body and hand the closure the value

Awaiting in the body and handing the closure what it needs:

async read_sensor(channel: int) -> int = channel * 100 + 17

async sweep() -> int
    val first = await read_sensor(1)
    val second = await read_sensor(2)
    val offset = (c: int) -> c - 17
    offset(first) + offset(second)

print(block_on(sweep()))
300

And only on a task:

async report(label: string) -> int
    await label
    1

print(block_on(report("adc")))
'await' needs a Task, which is what calling an 'async' function hands back, but this value is string

await stands wherever a value of the async function’s body may — a binding, an argument, an if‘s branch, a match‘s arm.

What may be async

A function, a generic function, and a method. A generic async function runs at each type its calls solved, as any generic does:

async latest[T](readings: []T) -> T
    await yield_now()
    readings[readings.len - 1]

async report() -> string
    val volts = await latest([3.1, 3.3, 3.2])
    val label = await latest(["idle", "sampling"])
    s"$label at $volts V"

print(block_on(report()))
sampling at 3.2 V

Nothing that something other than sysl calls directly may be async, because the caller would receive a task and have no way to drive it. An @exported function is the first of those:

async sample() -> int = 7

@export async tick() -> int = 1

print(block_on(sample()))
an '@export'ed function cannot be 'async': C calls it directly, and calling an async function only makes its task, which C has no way to drive. Export a plain function and keep the 'async' one behind it

The shape the message names — a plain export that drives the async function:

async read_sensor(channel: int) -> int = channel * 100 + 17

@export("sensor_read")
sensor_read(channel: int) -> int = block_on(read_sensor(channel))

print(sensor_read(4))
417

An extern names a C function, and an interrupt handler is called by the hardware:

async extern "getpid" c_pid() -> int
an 'extern' cannot be 'async': it names a C function, called directly, and calling an async function only makes its task, which C has no way to drive
async interrupt timer()
    print(1)
an interrupt handler cannot be 'async': the hardware calls it directly, and calling an async function only makes its task, which nothing there can drive

async marks a declaration, so it cannot stand in front of a statement — the entry file’s statements are main, for the same reason await cannot stand among them:

print("booting")
async print("sampling")
'async' marks a function declaration, and what follows it here is a statement. The statements of a body run where they stand — the entry file's are the program's 'main', which C calls directly and cannot drive a task through — so put the work in an 'async' function instead

A property cannot be async: it is read like a field, and a field read has no call to hand a task back from.

struct Adc
    channel: int

    async reading -> int = self.channel * 100
a property cannot be 'async': it is read like a field, with no call to hand a task back from — make it a method, written with '()'

As a method it is an ordinary async member, called and awaited through the receiver:

struct Adc
    channel: int

    async reading(self) -> int = self.channel * 100

print(block_on(Adc(3).reading()))
300

async and await are contextual

Neither word is reserved. async is a keyword only where a declaration follows it, and await only where an operand does, so both stay available as names — including in a program that also uses them as keywords:

var async = 1
var await = 2

print(async + await)
3

Running a task

block_on

block_on(t) runs t on the calling thread until it ends, and is its result. It is the one driver the compiler supplies, and it is an ordinary call: it may stand in the entry file, in a plain function, and behind an @export.

block_on is the only thing that could ever wake the task it drives — there is no other thread running it — so a task that parks and is never woken traps rather than spinning for ever:

async settle()
    await park((wake) -> ())

block_on(settle())

That program builds and stops with a trap. (A block on this page can only claim a program that runs to the end or one the compiler refuses, so this one is shown without a claim.)

The executor contract: step, park and yield_now

Four builtins are the whole interface between a task and whatever schedules it:

builtinwhat it does
block_on(t)runs t on this thread until it ends; its value is the result
step(t, wake: &Fn() -> unit) -> boolresumes t once; true once t has ended
await park(register: &Fn(&Fn() -> unit) -> unit)hands register a waker and suspends until the waker is called
await yield_now()wakes the task being stepped and suspends once

A waker is a &Fn() -> unit. An executor hands step the one it wants called when the task can run again; a task’s park hands its registrar a waker of its own, which the registrar keeps wherever the event it waits for will find it — a timer, an interrupt, a completion queue. Calling that waker calls the executor’s.

So an executor is a run queue and a loop over step. Here is a whole one, with a timer as the event source:

import sysl.buf.{Buf, buf}

// The executor: a run queue of task numbers, stepped in order until it is empty.
static var tasks: [2]Option[Task[unit]] = [None, None]
static var ready: Buf[usize] = buf()

spawn(id: usize, t: Task[unit])
    tasks[id] = Some(t)
    ready.push(id)

run()
    var i: usize = 0

    while i < ready.len()
        val id = ready[i]

        i += 1

        tasks[id] match
            Some(t) -> if step(t, () -> ready.push(id)) then tasks[id] = None
            None -> ()

    ready.clear()
end run

// The event source: one timer, and the waker it will call when it fires.
static var on_timer: Option[&Fn() -> unit] = None

arm(wake: &Fn() -> unit)
    on_timer = Some(wake)

fire_timer()
    on_timer match
        Some(wake) -> wake()
        None -> ()

// The tasks.
async blink(n: int)
    for i in 0..<n
        print(s"led $i")
        await yield_now()

async sample()
    print("sampler waits for the timer")
    await park(arm)
    print("sampler read the adc")

spawn(0, blink(2))
spawn(1, sample())
run()

print("-- timer fires --")
fire_timer()
run()
led 0
sampler waits for the timer
led 1
-- timer fires --
sampler read the adc

yield_now wakes the task before it suspends, which is why blink goes back on the queue behind sample each round. sample parks with nothing to wake it, so the first run ends with it off the queue; the timer’s waker puts it back.

Stepping a finished task resumes nothing and answers true again, so an executor that steps a task one time too many has done nothing wrong.

A waker fires at most once

However many times a waker is called, the executor hears of it once — and a waker whose park is over is a no-op, so a stale one held by an event source cannot wake the task out of a later park:

import sysl.buf.{Buf, buf}

static var kept: Buf[&Fn() -> unit] = buf()
static var heard: int = 0

keep(wake: &Fn() -> unit)
    kept.push(wake)

async adc_ready() -> int
    await park(keep)
    await park(keep)
    4095

val t = adc_ready()
val wake: &Fn() -> unit = () -> heard += 1

print(step(t, wake), heard)

kept[0]()
kept[0]()
print(heard)

print(step(t, wake), heard)

kept[0]()
print(heard)

kept[1]()
print(step(t, wake), heard)
false 0
1
false 1
1
true 2

A waker called during its own registration — an event that has already happened — still costs the task one suspension and is heard once. A task dropped while it is parked disarms its waker on the way out, so an event source that calls it afterwards wakes nothing.

A park at any depth wakes the outermost task

An executor steps the task it holds, and that task may be awaiting a task that awaits another. A park three awaits down calls the waker of the step in progress, which is the outermost task’s — the only one the executor knows about. A step nobody asked for, while the park is still unwoken, is absorbed by the park rather than seen by the task.

static var on_irq: Option[&Fn() -> unit] = None

arm(wake: &Fn() -> unit)
    on_irq = Some(wake)

irq()
    on_irq match
        Some(wake) -> wake()
        None -> ()

async uart_byte() -> int
    await park(arm)
    0x41

async uart_line() -> int = await uart_byte() + 1

async console()
    print(s"got ${await uart_line()}")

val t = console()
val wake: &Fn() -> unit = () -> print("the executor hears: console can run")

print(step(t, wake))
irq()
print(step(t, wake))
false
the executor hears: console can run
got 66
true

What the contract refuses

park is a task and can only be awaited:

async wait_for_dma() -> int
    val pending = park((wake) -> wake())
    512
'park(…)' is a task that waits on the waker it hands its registrar, and it can only be awaited — write 'await park(…)' in the body of an 'async' function

step takes the task and the waker, both:

async blink()
    await yield_now()

poll(t: Task[unit]) -> bool = step(t)
'step' takes the task it resumes and the waker its parks are to call, but 1 argument was given
async blink()
    await yield_now()

poll(t: Task[unit]) -> bool = step(t, 1)
'step' needs the waker as a '&Fn() -> unit', the closure a park in the task calls to say the task can run again, but this value is int

block_on, step, park and yield_now are names the compiler supplies, not reserved words, so a program’s own declaration of one is the nearer name and is called as written. That includes an import: sysl.threads declares a yield_now of its own — the thread’s — so a file that imports that module whole is handed the thread’s, and awaiting it is awaiting a bool. The refusal says whose yield_now the call became:

import sysl.threads.*

async blink()
    await yield_now()

block_on(blink())
'await' needs a Task, which is what calling an 'async' function hands back, but this value is bool — the 'yield_now' called here resolved to 'sysl.threads.yield_now', declared in 'sysl.threads', and a declaration of that name stands in front of the task form 'yield_now(…)', so the task form is shadowed

A local of the same name shadows the task form the same way, and is named as a local binding. Import what the file uses by name — import sysl.threads.spawn — and the task’s yield_now is the one in scope.

A task’s frame is on the heap

A task’s frame — its parameters, its result, and whatever is alive across an await — is allocated when the async function is called and freed when the last share of the task goes. The storage comes from one pair of functions:

sysl_task_alloc(size: usize) -> *u8
sysl_task_free(p: *u8)

The compiler defines both over the program’s allocator, weakly, so a program that defines either with @export replaces the default, and every frame — and every waker a park makes — comes from its own. Counting them shows each frame given back:

extern "malloc" c_malloc(n: usize) -> *u8
extern "free" c_free(p: *u8)

static var frames: int = 0
static var freed: int = 0

@export("sysl_task_alloc")
task_alloc(size: usize) -> *u8
    frames += 1
    c_malloc(size)

@export("sysl_task_free")
task_free(p: *u8)
    freed += 1
    c_free(p)

async read_sensor(channel: int) -> int = channel * 100 + 17

async average() -> int
    await yield_now()
    (await read_sensor(1) + await read_sensor(3)) / 2

print(block_on(average()))
print(frames > 0, frames == freed)
217
true true

On a microcontroller the pair is how frames come from a static pool instead of a heap. The object a board build produces holds no coroutine intrinsic — the compiler has lowered every one — and reaches no malloc: its frames come from whatever the program’s two definitions call.

// Frames come from a fixed pool the firmware owns, not from a heap.
extern "pool_take" pool_take(n: usize) -> *u8
extern "pool_give" pool_give(p: *u8)

@export("sysl_task_alloc")
task_alloc(size: usize) -> *u8 = pool_take(size)

@export("sysl_task_free")
task_free(p: *u8) = pool_give(p)

async read_sensor(channel: int) -> int
    await yield_now()
    channel * 100 + 17

async average(a: int, b: int) -> int = (await read_sensor(a) + await read_sensor(b)) / 2

@export("sensor_average")
sensor_average(a: int, b: int) -> int = block_on(average(a, b))

A task is a counted value: a binding, a field or an Option holding one keeps a share, and the frame goes when the last share does — once, whether the task finished, stopped partway, or never started.

A frame never moves, so a borrow may cross an await

A task’s frame is allocated once, by the call, and stays at that address until it is freed. A task value is the address of the frame, not the frame: handing a task to step, to another task that awaits it, to a join, or into a buffer or an Option copies that address, and every resumption reaches the body’s locals through it. Nothing a program can write copies a frame — so, unlike Rust, sysl needs no Pin: a body may lend out a view of its own array, or the address of its own local, and hold the loan across an await.

static var staged: [16]u8 = [0; 16]
static var on_irq: Option[&Fn() -> unit] = None

arm(wake: &Fn() -> unit)
    on_irq = Some(wake)

// Holds its caller's view across its own await, and fills it once it is woken.
async read_into(into: []u8)
    await park(arm)

    for i in 0..<into.len
        into[i] = staged[i]

async handle() -> int
    var buf: [1024]u8 = [0; 1024]
    val head = buf[0..<4]

    await read_into(head)
    int(head[0]) + int(head[3]) + int(buf[4])

val t = handle()

print(step(t, () -> ()))

for i in 0..<16
    staged[i] = u8(10 + i)

on_irq.unwrap()()
print(step(t, () -> ()), block_on(t))
false
true 23

buf lives in handle‘s frame, head views it, and read_into‘s frame holds head while both tasks are parked; the bytes are written through it after the resumption and read back from buf. A view handed to something that keeps it beyond the call — a device’s static, say — is instead the case --explain-escapes reports: the array is promoted to the heap, and the loan is just as safe.

A local lent across an await keeps its @align(n) too. Whatever boundary it asks — of the binding, or of a type declared @align(n) — its storage in the frame begins on it, at every optimization level:

@align(64)
struct Line
    bytes: [64]u8

async fill() -> int
    @align(64)
    var buf: [64]u8 = [0; 64]
    val head = buf[0..<4]
    var line = Line([0; 64])
    val p = &line

    head[0] = 7
    p.bytes[0] = 30
    await yield_now()
    print(usize(&buf) % 64, usize(p) % 64)
    int(head[0]) + int(p.bytes[0])

print(block_on(fill()))
0 0
37

Dropping a task cancels it

There is no cancel operation: a task is cancelled by letting go of it. A task dropped while it is suspended gives back exactly what a return written at that suspension would — its pending defers run, then its locals and parameters are released, in the same order a return releases them — and a task that awaits another gives back the awaited task first. A task dropped before it ever started gives back its parameters and nothing else, since none of its body ran.

struct DmaBuffer
    id: int

impl Drop for DmaBuffer
    drop(self)
        print(s"buffer ${self.id} back to the pool")

async transfer(id: int)
    val held: &DmaBuffer = DmaBuffer(id)
    defer print(s"transfer $id closed")
    await yield_now()
    print(s"transfer $id finished")

started_then_dropped()
    val t = transfer(1)

    step(t, () -> ())
    print("giving up on transfer 1")

never_started()
    val t = transfer(2)

    print("transfer 2 made, never stepped")

started_then_dropped()
print("--")
never_started()
print("done")
giving up on transfer 1
transfer 1 closed
buffer 1 back to the pool
--
transfer 2 made, never stepped
done

transfer 1 finished never prints: the task was let go of at its await, and everything after it is code that does not run. Transfer 2 never reached its first statement, so it made no buffer to give back.

A task handed to an async function as a temporary belongs to the task it was handed to. A temporary argument of an async call moves into the callee’s frame, so when the callee returns without finishing what it was given, that task is dropped — and cancelled — right there, before the caller does anything with the answer:

async parked()
    defer print("parked task cancelled")
    await park((w) -> ())

async give_up(t: Task[unit]) -> Option[int]
    step(t, () -> ())
    None

async caller()
    (await give_up(parked())) match
        None -> print("gave up")
        Some(n) -> print(s"got $n")
    print("after")

block_on(caller())
parked task cancelled
gave up
after

A timeout written this way — (await timeout(d, conn.read())) match … — lets go of the abandoned read the moment the timeout drops it. A task held in a named local is the caller’s as well as the callee’s, and lives until the local’s block ends.

A task crossing a concurrency domain

A task may be handed to another thread — or anywhere a @crossing parameter reaches — only if everything its frame can hold is safe to share. The compiler asks, of each thing the frame holds, the question it asks of what a closure captures when that closure is shared into a &sync Fn:

  • its parameters;
  • its result;
  • every binding alive across an await — a counted local lives to the end of its block, so one read only before the await is still held if its block outlasts it;
  • every value computed before an await in the same statement — an argument to the left of an awaited one, the left operand of an operator whose right one awaits, a receiver whose call awaits in an argument, the elements of an array or tuple and the text of an interpolation built before an awaited part. A temporary used up in an earlier statement is gone before the await, and a scalar one crosses;
  • every task it awaits, followed down the chain — and a task handed in as a parameter is answered by the argument its call was given.

A task of scalars crosses, and runs on the other thread while this one runs its own:

import sysl.threads.spawn

struct Job
    t: Task[int]
    out: int

run(j: *Job)
    j.out = block_on(j.t)

async checksum(n: int) -> int
    var total = 0

    for i in 0..<n
        await yield_now()
        total += i

    total
end checksum

var job = Job(checksum(1000), 0)
val worker = spawn(&run, &job).unwrap()
val mine = block_on(checksum(2000))

worker.join()
print(job.out, mine)
499500 1999000

The same hand-over is refused once the frame holds a string, whose bytes are owned through a count that is not atomic. The refusal names the function and the parameter:

import sysl.threads.spawn

struct Job
    t: Task[int]
    out: int

run(j: *Job)
    j.out = block_on(j.t)

async checksum(n: int, label: string) -> int
    var total = 0

    for i in 0..<n
        await yield_now()
        total += i

    total
end checksum

var job = Job(checksum(1000, "firmware"), 0)

spawn(&run, &job).unwrap().join()
print(job.out)
but its 't' is a task of 'checksum', whose frame holds its parameter 'label', a 'string', which owns its bytes through a count that is not atomic

An ordinary &T held across an await is refused the same way, at the call that makes the task:

struct Reading
    value: int

@crossing(t)
hand_off(t: Task[int]) = ()

async filtered(raw: int) -> int
    val r: &Reading = Reading(raw)
    await yield_now()
    r.value / 4

hand_off(filtered(1023))
't' of 'hand_off' reaches another concurrency domain, so every count inside it has to be atomic — but it is a task of 'filtered', whose frame holds its local 'r', a '&Reading', whose count is not. Hold it as a '&sync Reading'

Held as the message says, the task crosses — and so does one whose &T is let go of before its first await, because then the frame never holds it:

struct Reading
    value: int

@crossing(t)
hand_off(t: Task[int]) -> int = block_on(t)

// The reading is shared through an atomic count, so the frame may cross.
async filtered(raw: int) -> int
    val r: &sync Reading = Reading(raw)
    await yield_now()
    r.value / 4

// The reading is gone before the first await, so the frame never holds it.
async scaled(raw: int) -> int
    var v = 0

    if raw > 0
        val r: &Reading = Reading(raw)
        v = r.value

    await yield_now()
    v * 2
end scaled

print(hand_off(filtered(1023)), hand_off(scaled(100)))
255 200

A value a statement has already computed when it reaches an await waits in the frame until the statement finishes, so it is asked about too — here the label, built before the awaited reading:

@crossing(t)
hand_off(t: Task[int]) = ()

async sensor() -> int
    await yield_now()
    7

describe(label: string, n: int) -> int = label.len + n

async report(n: int) -> int
    describe(s"channel $n", await sensor())

hand_off(report(3))
but it is a task of 'report', whose frame holds a value computed before an 'await' in the same statement, a 'string', which owns its bytes through a count that is not atomic

Computed into a local of its own in an earlier statement and used up there, or kept as a scalar, it is not in the frame at the await.

The question is answered where the call that makes the task can be seen. A task that arrives as a plain parameter could have been made by any async function, so handing it on is refused unless that parameter is itself marked @crossing — which moves the question to the callers. A task held in a &sync structure is asked about where the structure is boxed, and one captured by a closure shared into a &sync Fn is asked about at the closure. Everything refused here is accepted where the task stays in its own domain.

A task that parks stays in its domain

A task that awaits park is never allowed to cross. Its waker is a &Fn() -> unit belonging to the domain whose executor steps it, so the task belongs to that domain too:

@crossing(t)
hand_off(t: Task[int]) = ()

async wait_for_dma() -> int
    await park((wake) -> wake())
    512

hand_off(wait_for_dma())
but it is a task of 'wait_for_dma', whose frame holds the 'park' it awaits — a task that parks is handed a waker, and a waker is a '&Fn() -> unit' belonging to the domain whose executor steps the task, so a task that parks stays in that domain

The same rule keeps a waker at home from the other side: a registrar may not capture the waker it is handed into a closure shared into a &sync Fn, and step will not take a &sync Fn() -> unit as its waker. No spelling makes a waker atomic, so the refusal says where it belongs instead:

@crossing(f)
later(f: &sync Fn() -> unit) = ()

async waits() -> int
    await park(w -> later(() -> w()))
    1

print(block_on(waits()))
a closure shared between two domains may be called from either, so every count it captures has to be atomic — but the 'w' it captures is the waker 'park' handed its registrar, a '&Fn() -> unit' belonging to the domain whose executor steps the task, and no spelling makes it atomic. Keep the waker in that domain: let the other domain — an interrupt, another thread — signal the executor, and have the executor call the waker

The interrupt that completes the request is the other domain a kernel meets, and the shape it writes first is a waker parked in module storage and called from the handler. Marked @domain(interrupt), the handler is held to what it reaches, and the waker is what it reaches:

var on_complete: Option[&Fn() -> unit] = None

arm(wake: &Fn() -> unit)
    on_complete = Some(wake)

@domain(interrupt)
@export("disk_irq")
disk_irq()
    on_complete match
        Some(wake) -> wake()
        None -> ()

async read_sector(n: u64) -> u64
    await park(arm)
    n * 2
'disk_irq' is entered in the 'interrupt' domain, on top of whatever was running, so every count it reaches has to be atomic — but the module storage 'on_complete' it reaches holds a '&Fn() -> unit' in its 'Some(value)', whose count is not. If it holds a waker 'park' handed its registrar, no spelling makes that atomic: keep the waker in the executor's domain — have this one signal the executor, and the executor call the waker. A callback of the program's own is held as a '&sync Fn() -> unit' ('06')

What the handler may do is what an Atomic word or a &sync object allows: set a flag the executor reads, or push the request’s number onto a ring the executor drains — and the executor, back in its own domain, calls the waker.

yield_now makes no waker, so a task that only yields crosses — checksum above is one.

Where the executors live

Not in the language and not in the standard library. The compiler supplies block_on and the contract above; an executor is a package, written against the contract in ordinary sysl, exactly as the run queue on this page is.

  • kairos is an event loop written for a microcontroller first — numbered sources an interrupt raises, timers on one clock. It is also an executor: tasks spawned on the loop sleep on its timers and wait on its sources, with the wakers kept where the timers and sources fire them. A board and a host use the same loop; only the driver value differs.
  • With its uv feature (features = [uv]), kairos runs the same loop over libuv for a host: sockets and whole-file reads and writes are tasks awaited beside sleep and timeout. With the feature off nothing of libuv is fetched or linked.

Next: the foreign interface.

Search

Esc
↑↓ to navigate ↵ to open Esc to close