DocsConcurrency and runtime

Async tasks and channels.

Start concurrent work, manage its lifetime, handle failures, and communicate through channels.

Async lets independent pieces of work run at the same time. You can start tasks, wait for their values, pass messages through channels, and cancel work you no longer need. Every task has an owner. When the owner finishes, any unfinished children are cancelled, and their cleanup completes before the owner moves on.

This is structured concurrency. Background work always has a clear lifetime, but that also means you have to await any task whose result you care about. Returning from a scope doesn’t leave its tasks running in the background.

Async needs the native runtime, so it isn’t available in the browser playground. Save the examples below as .fango files and run them with fango run.

API reference: Async

Runners and tasks.

Let’s start with two tasks whose values we combine:

import Async

main() =
    result = Async.runOutcome {
        first = Async.spawn { 6 * 7 }
        second = Async.spawn { 8 * 9 }
        (Async.await first, Async.await second)
    }
    print result
Completed (42, 72)

spawn starts a child right away and gives you back a Task value handle. Both children are running before the first await begins to wait, so waiting on the first doesn’t hold up the second.

runOutcome creates a root scope and handles the Async effect. It returns an Outcome value: Completed value if the body finishes, or Cancelled if it gets cancelled. The body here returns a pair, so that pair is what ends up inside Completed.

If you only want to do some work and don’t need a result, use run. Its callback must return (), and it returns () whether the body completed or was cancelled. It pairs nicely with use in main():

import Async

main() =
    use Async.run
    first = Async.spawn { 6 * 7 }
    second = Async.spawn { 8 * 9 }
    print (Async.await first + Async.await second)
114

The statements after use Async.run become the runner’s callback. Both runners add IO at the call site, since tasks run concurrently for real and no handler can simulate them, and the body’s other effects still need to be permitted by the caller. Neither runner handles application failures for you.

Task lifetimes.

A root runner owns the tasks started directly inside it, and each task owns its own children. Whenever an owner exits, for any reason, including a normal return, it cancels its unfinished children and waits for their cleanup. A task’s final outcome only becomes available once all its descendants have finished draining.

scope lets you give some children a shorter lifetime without starting another task. Its body runs on the calling task. When it returns normally, the calling task and any work outside the nested scope keep running:

import Async
import Runtime.Scope as Scope

main() =
    result = Async.runOutcome {
        value = Async.scope {
            ready = Async.channel 0
            gate : Async.Channel Int
            gate = Async.channel 0
            _ = Async.spawn {
                Scope.finally {
                    _ = Async.send ready ()
                    Async.receive gate
                } { print "helper cleaned up" }
            }
            _ = Async.receive ready
            42
        }
        print "scope finished"
        value
    }
    print result
helper cleaned up
scope finished
Completed 42

The ready channel makes sure the helper is inside its cleanup scope before the parent carries on. The helper then blocks on gate, since nobody ever sends to it. When the scope returns 42, it cancels the stuck helper and runs its cleanup before scope itself returns. Resources and scopes explains Scope.finally.

If a child’s work has to finish normally, await it before its owner exits. Ignoring a handle changes nothing about ownership or cleanup. Even if you return a handle out of scope, the task won’t stay alive: the handle still works, but the task has already been cancelled and drained.

Waiting for a task doesn’t adopt it or change who can cancel it. Ownership comes from where the task was started, not from who awaits it later. There is no detached spawn.

Observe task results.

Which function you want depends on whether you need one value, all of them, or whichever task finishes next:

Function What it returns
await One task’s value
awaitOutcome Completed value or Cancelled
awaitAll All values in input order
awaitAny Just (index, value) for one finished task
awaitAnyOutcome Just (index, outcome) for one finished task

The plain-value waits (await, awaitAll and awaitAny) cancel the observing task and its descendants if one of the tasks they were waiting on was cancelled. The outcome variants hand you that cancellation as data instead, so you can decide what to do about it.

