Chapter 13 — Tasks
A program that talks to the network or the disk spends most of its time waiting. Tasks let it wait on many things at once: while one task waits for a reply, the others keep running. All tasks run on one thread and share the same memory, so they need no locks.
Everything here comes from the compiler’s built-in
task module.
Scopes and spawn#
scope runs a function that can start tasks with s.spawn(…). Each spawn
returns a handle whose join() waits for the task’s result. The scope
itself finishes only when every task started in it has finished, so no task
can outlive the code that started it:
use io::sync::{println};
use task::{scope, Scope, sleep};
fn fetch(string name, int ms) -> string {
sleep(ms); // stands in for a network request
return name;
}
fn main() {
let result = scope(fn (Scope s) {
let slow = s.spawn(fn () => fetch("slow", 50));
let fast = s.spawn(fn () => fetch("fast", 10));
return (fast.join() ?? "?") + " finished before " + (slow.join() ?? "?");
});
println(result ?? "failed")?;
}
Output:
fast finished before slow
Both requests wait at the same time, so the whole thing takes about 50 ms,
not 60. join returns a Result, because a task can fail; scope returns
one too, holding the value its function returned.
When tasks switch#
A task runs until it has to wait, and only then does another task get the thread. Tasks switch at:
- an IO wait, such as reading from a socket, accepting a connection or
read_lineon a stream that has no data yet; task::sleep(ms);join()on a task that has not finished;sendon a full channel orrecvon an empty one (see below);task::yield_now(), which just lets the others run.
There is no preemption: a long calculation without any of these keeps the
thread to itself. Ordinary functions that do IO need no changes to run
inside a task. For heavy computation, use a thread instead (chapter 14), or
task::blocking(f), which runs f on a worker thread while the other tasks
carry on.
Channels#
A Channel passes values from one task to
another. It holds a limited number of values: send waits while it is
full, and recv waits while it is empty. After close, recv returns
what is left and then an error, which ends a while let loop:
use io::sync::{println};
use string::{format};
use task::{scope, Scope, channel, Channel};
fn main() {
let queue: Channel<int> = channel(4);
let total = scope(fn (Scope s) use (queue) {
s.spawn(fn () use (queue) {
for n in 1..=5 {
let _ = queue.send(n * n);
}
queue.close();
});
let sum = 0;
while let Ok(value) = queue.recv() {
sum += value;
}
return sum;
});
println(format("sum of squares %i", total ?? -1))?;
}
Output:
sum of squares 55
The producer stops each time the queue is full and resumes when the consumer has made room, so a fast producer cannot run away with memory.
Timeouts and cancellation#
timeout(ms, f) runs f as a task and gives up when it takes too long:
use io::sync::{println};
use string::{format};
use task::{timeout, sleep};
fn slow_lookup() -> int {
sleep(1000);
return 42;
}
fn main() {
let answer = timeout(20, fn () => slow_lookup());
println(format("%v", answer))?;
}
Output:
Err(TaskError::TimedOut)
You can also stop a task yourself with handle.cancel(). A cancelled task
stops at its next wait, runs its defer blocks, and its join returns
Err(TaskError::Cancelled). Wrap a section that must not be interrupted
halfway, such as writing a record, in task::shield(fn () { … }).
If a task panics, the panic does not end the program. The scope cancels the
task’s siblings and returns Err(TaskError::Panicked(message)).
A small echo server#
Putting it together: a TCP server that serves each connection in its own task, and two clients that talk to it at the same time. The listener uses port 0, which lets the operating system pick a free port:
use io::{Stream, close};
use io::sync::{println, write_all, read_line, accept_wait};
use io::net::tcp::{listen, connect, local_addr};
use string::{to_bytes};
use task::{scope, Scope};
// Sends every line back to the client until it disconnects.
fn serve(Stream conn) -> Result<int, IoError> {
while let Some(line) = read_line(conn)? {
write_all(conn, to_bytes("echo: " + line + "\n"))?;
}
close(conn)?;
return Ok(0);
}
fn ask(int port, string message) -> Result<string, IoError> {
let conn = connect("127.0.0.1", port)?;
write_all(conn, to_bytes(message + "\n"))?;
let reply = read_line(conn)? ?? "";
close(conn)?;
return Ok(reply);
}
fn main() {
let listener = listen("127.0.0.1", 0)?;
let (_, port) = local_addr(listener)?;
let replies = scope(fn (Scope s) use (listener, port) {
// The server: accept two connections, one task each.
s.spawn(fn () use (listener, s) {
for i in 0..2 {
let conn = accept_wait(listener)?;
s.spawn(fn () use (conn) => serve(conn));
}
return Ok(0);
});
// Two clients, running at the same time as the server.
let a = s.spawn(fn () use (port) => ask(port, "hello"));
let b = s.spawn(fn () use (port) => ask(port, "world"));
let ra = a.join() ?? Err(IoError::Other);
let rb = b.join() ?? Err(IoError::Other);
return [ra ?? "no reply", rb ?? "no reply"];
});
for reply in replies ?? ["failed", "failed"] {
println(reply)?;
}
}
Output:
echo: hello
echo: world
Run it with --allow-net. A real server would loop on accept_wait for
ever instead of twice; each connection costs one task, not one thread.
Exercises#
- Start ten tasks that each sleep a random time between 10 and 100 ms
(see
random) and print their numbers in the order they finish. - Build a pipeline of three tasks connected by two channels: one produces numbers, one squares them, one prints them.
- Change the echo server to close a connection that sends nothing for two
seconds, using
timeout.
Next: Chapter 14 — Threads.