Tasks

Task<Value> is what an asynchronous function answers; await() waits for it and answers the value, and a cancellation is passed on to the waiter instead of answered.

Asynchrony lives in the type system, not in a keyword. A function that returns Task<Value> may call await() inside its body, the same way a function that returns a Result may use ?. await() answers the Value itself. Every task can be cancelled, and a cancellation is passed on, not answered: a task that awaits a task that ended cancelled is cancelled itself, right at that await(), so an IO line is file.addLine(text).await()? - one ? for the failure of the work, and nothing for a cancellation. result() is the one wait that answers a cancellation as a value. A pool of workers - one operating-system thread per processor, TORB_WORKERS to say otherwise - runs the tasks of a program: each worker runs one task at a time in the order they became ready, and an idle worker takes a task that has not started yet where everything it holds may cross to another thread (numbers, a channel of numbers, a text nobody else holds). What a program reads of its tasks - their values, the items of a channel - does not depend on which worker ran them; what several tasks print at once interleaves as the machine runs them, and with TORB_WORKERS=1 in the one order of the queue.

Example#

fn double(value: Int): Task<Int> {
  spawn { value * 2 }.await()
}

const doubled = double(21).await()
print doubled

Syntax#

Task<Value>                                the type: a computation that finishes later
spawn(body: () => Value): Task<Value>      starts body as a new task, running in parallel
task.await(): Value                        waits for the value; a cancelled task cancels the waiter in turn
task.result(): Result<Value, Cancelled>    waits, and answers a cancellation as Fail(Cancelled) instead
task.cancel()                              asks the task to stop (a var fn)
task.within(limit): Task<Result<Value, TimedOut>>
pause(): Task<Void>                        lets every other ready task run first (std/task)
sleep(seconds: Float): Task<Void>          finishes once the time is up (std/time)
task.map(transform): Task<Output>
task.flatMap(transform): Task<Output>
both(first: Task<A>, second: Task<B>): Task<(A, B)>
Task.all(tasks: Iterate<Task<Value>>): Task<List<Value>>
Task.first(a: Task<Value>, b: Task<Value>): Task<Value>