Any of these waits can also be interrupted if the observing task itself is cancelled. awaitOutcome lets you inspect a cancelled target, but it doesn’t protect the observer from its own cancellation.

Selection is useful when you care more about what’s ready first than about the order you started things in:

import Async

main() =
    result = Async.runOutcome {
        gate : Async.Channel Int
        gate = Async.channel 0
        pending = Async.spawn { _ = Async.receive gate; 1 }
        ready = Async.completed 42
        Async.awaitAny [pending, ready]
    }
    print result
Completed (Just (1, 42))

completed creates a task that already holds a value. Only the second task can finish here, so the selected index is 1 (indices start at zero). The pending task is cancelled when the root body returns. Selecting a task doesn’t cancel the others.

If several tasks are ready, any of them may be picked. Calling again can return the same task, because selection observes a result without consuming it. To handle each completion once, remove the selected entry from the list before the next call. Indices always refer to the list you pass to that particular call.

awaitAll [] returns [], and both selection functions return Nothing for an empty list. awaitAll keeps input order even when tasks finish in a different order. It just waits for the tasks that exist, and it doesn’t stop early when one of them returns an application error.

A task’s outcome never changes, so repeated or concurrent observers all see the same result. Outside an Async scope, wait uses IO to wait on a handle and returns its outcome. It isn’t an Async cancellation point. You can still inspect a handle saved from an earlier scope, but saving it didn’t extend the task’s lifetime.

Application failures.

Finishing, failing and being cancelled are three different things. A task can finish successfully with a value that describes an error:

import Async
import Fail

main() =
    result = Async.runOutcome {
        task = Async.spawnAttempt {
            Fail.fail "item not found"
            42
        }
        Async.await task
    }
    print result
Completed (Err "item not found")

spawnAttempt handles the action’s named Fail error inside the child and creates a Task (Result error value). The child completed with an Err, and await returned it as an ordinary value. It didn’t raise the error or cancel any siblings. The same is true when a callback given directly to spawn returns an Err.

To raise the error in the observer, pass the awaited value to Fail.fromResult. To keep it as data, pattern match on Ok and Err. Ignoring an error value, or the task handle, doesn’t make the owning scope fail.

Plain spawn needs application aborts to be handled inside its callback. A Fail handler outside can’t be unwound from a child, and awaiting a task doesn’t give it handlers to run with. spawnAttempt handles one named Fail. Other abort effects still need local handlers.

Where you put the handler in the root body matters too. Say the body returns Int and performs Fail String:

  • Async.runOutcome { Fail.attempt body } returns Outcome (Result String Int). A caught failure is a value the body can inspect, and the root carries on.
  • Fail.attempt { Async.runOutcome body } returns Result String (Outcome Int). A failure leaves the root, which cancels and drains its children, and then reaches the outer failure handler.

The same rule applies when a failure leaves a nested scope. For more on Fail and Result, see Failure, state and randomness.

Concurrent groups.

Often you want a group of actions to succeed together, and you want the unfinished ones cancelled as soon as one raises an application failure. Async has helpers for this. They start and own the group’s tasks, handle Fail inside them, and re-raise the chosen error in the caller once cleanup is done.

Combine two actions.

both runs two actions concurrently and returns their results as a pair. The values can have different types, but the actions share one error type:

import Async exposing (Outcome)
import Fail
import Result exposing (Result)

main() =
    result : Outcome (Result String (Int, String))
    result = Async.runOutcome {
        Fail.attempt { Async.both { 6 * 7 } { "ready" } }
    }
    print result
Completed (Ok (42, "ready"))

The annotation fixes the error type because neither action here can fail. In a real program it usually comes from the actions’ own signatures.

Complete a list of actions.

all starts every action concurrently and returns their values in input order. Unlike awaitAll, it watches tasks as they finish, so it notices a failure without first waiting for the earlier entries:

import Async
import Fail
import Runtime.Scope as Scope

