ferrule

tasks and structured concurrency

β
scope-parallelspawn-and-awaitcancellationfailure-policiescontext-flow

tasks and structured concurrency

this feature is planned for β. the spec describes what it will be, not what's implemented now.

ferrule uses structured concurrency. tasks form trees where:

  • child tasks can't outlive their parent scope
  • cancellation propagates down the tree
  • failures are explicitly aggregated

this eliminates "fire and forget" patterns that lead to resource leaks and hard-to-debug races.

task scopes

create a task scope with scope parallel:

function get_many(urls: View<Url>, cap net: Net, cap clock: Clock) -> Vec<Response>
    effects [net, time, alloc, suspend, fail<ClientError>]
{
    scope timeout(Duration.seconds(30), clock) {
        scope parallel {
            urls.map(function(url: Url) -> Response {
                return check fetch(url, net)
            })
        }
    } on timeout {
        return err Timeout { message: "fetch timed out" }
    }
}

the scope:

  • tracks all spawned tasks
  • ensures they complete before the scope exits
  • propagates cancellation on failure

spawning

tasks are spawned inside scope parallel blocks:

scope parallel {
    const a = check fetch(url_a, net)
    const b = check fetch(url_b, net)
    return { a, b }
}

spawned tasks:

  • inherit the scope's cancellation token
  • must complete before the scope exits

you can't spawn outside a scope. there's no global task pool.

awaiting

scope blocks implicitly await all spawned tasks before exiting. explicit await is not needed. the scope boundary is the synchronization point.

scope parallel {
    const results = urls.map(function(url: Url) -> Response {
        return check fetch(url, net)
    })
    // all tasks complete before scope exits
    return results
}

cancellation

cancellation propagates through scope blocks:

scope timeout(Duration.seconds(10), clock) {
    // all tasks in this scope are cancelled when the timeout fires
    scope parallel {
        const a = check fetch(url_a, net)
        const b = check fetch(url_b, net)
        return { a, b }
    }
} on timeout {
    return err TimedOut {}
}

when a scope is cancelled:

  1. all child tasks receive the cancellation signal
  2. ongoing operations check the token and return early
  3. cleanup runs via defer

handling cancellation in your code:

function fetch(url: Url, cap net: Net) -> Response
    effects [net, suspend, fail<ClientError>]
{
    const sock = check net.connect(url.host, url.port)
    return check request(sock, url)
}

failure policies

scope blocks support different failure policies:

// fail-fast (default): cancel siblings on first failure
scope parallel {
    check fetch(url_a, net)
    check fetch(url_b, net)
}

// collect-all: gather all failures
scope parallel(policy: collect) {
    check fetch(url_a, net)
    check fetch(url_b, net)
} on fail(errors) {
    log.error("failures: {}", errors)
}

fail-fast is the default. it's usually what you want: if one request fails, cancel the others and return the error.

regions and tasks

regions created inside a scope block are disposed when the scope exits:

scope parallel {
    const arena = region.arena(1024)
    defer arena.dispose()  // runs even on cancellation

    // use arena...
    return result
}

aborted tasks must release their regions. finalizers run, status events are logged.

context flow

context flows through scope boundaries:

with context { request_id: rid } in {
    scope parallel {
        check child_operation()  // inherits request_id
    }
}

errors from child tasks include the inherited context frames. this helps with debugging distributed operations.

async model

ferrule's async is effect-based. the suspend effect marks functions that may pause:

function fetch(url: String, cap net: Net) -> Response
    effects [net, suspend, fail<NetError>]
{
    const socket = check net.connect(url.host, url.port)
    return check socket.readAll()  // may suspend here
}

there's no function coloring. you can call suspend functions from any context that allows the suspend effect. the runtime handles the actual suspension and resumption.

different runtimes can plug in:

  • tokio-style for server workloads
  • single-threaded for embedded
  • deterministic for testing

see the async rfc for details.

example: parallel fetch with timeout

function fetch_all(
    urls: View<Url>,
    cap net: Net,
    cap clock: Clock
) -> Vec<Response> effects [net, time, alloc, suspend, fail<FetchError>] {

    scope timeout(Duration.seconds(30), clock) {
        scope parallel {
            urls.map(function(url: Url) -> Response {
                return check fetch(url, net)
            })
        }
    } on timeout {
        return err Timeout { message: "fetch timed out" }
    }
}

On this page