Concurrency · Lesson 25.5

Channel

Source
Pass values between threads through a bounded Channel<T>: one thread sends, another transforms, Main receives, and closing a channel ends the stream once what was sent has drained.
You'll need: Presence, Thread, Mutex

Atomics and locks let threads share data safely. A channel lets them share nothing at all: one thread sends a value, and the value moves to whichever thread receives it. Each value has one owner at a time, so there is nothing to guard. Many concurrent programs are easiest to write as a line of stages, each on its own thread, passing work down the line.

import Sync::Channel;

A bounded queue

A Channel<T> is a queue with room for a fixed number of values, its capacity, set when it is made. Values come out in the order they went in.

MethodDoesReturns
Send(v)adds v, waiting while the channel is full! T: fails if closed
TrySend(v)adds v if there is room, without waiting! T: fails if full
Receive()takes the oldest value, waiting while the channel is emptyT?: none once drained
TryReceive()takes the oldest value if there is one, without waitingT?
Close()ends the stream: no more sendsnothing

Because Send waits while the channel is full, a fast producer cannot run ahead of a slow consumer and pile up values without limit: it is slowed to the consumer's pace. That is called backpressure. The channels here have room for just two values each.

Like Mutex<T>, a channel is a handle, and Clone gives each thread one of its own.

A pipeline

The program is three stages connected by two channels. Generate sends the numbers 1 to 10, Square squares each one, and Main adds them up:

flowchart LR
    g["`Generate`"] -- "numbers" --> s["`Square`"]
    s -- "squares" --> m["`Main`"]
let numbers = Channel<uint64>(2);
let squares = Channel<uint64>(2);
var producer = Spawn(Generate, numbers.Clone()) catch { else => return 1 };
var squarer = Spawn(Square, Stage { input: numbers.Clone(), output: squares.Clone() }) catch {
    else => return 1
};

The middle stage holds one end of each channel. It receives until its input runs dry, sends each square on, and then closes its own output:

func Square(stage: Stage) -> () {
    while true {
        match stage.input.Receive() {
            value? => stage.output.Send(value * value) catch { else => return },
            none => break
        }
    }
    stage.output.Close();
}

Receive returns uint64?. A value means work to do; none means the channel is closed and empty, so nothing more will ever come. Only Main prints, and with one sender per channel the squares arrive in exactly the order they were sent.

Closing

A receiver cannot tell "nothing yet" from "nothing ever" on its own. The sender says so by calling Close when it is finished, and each stage passes the end of the stream down the line by closing its own output once its input is done.

Closing does not throw away what was already sent: receivers still take every value left in the queue, and only then get none.

let late = Channel<uint64>(4);
late.Send(7) catch { else => return 1 };
late.Send(8) catch { else => return 1 };
late.Close();

After the Close, late still gives 7, then 8, then none. A Send to a closed channel fails — and hands the value back as its failure, so nothing is lost silently:

match late.Send(9) {
    .Success(_) => PrintLine("sent 9"),
    .Failure(value) => PrintLine("closed, {} came back", value)
}

Any number of threads may send to one channel and receive from it. With several senders the values of each one still arrive in order, but how the senders' values interleave depends on the scheduling. Close the channel only once every sender has finished — for instance after joining them all.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// Locks let threads share data. A channel lets them share nothing: one thread sends a value, and
// the value moves to whichever thread receives it. Each value has one owner at a time, so there
// is nothing to guard.
//
// A `Channel<T>` is a queue with room for a fixed number of values. `Send` waits while it is full
// and `Receive` waits while it is empty, so a fast producer is slowed to the pace of its consumer
// instead of piling up values without limit. Values arrive in the order they were sent.
//
// When the producer has nothing more to send, it calls `Close`. Receivers still take what is left
// in the queue, and then `Receive` returns none: that is how a consumer knows the stream is over.
// Like `Mutex<T>`, a channel is a handle, and `Clone` gives each thread its own.
import Io::{ Print, PrintLine };
import Sync::Channel;
import Thread::{ JoinHandle, Spawn };

// The first stage sends 1 to 10, then closes its channel.
func Generate(output: Channel<uint64>) -> () {
    for value in 1u64..=10u64 {
        // `Send` fails, handing the value back, only once the channel is closed.
        output.Send(value) catch { else => return };
    }
    output.Close();
}

// The middle stage's two ends.
struct Stage {
    input: Channel<uint64>;
    output: Channel<uint64>;
}

// The second stage squares each value it receives until its input runs dry, then closes its own
// output, so the end of the stream passes down the line.
func Square(stage: Stage) -> () {
    while true {
        match stage.input.Receive() {
            value? => stage.output.Send(value * value) catch { else => return },
            none => break
        }
    }
    stage.output.Close();
}

func Main() -> int {
    // Two small channels: neither stage can run more than two values ahead of the next.
    let numbers = Channel<uint64>(2);
    let squares = Channel<uint64>(2);
    var producer = Spawn(Generate, numbers.Clone()) catch { else => return 1 };
    var squarer = Spawn(Square, Stage { input: numbers.Clone(), output: squares.Clone() }) catch {
        else => return 1
    };

    // Only `Main` prints. It receives the squares in order, and stops when the stream ends.
    // `Receive` returns `uint64?`, so `let value: uint64 = squares.Receive();` is refused:
    //
    //     error: cannot assign 'uint64?' to 'uint64'
    var total: uint64 = 0;
    Print("squares:");
    while true {
        match squares.Receive() {
            value? => {
                Print(" {}", value);
                total = total + value;
            },
            none => break
        }
    }
    PrintLine("");
    PrintLine("total:   {}", total);
    producer.Join();
    squarer.Join();

    // Closing does not throw away what was already sent: the queue drains first.
    let late = Channel<uint64>(4);
    late.Send(7) catch { else => return 1 };
    late.Send(8) catch { else => return 1 };
    late.Close();
    let first = late.Receive() ?? 0;
    let second = late.Receive() ?? 0;
    let third = late.Receive();
    PrintLine("after close: {}, {}, then {}", first, second, third == none ? "none" : "more");

    // A send to a closed channel fails and returns the value, so nothing is lost silently.
    match late.Send(9) {
        .Success(_) => PrintLine("sent 9"),
        .Failure(value) => PrintLine("closed, {} came back", value)
    }
    return 0;
}

Besides Io, its Rux.toml lists Sync and Thread under [Dependencies].

Run it

cd Examples/Concurrency/Channel
rux run
squares: 1 4 9 16 25 36 49 64 81 100
total:   385
after close: 7, 8, then none
closed, 9 came back

Common mistakes

Try it yourself

  1. Add a third stage between Square and Main that passes on only the even squares.
  2. Start two Square threads on the same pair of channels. Each closes squares when it is done — why can that lose values, and how would Main close it instead?
  3. Make Generate use TrySend, and count how often the channel was full.

Learn more

  • Sync — the package in the API reference
  • Presence — matching value? and none
  • Mutex — sharing data instead of passing it
  • Wait group — the next lesson: waiting for a whole group of threads