main() =
    result = Async.runOutcome {
        ready = Async.channel 0
        gate : Async.Channel Int
        gate = Async.channel 0
        Fail.attempt {
            Async.all [
                { Scope.finally {
                      _ = Async.send ready ()
                      _ = Async.receive gate
                      1
                  } { print "unfinished action cleaned up" }
                }
            , {
                  _ = Async.receive ready
                  Fail.fail "second action failed"
                  2
              }
            ]
        }
    }
    print result
unfinished action cleaned up
Completed (Err "second action failed")

The first action is blocked when the second one fails. all sees the failure, cancels the remaining action, waits for its cleanup, and then raises the error into Fail.attempt. Tasks outside the group’s scope are left alone. An empty list of actions returns [].

both follows the same failure and cancellation rules. In either helper, if a participant is cancelled, the caller is cancelled too and the remaining work is drained. A returned Err is still just data: fail-fast applies to a raised Fail, not to which constructor a value happens to use.

Take the first outcome.

race starts all the actions and picks the first one to reach a final outcome. In this example one action returns while the other stays blocked:

import Async exposing (Outcome)
import Fail
import Result exposing (Result)

main() =
    result : Outcome (Result String Int)
    result = Async.runOutcome {
        gate : Async.Channel Int
        gate = Async.channel 0
        Fail.attempt {
            Async.race [
                { _ = Async.receive gate; 1 }
            , { 42 }
            ]
        }
    }
    print result
Completed (Ok 42)

If the winner returns a value, you get the value. If it raises a Fail, that is raised in the caller, and if it was cancelled, the caller is cancelled. race doesn’t keep looking for a success after an error. An empty race cancels the caller, and when several outcomes are ready at the same moment, any of them may win.

All three helpers wait for the remaining tasks to clean up before they return or pass along an outcome. If the caller is cancelled before that draining finishes, the cancellation wins over any selected value or error. Failures that happen later during draining are discarded.

“Fail-fast” means the group asks for cancellation once it sees the failed task’s final outcome. That outcome is published after the failing task’s own cleanup, not at the instant it raises Fail. Likewise, a race asks the losers to cancel as soon as it has a winner, but it can’t promise how quickly it returns, because the losers have to cooperate and finish their cleanup. Other application aborts still need handlers inside the actions.

Cancellation.

cancel asks a task and its descendants to stop. It doesn’t cancel the caller or its siblings, and it returns without waiting for cleanup. When you need to know the task is really done, follow it with awaitOutcome:

import Async

main() =
    result = Async.runOutcome {
        gate : Async.Channel Int
        gate = Async.channel 0
        child = Async.spawn { Async.receive gate }
        Async.cancel child
        Async.awaitOutcome child
    }
    print result
Completed Cancelled

The root completed normally, and its value is the child’s cancellation. If you used await instead of awaitOutcome, the root would be cancelled too, so the result would be Cancelled. That also holds when the await happens inside a nested scope.

Cancellation is cooperative. Task waits, channel transfers and sleep milliseconds all respond to it. A sleep wakes up early when cancelled, and a duration of zero or less acts as a cancellation checkpoint.

A long CPU-bound computation has to call checkpoint() every so often. It does nothing while the task is active, and if cancellation has been requested, it leaves the task and runs cleanup. Put checkpoints inside long loops, because ordinary calculations won’t notice cancellation on their own.

cancelled() tells you whether the current scope has been cancelled, without stopping the task. You can still call it during cleanup. cancel, close and contextToken also keep working after cancellation, but anything that can be cancelled, such as sleeping, awaiting, sending and receiving, stops immediately. So cleanup code should release resources without relying on another cancellable wait.

Draining can’t be interrupted. A task that never responds to cancellation can stop its owner from finishing. Ordinary blocking IO isn’t interrupted automatically either, so for native network waits use cancellation-aware APIs such as Net socket operations.

If cleanup also fails during cancellation, cancellation is still the outcome, and the failure is discarded. This is why cleanup is for releasing resources during cancellation, not for producing an application error.

Channels.