Rules#

  1. There is no async keyword. A function's result type, Task<Value>, is the only marker that it may wait; a caller sees that from the signature alone, the same way it sees that a function may fail from Result. The body of such a function produces Value directly, never Task<Value>: fn asNumber(value: Int): Task<Int> { value } is the whole function, and the caller gets the Task from calling it, not from the body wrapping one.

  2. A task starts running as soon as it is created, whether that is spawn { ... } or a call to a function that returns a Task. await() does not start anything; it only waits for a result that is already on its way.

  3. await() belongs only in a function that returns a Task, in a closure passed directly to spawn, in a closure whose declared result is a Task, and at the top level of an entry file or a script, because a function that waits should say so in its own return type - the same reasoning ? follows for Result. The checker rejects it everywhere else: `await()` is only allowed in a function that returns a `Task` for a function, and `await()` is only allowed in a closure that becomes a task for a closure. result() waits exactly the same way - it only answers a cancellation instead of passing it on - so it stands under the same rule, and the two messages name it.

    An ordinary closure is not a task, and tasks.map { _.await() } is the mistake the rule is for: the closure runs where the pipeline is pulled, so the wait would block the worker underneath it. Task.all(tasks).await() waits for many at once, and task.map { ... } goes on when one finishes.

    Does not compile
    fn sumAll(tasks: List<Task<Int>>): Int {
      tasks.map({ _.await() }).sum()
    }
    // error: `await()` is only allowed in a closure that becomes a task

    A library that runs a closure as a task says so in the parameter's type. Source.produce takes a (var sink: Sink<Item, Failure>) => Task<Result<Void, Failure>>, and the Task in that type is what lets its body wait - exactly as the result type of a fn does.

  4. spawn gets a copy of everything its closure captures, and cannot capture a var binding. Values are passed freely between tasks because a task never shares storage with the scope it was spawned from; only Task and Channel connect two tasks. A captured var binding is the one variable the language shares - the closure and the scope around it both reach it - so a task that took one with it would be the data race this design does not have. A top-level var is the same race for the same reason: it is one place the whole file shares. The checker rejects the capture itself, so one spawn is already too many and two are two messages:

    Does not compile
    fn counted(): Int {
      var total = 0
      const first = spawn { total + 1 }
      const second = spawn { total + 2 }
      total
    }
    // error: `spawn` cannot take the `var` binding `total` with it

    Read the value into a const before the closure, and every task gets its own copy:

    fn counted(): Task<Int> {
      var total = 0
      total = 1
      const now = total
      const first = spawn { now + 1 }
      const second = spawn { now + 2 }
      first.await() + second.await()
    }
    
    print counted().await()
  5. A spawn closure takes no object with it, and a Channel carries none either. A shared type is confined to the task that made it: a task gets copies, and an object is not copied, so both tasks would reach the same one. Task and Channel are the two exceptions, because they are what a value crosses through. The rule reaches a value that holds an object anywhere inside it, and a channel is checked where its item type is written.

    Does not compile
    shared type Counter {
      var count: Int = 0
    
      static fn open(): Self {
        Self()
      }
    
      fn value(): Int {
        count
      }
    }
    
    fn counted(): Task<Int> {
      const counter = Counter.open()
      spawn({ counter.value() }).await()
    }
    // error: `spawn` cannot take `counter` with it: a `Counter` has an identity

    Make the object inside the closure, or send what it holds through a Channel.

    What the closure cannot see is a value is refused as well. A type parameter may be filled with a shared type, a function value does not say what it captured, and a shared type may stand behind a shared trait - so a spawn closure may capture none of the three, whatever the bounds say:

    Does not compile
    fn later<Value>(value: Value): Task<Int> {
      const task = spawn {
        const _ = value
        1
      }
      task.await()
    }
    // error: `spawn` cannot take `value` with it: a `Value` may hold an object

    The one function value a task takes is a function-typed parameter it captures: the parameter then crosses, and every caller of the function is held to the rule instead - a closure it hands in may capture only what a task may take, and a function value whose captures are not visible (a closure bound to a name, a field) is refused. A declared function captures nothing and always may.

  6. both waits for two tasks of different types at once; Task.all waits for a list of tasks of the same type. Both answer their values in the order of the tasks, which is the order they were given in, not the order they finish in.

    fn asNumber(value: Int): Task<Int> {
      value
    }
    
    fn pair(): Task<String> {
      const (first, second) = both(asNumber(1), asNumber(2)).await()
      "{first} {second}"
    }
    
    print pair().await()

    Task.first(a, b) waits for whichever of two tasks of the same type completes first and answers its value. The other one keeps running, for a later await() or a cancel(); where both have completed already, a wins. Where the first to complete ended cancelled, the waiter is cancelled too, as rule 9 describes, and cancelling the answer cancels neither task. Two kinds of work race once each is mapped into a type with a case for either.

    fn asWord(value: Int): Task<String> {
      "{value}"
    }
    
    fn firstWord(): Task<String> {
      const later = asWord 2
      const word = Task.first(asWord(1), later).await()
      "{word}, then {later.await()}"
    }
    
    print firstWord().await()
  7. A var fn method may answer a Task only when its type is a shared type. A value's var fn receiver is a copy in and a copy back that ends with the call; a Task finishes later, so only an object - which has no copy to lose

  8. Every task is cancellable, and cancel() is a request, not a kill. A task notices it where it waits and where a loop of it turns around, so a loop that never waits stops at its next turn too. It stops there, its scopes end in reverse order - every using is closed - and it releases what it holds as a return would. The tasks it started are cancelled with it. cancel() is a var fn, so a task somebody may stop sits in a var binding; dropping the last handle stops nothing.

    fn counting(): Task<Int> {
      var count = 0
      while count >= 0 {
        count = count + 1
      }
      count
    }
    
    var worker = counting()
    worker.cancel()
    print worker.result().isOk()
  9. await() passes a cancellation on. Where the awaited task ended cancelled, the waiter is cancelled at that await(): the line after it never runs, it stops the way rule 8 describes, and whoever awaits it is cancelled in turn - a cascade up to the first task that observes it. At the top level of an entry file that ends the program with cancelled: the program waited for a task that was cancelled on standard error and exit code 130, never the 101 of a panic. So a waiter needs no value for a cancellation, and a task whose value is a Result is one ? per line:

    fn fetched(id: Int): Task<Result<String, String>> {
      "user {id}"
    }
    
    fn fetchedTwo(): Task<Result<String, String>> {
      const first = fetched(1).await()?
      const second = fetched(2).await()?
      "{first}, {second}"
    }
  10. result() observes a cancellation instead: Ok(value) where the task finished, Fail(Cancelled) where it ended cancelled, and the waiter goes on either way. It is for the code that has to know - a supervisor that restarts what was cancelled, a test, the task that called cancel() and wants to confirm it. Task.all and both are written over it: where one part is cancelled they cancel the others and end cancelled themselves, so whoever awaits them is cancelled in turn. within(limit) keeps its own failure, TimedOut, because a deadline is an answer the caller asked for.

What this is not#

await() is not legal in a function that does not return a Task. CONCEPT.md names exactly three places await() is allowed; a function that answers a plain Int and calls await() anyway is rejected.

fn double(value: Int): Task<Int> {
  spawn { value * 2 }.await()
}

print double(21).await()
Does not compile
fn sum(a: Int, b: Int): Int {
  spawn { a }.await()
}
// error: `await()` is only allowed in a function that returns a `Task`

await() does not answer a Result of its own. A ? after it unwraps the task's own failure, and a task that cannot fail has none:

Does not compile
fn doubled(value: Int): Task<Result<Int, String>> {
  Ok(spawn({ value }).await()? * 2)
}
// error: `?` needs an `Option` or a `Result`, and `Int64` is not one

A library that runs a closure as a task says so in the parameter's type. Source.produce's body parameter is a (var sink: Sink<Item, Failure>) => Task<Result<Void, Failure>>, and the Task in it is what makes the await() inside legal - the same marker a fn carries in its result type. Its body still produces the Result directly, the way the body of such a fn does.

use Source, Sink from "std/stream"

fn ints(): Source<Int, ChannelClosed> {
  Source.produce { sink =>
    sink.add(1).await()?
    Ok void
  }
}
  • Channels - the other shared type that connects two tasks.
  • Streams - Source and Sink, whose verbs also answer a Task.
  • Shared types - why a var fn method may answer a Task only for an object.
  • Result - the vocabulary Task shares with ?, map and flatMap.
Esc