Skip to content

async and generators

editable examples

Every example on this page can be edited and run here: click the pencil to open it in an editor, change it, and run it in your browser. Errors, hovers and completions come from the ghūl compiler as you type.

The ghul-examples repository has fuller async-await and generators examples to build and run locally, in a GitHub Codespace or a dev container.

Two kinds of ghūl function suspend and resume instead of running straight through: an asynchronous function waits for tasks without blocking, and a generator produces a sequence lazily, one element per request. Both are declared by their return type alone - Tasks.TASK[T] for asynchronous functions, Pipe[T] for generators - and the body reads top to bottom either way.

asynchronous code

A function is asynchronous when its declared return type is Tasks.TASK[T] (or Tasks.TASK, for one that produces no value).

Inside such a function, await e evaluates to the result of the task e once it completes. let x = await e; assigns the result to a local and the rest of the function continues:

ghul
compute() -> Tasks.TASK[int] is
let a = await double_async(10) // a = 20
let b = await double_async(a) // b = 40
let c = await add_async(a, b) // c = 60
return c
si
write_line("{compute().result}")
60

await e; as a bare statement is the value-less form: it waits for e to complete and discards any result. Use it when you only care that the work has finished:

ghul
run_side_effects() -> Tasks.TASK is
await side_effect("first")
await side_effect("second")
return
si
run_side_effects().wait()
side effect: first
side effect: second

await can also appear inside the body of a for or while loop: the loop iterates, awaiting and resuming once per iteration. A return from inside an awaiting loop body propagates out through the loop as usual:

ghul
sum_of_squares(xs: Collections.List[int]) -> Tasks.TASK[int] is
let total mut = 0
for x in xs do
let y = await fetch_async(x)
total = total + y
od
return total
si
let result = sum_of_squares([1, 2, 3, 4]).result
write_line("sum_of_squares = {result}")
sum_of_squares = 30

await is not limited to tasks. Anything that follows .NET's awaiter pattern can be awaited: a type with a parameterless get_awaiter() whose result has a bool property is_completed, a parameterless get_result(), and implements System.Runtime.CompilerServices.INotifyCompletion. Tasks.ValueTask[T] and Tasks.TASK.yield() both qualify, and the await takes the type get_result returns:

ghul
// ValueTask[T] and Task.yield() follow the awaiter pattern, so both can
// be awaited like a Task
half(n: int) -> Tasks.ValueTask[int] => Tasks.ValueTask[int](n / 2)
compute() -> Tasks.TASK[int] is
await Tasks.TASK.yield()
let a = await half(84)
return a
si
write_line("{compute().result}")
42

An await over a value that does not follow the pattern is reported, naming the first member it lacks.

A try / catch / finally around awaiting code works as expected, including a return from inside the try. What is not yet supported is an await inside a catch or finally handler itself. A faulted task can also be handled at the call site: reading .result on a returned task throws the fault as a System.AggregateException.

coroutines

What an asynchronous function returns is not fixed to Tasks.TASK either. Any type carrying .NET's AsyncMethodBuilderAttribute can be the return type: the attribute names a builder type, and the compiler drives that builder instead of the one for tasks. The runtime's Ghul.Coroutines namespace uses this for cooperative coroutines. A function returning COROUTINE or COROUTINE[T] is a coroutine: calling it runs its body until the first await, pause() gives up its turn, and run() resumes waiting coroutines one at a time until none remain. Everything runs on one thread:

ghul
// a coroutine is an asynchronous function returning COROUTINE
worker(name: string, steps: int) -> COROUTINE is
for step in 1::steps do
write_line("{name} step {step}")
// give the other coroutines a turn
await pause()
od
si
worker("a", 3)
worker("b", 2)
// resume parked coroutines until none remain
run()

sleep(milliseconds) gives up the turn until a deadline has passed, and CHANNEL[T], MUTEX and SEMAPHORE pass values between coroutines and guard what they share. A coroutine can also await a task, and run() resumes it on its own thread when the task completes.

generators

A function is a generator when its declared return type is Pipe[T] (Ghul.Pipes.Pipe[T]) and its body contains yield E;. Each yield produces the next value in the sequence; execution suspends until the caller asks for another value, then resumes from the statement after the yield:

ghul
squares(limit: int) -> Ghul.Pipes.Pipe[int] is
let i mut = 1
while i <= limit do
yield i * i
i = i + 1
od
si
for s in squares(4) do
write_line(s)
od

A generator is a pipe, so it can be looped over directly and composed with map / filter / take and the other pipe operators:

ghul
// fibs() is an infinite generator; take(8) bounds it
for f in fibs() |> take(8) do
write_line(f)
od

yield in E yields every element of E in turn, where E is anything a for loop can iterate. The elements are pulled one at a time as the consumer asks for them, so a recursive generator reads naturally:

ghul
// yield in yields every element of the iterable after it
in_order(tree: Tree) -> Pipe[int] is
if let (left, value, right): NODE = tree then
yield in in_order(left)
yield value
yield in in_order(right)
fi
si
let tree = NODE(NODE(LEAF, 1, LEAF), 2, NODE(LEAF, 3, NODE(LEAF, 4, LEAF)))
write_line("{in_order(tree)}")
1, 2, 3, 4

return; ends the sequence early; falling off the end of the body has the same effect.

As with await, a yield inside a catch or finally handler is not yet supported, and a function cannot be both a generator and asynchronous.