Eve Multitasking
Why take turns
Most enterprise work waits. A query waits for the database, a download for the network, a report for the disk. While a process waits, its core does nothing. If a driver reads three files of 100 ms each, one after the other, it needs 300 ms, and the core is idle almost all that time. With three tasks, the waits overlap: the first task asks for its file and gives the turn away, the second asks for its file, then the third. The three answers arrive in about 100 ms.
The code does not change shape for this. A task reads like plain sequential code that waits; the machine gives the core away at each wait and brings the task back when its wait is over. There are no callbacks, no promises and no event loop to write.
Asynchronous functions and procedures
The keyword async in front of function or procedure declares a subprogram that runs as a task:
async procedure name(parameters) is
...
return;
async function name(parameters) => (@result: Type) is
...
return;
An asynchronous subprogram is called only with spawn or await, never as a plain call. The word is a promise to the reader: wherever async appears, the caller can see that this call may give the turn away. A plain call is a compile error:
load("one.csv", @a); ** error: load is async: use spawn or await
Tasks in a job
A job is the region that owns the tasks. Inside its do region:
spawncreates a task from anasync procedureand goes on at once, with the next statement;awaitruns an asynchronous subprogram and waits for it at once, in serial mode. For a function,awaitis written in an expression and gives the result:new n := await measure(path);. For a procedure it is a statement:await load(path, @text);;- the closer of the job,
done name;, waits until every task spawned in the job has ended. After it, every output of the tasks is ready.
# read three files at the same time, on one core
driver load_all is
** an asynchronous procedure: no result, the text goes out by @text
async procedure load(path: String, @text: String) is
new f := fs.open(path);
defer f.close();
let text := f.read(); ** waits for the disk: the turn goes to the next task
return;
process main is
new a, b, c :String;
loading: job
do
spawn load("one.csv", @a); ** a task: the job goes on
spawn load("two.csv", @b); ** a second task
await load("three.csv", @c); ** runs this one and waits for it
done loading; ** waits for every spawned task
print a.length(), b.length(), c.length();
return;
end load_all;
Rules
spawnis allowed only in thedoregion of a job, of a driver or of an aspect.awaitis allowed in a process, in a job and in another asynchronous subprogram;spawnstarts only procedures. Spawning a function is a compile error,double is a function: use await, a spawned result would be lost: a spawned task has nobody to receive its result. A procedure gives its results through@output parameters, which stay in the variables of the job;- Inputs are passed by value: a task works on its own copy of each argument, so the job may go on changing its variables;
- One
@output belongs to one task. Two tasks never write the same variable; - The outputs of a spawned task are ready after the
doneof the job; the outputs of an awaited one, on the next statement.
How the tasks take turns
Knowing exactly when a task gives the turn away is what makes multitasking easy to read. Eve keeps the rule small:
- spawn does not run the task. It records the task and its arguments; the main flow of the job keeps the turn and goes on;
- A task gives the turn away only when it waits:
wait duration;, anawaitof a subprogram that waits, thedoneof the job, a full or empty channel, and the input and output of the system library (files, network, databases); - The next task is the next ready one, in the order of spawn. The tasks form a circle: after the last one comes the main flow of the job, then the first task again. A task whose wait is not over is skipped;
- A task that never waits runs to its end. There is no time slice: a long computation keeps the core until it is finished, and the other tasks wait. Long computations belong to aspects that run at the same time (Concurrency).
So between two waits, a task is alone: no other task can see its variables half changed. This is why the tasks of a job need no lock, no atomic variable and no mutex.
Example: two clocks
# two tasks take turns while they wait
driver mt_turns is
async procedure tick(name: String, times: Integer) is
for i in (1..times) do
print "{name} {i}";
wait 10ms; ** gives the turn away for 10 ms
done;
return;
async function slow_double(n: Integer) => (@result: Integer) is
wait 50ms;
let result := n * 2;
return;
process main is
clock: job
do
spawn tick("a", 3); ** recorded, not run yet
spawn tick("b", 2);
print "main goes on"; ** the main flow still has the turn
new d := await slow_double(21); ** waits: a and b get their turns
print "double {d}";
done clock; ** waits for a and b
print "all done";
return;
end mt_turns;
main goes on a 1 b 1 a 2 b 2 a 3 double 42 all done
"main goes on" comes first, because spawn only records the tasks. The await makes the main flow wait 50 ms; in that time a and b take turns, every 10 ms. When two tasks are ready at the same moment, they run in the order of spawn: a before b.
done.Timers: wait
wait duration; suspends the current task and gives the turn away. A duration is an integer with a unit written right after it: ms, s, m, h (10ms, 30s, 2m, 1h). Outside a job, wait simply pauses the process.
Periodic work is a loop with a wait. A driver that checks a folder every minute, while a second task sends a heartbeat every 10 seconds:
# periodic work: two tasks with their own rhythm
driver watcher is
async procedure scan(rounds: Integer) is
for i in (1..rounds) do
print "scan {i}";
wait 1m;
done;
return;
async procedure heartbeat(rounds: Integer) is
for i in (1..rounds) do
print "alive";
wait 10s;
done;
return;
process main is
watch: job
do
spawn scan(60); ** one hour of scans
spawn heartbeat(360); ** one hour of heartbeats
done watch;
return;
end watcher;
A server that runs for days uses the same shape, with a condition instead of a count (see Server).
Errors in a job
A failing task never stops the other tasks. They go on, and done waits for all of them. Then the job raises one error, ParallelError (code 45), that lists every failed task. No error is lost, and the list is in the order of spawn, whatever task failed first:
| Member | Value |
|---|---|
$error.message | {n} of {m} tasks failed |
$error.job | the label of the job |
$error.errors | one record {index, aspect, code, message, job, line} for each failed task; index is the spawn number, from 1; aspect is empty for a task of a job |
# a failing task does not stop the others
driver job_errors is
async procedure check(n: Integer, @r: Integer) is
wait 5ms;
raise "bad {n}" if n == 2;
let r := n;
return;
process main is
new a, b, c :Integer;
work: job
do
spawn check(1, @a);
spawn check(2, @b); ** this one fails
await check(3, @c); ** an awaited procedure, as a statement
done work; ** raises ParallelError
print "never";
recover
print $error.message;
print "job: {$error.job}";
print "{$error.errors[1].index}: {$error.errors[1].message}";
print "{a} {c}"; ** the tasks that did not fail have their outputs
return;
end job_errors;
1 of 2 tasks failed job: work 2: bad 2 1 3
An error of the main flow of the job ends the main flow, but the job still waits for its tasks: then it raises that error, or a ParallelError that holds it as item 0 when tasks failed too. A task is never abandoned half done. In recover, retry runs the whole job again and resume goes on after it (see Processing).
Generators
A generator is the lightest form of multitasking: a function that gives its values one at a time with yield and keeps its place between two values (see Generators). It takes turns with its caller only, at each yield, on the core of the caller. Use a generator for one sequence that is produced step by step, and tasks in a job for several pieces of work that wait.
Eve and async and await
Many languages have async and await. Eve keeps the two words and drops what makes them hard to read: a task is not a value, there are no futures to pass around, and the results arrive in variables at a known place, the done of the job.
| Need | In languages with async | In Eve |
|---|---|---|
| overlap many waits | await all(f1(), f2()) | a job that spawns async procedures |
| take a result later | a future or a promise | an output parameter @, ready at the done of the job |
| wait for one call at once | await f() | await f(), in serial mode |
| stream of results | an async iterator | a generator, or a channel |
| errors of many tasks | the first error, the others are often lost | one ParallelError with every failed task, in the order of spawn |
| colored functions | async spreads to every caller | async marks the subprogram; it is called only with spawn or await, and a plain process may await it |
| data races | possible when tasks run on a thread pool | none: the tasks of a job share one core and switch only when they wait |
Callbacks and completion
A callback (Callbacks) runs in serial mode, in the receiver, at the moment it is called. It is never called "later, from somewhere else": that would bring hidden order into plain code. When a task has a result, the job reads it after done, from the output parameters.
Status
async, spawn, await, wait, the job barrier and its ParallelError. The waits of the system library (files, network, databases) give the turn away when these modules are implemented (levels 5 and 6).fs, and an example that calls several web services with the module http, when they exist (level 6).Read next: Concurrency