A Channel value connects producers and consumers. In this example the producer sends two values and closes the channel. The consumer receives both values, then the end-of-stream marker:

import Async

main() =
    result = Async.runOutcome {
        messages = Async.channel 1
        producer = Async.spawn {
            _ = Async.send messages 10
            _ = Async.send messages 20
            Async.close messages
        }
        first = Async.receive messages
        second = Async.receive messages
        end = Async.receive messages
        _ = Async.await producer
        (first, second, end)
    }
    print result
Completed (Just 10, Just 20, Nothing)

channel capacity creates a channel that can buffer that many values. With capacity 1, the producer’s second send may have to wait until the consumer makes room. This is backpressure, and it stops a producer from running far ahead of its consumer. A capacity of zero or less gives you an unbuffered channel, where a send waits for a receiver.

send waits while the buffer is full and returns True once the value is sent. It returns False if the channel is closed, even if it was closed while the send was waiting. The example discards that result with _ = because the producer is the only code that closes the channel, and it does so after its sends.

receive waits until a value is available. It returns Just value for each queued value, and Nothing once the channel is closed and empty. In a longer consumer, pattern match on the result and keep receiving until you get Nothing.

close wakes any blocked senders and receivers, and closing a second time is harmless. Values already in the buffer can still be received after closing. Cancellation is different: it stops a blocked sender or receiver outright, and doesn’t turn the operation into False or Nothing.

Channels exist independently of task ownership and can outlive the scope that created them. Cancelling a producer doesn’t close its channel for you. If consumers rely on the channel closing, arrange that yourself, for example with Scope.finally around the producer. When there are several producers, a coordinator can await all of them and then close the channel once nothing more will arrive.

Handler inheritance and aborts.

A spawned callback’s effects remain requirements of the spawn call. If the child prints, the caller must permit Console, even if it throws away the handle. A pure child adds nothing to a helper that only requires Async. Starting a task doesn’t hide what it needs, and observing it later doesn’t grant any permissions.

Children inherit resumptive handlers, which are handlers that answer an effect request and then resume the computation. They share the same handler activations and state cells, so concurrent tasks can share state. Be careful, though: a read followed by a write isn’t automatically atomic and can lose another task’s changes. Use State.runAtomic for a shared counter or similar single value, and STM when several values change together. If a child needs private state, install a separate handler inside it. The parent’s handler return clause doesn’t transform the child’s task result.

Effects with an abort operation need local handling, as described under application failures, even when the same effect also has resumable operations. Escaping aborts that are known directly, and fresh scoped permissions, are rejected at the task boundary. Dependencies hidden inside generic callbacks or inherited handlers are checked before the child callback runs, and an unsupported one produces a runtime diagnostic, not an Err value or a cancellation. Effects and handlers explains the underlying distinction.

Fresh buffered reader and writer permissions can’t cross the task boundary, so create those buffers inside each child. Sharing a file or socket handle doesn’t extend the resource’s lifetime. Either keep the Async scope inside the scope that acquires the resource, so every task has finished before the resource closes, or acquire the resource inside the child. See Resources and scopes and Files, console, and sockets.

For native integration, contextToken() gives you an opaque cancellation context that a sidecar can use to interrupt a blocking operation. You can still get it during cancellation cleanup, and getting it isn’t a checkpoint. Most Fango programs should use cancellation-aware library operations and leave the token alone.

Pure parallel mapping.

When each calculation is independent and pure, you don’t need to manage tasks yourself:

import Async

main() = print (Async.parMap { number -> number * number } [1, 2, 3, 4])
[1, 4, 9, 16]

parMap takes a pure function and keeps input order no matter which calculation finishes first. It uses at most GOMAXPROCS workers per call, needs no Async scope, and never calls the function for an empty list.

Use parMap for pure transformations. Reach for both, all or race when effectful work fits one of their lifetime and failure policies. Start tasks yourself when you need explicit handles, communication between tasks, or control over how each outcome is observed.