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.
Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite
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 doubledSyntax#
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#
There is no
asynckeyword. 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 fromResult. The body of such a function producesValuedirectly, neverTask<Value>:fn asNumber(value: Int): Task<Int> { value }is the whole function, and the caller gets theTaskfrom calling it, not from the body wrapping one.A task starts running as soon as it is created, whether that is
spawn { ... }or a call to a function that returns aTask.await()does not start anything; it only waits for a result that is already on its way.await()belongs only in a function that returns aTask, in a closure passed directly tospawn, in a closure whose declared result is aTask, 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 forResult. 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 taskfor 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, andtask.map { ... }goes on when one finishes.Kompiliert nichtfn sumAll(tasks: List<Task<Int>>): Int { tasks.map({ _.await() }).sum() } // error: `await()` is only allowed in a closure that becomes a taskA library that runs a closure as a task says so in the parameter's type.
Source.producetakes a(var sink: Sink<Item, Failure>) => Task<Result<Void, Failure>>, and theTaskin that type is what lets its body wait - exactly as the result type of afndoes.spawngets a copy of everything its closure captures, and cannot capture avarbinding. Values are passed freely between tasks because a task never shares storage with the scope it was spawned from; onlyTaskandChannelconnect two tasks. A capturedvarbinding 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-levelvaris the same race for the same reason: it is one place the whole file shares. The checker rejects the capture itself, so onespawnis already too many and two are two messages:Kompiliert nichtfn 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 itRead the value into a
constbefore 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()A
spawnclosure takes no object with it, and aChannelcarries none either. Ashared typeis 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.TaskandChannelare 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.Kompiliert nichtshared 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 identityMake 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 ashared typemay stand behind ashared trait- so aspawnclosure may capture none of the three, whatever the bounds say:Kompiliert nichtfn 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 objectThe 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.
bothwaits for two tasks of different types at once;Task.allwaits 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 laterawait()or acancel(); where both have completed already,awins. 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()A
var fnmethod may answer aTaskonly when its type is ashared type. A value'svar fnreceiver is a copy in and a copy back that ends with the call; aTaskfinishes later, so only an object - which has no copy to lose- can be changed this way. See Shared types, rule 6.
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 - everyusingis closed - and it releases what it holds as areturnwould. The tasks it started are cancelled with it.cancel()is avar fn, so a task somebody may stop sits in avarbinding; 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()await()passes a cancellation on. Where the awaited task ended cancelled, the waiter is cancelled at thatawait(): 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 withcancelled: the program waited for a task that was cancelledon 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 aResultis 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}" }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 calledcancel()and wants to confirm it.Task.allandbothare 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()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:
fn doubled(value: Int): Task<Result<Int, String>> {
Ok(spawn({ value }).await()? * 2)
}
// error: `?` needs an `Option` or a `Result`, and `Int64` is not oneA 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
}
}Related#
- Channels - the other shared type that connects two tasks.
- Streams -
SourceandSink, whose verbs also answer aTask. - Shared types - why a
var fnmethod may answer aTaskonly for an object. - Result - the vocabulary
Taskshares with?,mapandflatMap.