Skip to content
academia.sh

Lesson 02 / 10

Message Passing with Channels

The side waiting for word from a channel advances when something else finishes; the side that builds and sends the word advances on its own. The sent value's ownership moves into the channel and can no longer be used on the sending side.

Published Updated

In this lesson

  • Shows that a value sent over a channel has its ownership moved to the receiver and no longer remains with the sender
  • Measures that the side waiting for word from a channel advances when something else finishes, while the side that builds the word advances on its own
  • Shows how the absence of an expected number of records is measured when it does not arrive
Contents

The previous lesson used a signal channel only as a wake-up tool: the value sent was empty, and the only thing that mattered was whether the send happened at all. This lesson runs the same mechanism with a payload. The value passing through the channel is no longer empty; it carries data, and the question of who that data belongs to is measured in this lesson together with the question of progress.

The concept of a channel is not established in this course. Go’s Concurrency course dealt with channels in detail, but what was measured there was the channel used as a stopping mechanism — part of the question of when and why a goroutine stops. In this lesson, the same mechanism is measured as a progress tool: here a channel does not end a unit, it starts or continues one. The channel’s own machinery — whether it is buffered or unbuffered, its closing behavior, its multiple-producer single-consumer pattern — was closed out in that course and is not repeated here; this lesson only asks how the channel corresponds to the progress classes.

A Channel That Carries Ownership

The Ownership and the Memory Model course established what it means for a value to be moved: when a value moves from one binding to another, the first binding is no longer valid, and the value can only be reached through the new binding. Sending over a channel is an instance of this same rule — the send call takes the value and moves it into the channel; the sending side can never touch that value again.

The consequence is that the receiving side can mutate the value without a lock. The program below sends a list from one thread to another; the receiving side grows the list, but it needs no lock to do so, because it is the sole owner of the list at that moment.

This should not be confused with a situation where two threads can reach the same heap memory. When the list value is constructed in the sending thread, it occupies a place on the heap; the send call moves not the address of that place, but its ownership. When the receiving side calls list.push and growth is needed, Rust’s growable array type allocates a new place on the heap and copies the old contents there — but during this operation there is no possibility whatsoever of a second side reaching that same memory, because the sending thread has already been joined and no longer holds a binding named list.

// move.rs — a value arriving from a channel is moved, so it is open to lock-free mutation.

use std::sync::mpsc::channel;
use std::thread;

fn main() {
    let (sender, receiver) = channel::<Vec<i32>>();

    let giver = thread::spawn(move || {
        let list = vec![1, 2, 3];
        sender.send(list).unwrap();
        // list can no longer be used here: ownership moved into the channel.
    });
    giver.join().unwrap();

    // --- receiving side: grows the list directly with no lock, because it is the sole owner ---
    let mut list = receiver.recv().unwrap();
    list.push(4);
    list.push(5);

    println!("length of the received list: {}", list.len());
    println!("grew without a lock, because this value is shared nowhere else");
}
length of the received list: 5
grew without a lock, because this value is shared nowhere else

TH2. The list variable never appears again in the source once it is handed to the send call; if it did, the source would not compile, because accessing a moved value violates Rust’s ownership rule. This restriction is not run as an error in this lesson — it is only described in prose — and next to it stands the accepted design that honors the restriction: the sending side builds the value and sends it, the receiving side takes it over and grows it, and at no moment do the two share a common reference. The Mutex and Read-Write Lock lesson will cover the lock; the sharing found there is absent here, because there is nothing shared — there is only something that fully changes hands.

This distinction is also reflected in the channel’s own type: writing channel::<Vec<i32>>() says that the channel will only carry values of type Vec<i32>, and that these values pass as one complete, single ownership, not as a reference or a copy. When the receiving side takes the value returned from the receiver.recv() call, it now holds the source’s only copy of that value; this is why the list.push call needs no concurrency primitive at all — at the moment it grows, the growing list sits nowhere that any other binding can reach. This also explains why a channel is a different tool from a mutex: a lock grants sequential access to a shared value, while a channel hands the value over completely, without ever sharing it.

The Sender On Its Own, the Waiter When Something Else Finishes

Aside from the ownership story of the value passing through the channel, the same send also carries a progress question. The measurement below shows this separately for both sides: the sending side builds and sends three separate messages, waiting for nothing; the waiting side makes a separate receive call for each message, and that call does not return until the other side sends.

This pair is the same as the thread-signaler and thread-channel-waiter rows in the previous lesson’s measurement core — there, the signal was an empty value (()); here, the same relationship is repeated with three separate messages that carry an actual payload. The classification does not change: the side that builds the signal advances on its own both there and here, and the side that waits for the signal advances when something else finishes both there and here. What changes is what passes through the channel.

The measurement runs two separate channels together. The first is the message_sender/message_receiver pair: it carries the actual payload, Message values, each a struct carrying a content string and a sequence number. The second is the log channel, familiar from the previous lesson: it is used for both sides to write their own progress to a ledger, and this ledger is sorted before its order is measured. Keeping the two channels separate is deliberate — if the channel that measures the actual message-passing got mixed up with the log channel used to verify that measurement, it would become unclear which record is part of the real work and which is only an observation.

The code shows both log_sender.clone() and the moving of a Message value side by side, and the two are different things. The sending end of a channel is a cloneable type: the log_sender.clone() call produces a second handle that can write to the same channel; the two handles share no data, they only share access rights to the same destination. Every thread having its own handle is what lets multiple senders write to the same channel concurrently — this is why the send() call itself needs no mutex. The Message value, by contrast, is never cloned: it is moved directly into the send call, because it must have exactly one owner. A handle being duplicable and a value staying single-owned are two separate rules standing side by side in the same program.

The Message struct in the measurement carries two fields: content is a string, sequence is an integer. Sending the two together is a deliberate choice — it shows that a channel can carry not just a single integer or a single string, but a value of arbitrary complexity under the same ownership rule. The sequence field also serves the measurement itself: the total_sequence the waiting side accumulates proves that all three messages arrived complete and with correct content — not only that their count was right, but that their values were carried correctly too.

// channel.rs — the channel waiter advances when something else finishes, the sender advances on its own.

use std::sync::mpsc::channel;
use std::thread;

struct Message {
    content: String,
    sequence: u32,
}

fn main() {
    let (log_sender, log_receiver) = channel::<String>();

    // --- giving side: builds and sends messages on its own, waiting for no one ---
    let (message_sender, message_receiver) = channel::<Message>();
    let lg = log_sender.clone();
    let giver = thread::spawn(move || {
        for sequence in 1..=3 {
            let value = Message {
                content: format!("record-{}", sequence),
                sequence,
            };
            message_sender.send(value).unwrap();
            lg.send(format!("giver-sent-{}", sequence)).unwrap();
        }
        // value no longer exists here: ownership moved into the channel with the send call.
    });

    // --- waiting side: advances when something else finishes, a separate recv() per message ---
    let lg = log_sender.clone();
    let waiter = thread::spawn(move || {
        let mut total_sequence = 0u32;
        for _ in 1..=3 {
            let message = message_receiver.recv().unwrap();
            total_sequence += message.sequence;
            lg.send(format!("waiter-received-{}", message.content)).unwrap();
        }
        total_sequence
    });

    giver.join().unwrap();
    let total = waiter.join().unwrap();

    let mut records: Vec<String> = Vec::new();
    for _ in 0..6 {
        records.push(log_receiver.recv().unwrap());
    }
    records.sort();

    println!("full scenario — accumulated sequence: {}", total);
    println!("records ({}):", records.len());
    for r in &records {
        println!("  {}", r);
    }

    // --- missing scenario: 3 givers expected, but only 2 were spawned ---
    let (log_sender2, log_receiver2) = channel::<String>();
    let (message_sender2, message_receiver2) = channel::<Message>();
    let lg2 = log_sender2.clone();
    let missing_giver = thread::spawn(move || {
        for sequence in 1..=2 {
            let value = Message {
                content: format!("missing-record-{}", sequence),
                sequence,
            };
            message_sender2.send(value).unwrap();
            lg2.send(format!("missing-giver-sent-{}", sequence)).unwrap();
        }
    });
    missing_giver.join().unwrap();
    drop(log_sender2);

    let mut missing_records: Vec<String> = Vec::new();
    while let Ok(r) = log_receiver2.recv() {
        missing_records.push(r);
    }
    missing_records.sort();

    let mut received_messages: Vec<String> = Vec::new();
    while let Ok(message) = message_receiver2.try_recv() {
        received_messages.push(message.content);
    }
    received_messages.sort();

    let expected = 3;
    println!();
    println!("missing scenario — expected: {}, arrived: {}", expected, received_messages.len());
    for r in &received_messages {
        println!("  {}", r);
    }
    println!("missing record count: {}", expected - received_messages.len());
}
full scenario — accumulated sequence: 6
records (6):
  giver-sent-1
  giver-sent-2
  giver-sent-3
  waiter-received-record-1
  waiter-received-record-2
  waiter-received-record-3

missing scenario — expected: 3, arrived: 2
  missing-record-1
  missing-record-2
missing record count: 1

TH3. In the full scenario, the giver thread builds and sends three messages, writing every record without waiting for anyone else to finish — it falls into the on-its-own class. The waiter thread, on the other hand, waits at each recv call for the other side’s next send — it falls into the when-something-else-finishes class. The accumulated sequence is 1 + 2 + 3 = 6; this value confirms that all three messages arrived in order and complete, and it comes out identical across three separate runs.

The total_sequence value itself continues a rule from the previous lesson: the waiter thread’s closure returns this number, and the waiter.join() call carries that returned value to the calling side. The rule established in the Thread Creation lesson holds here too — the value the closure produces passes into the join call by the move rule of the Ownership and the Memory Model course, and the calling side cannot read that value before the join completes. What is worth noticing here is that this same move follows the same path for both Message structs and a plain u32 sum: the channel and the join apply the same ownership rule to different shapes of data.

The Limits of the Measurement

The progress measured in this lesson has only two outcomes: either a message arrives and the waiting side continues, or none arrives and the waiting side stays there. How many messages arrive, or at what speed, is never measured in this course — only whether all three arrived. The waiting side waits indefinitely. The recv call does not return until a message arrives from the other side. If the expected number of sends never happens, no record is ever created — and in a real program the waiting side would stay blocked forever. This lesson’s second measurement shows this situation without entering a deadlock, by computing the expected and the actual counts separately: in the “missing scenario”, three messages were expected, but the giving side sent only two and finished. Instead of waiting for the third message forever, the program detects that the sender and the log channel have closed (drop(log_sender2)), collects the records that did arrive, and reports the missing count as 1.

This detection is not left to chance: the drop(log_sender2) call explicitly drops the last remaining copy of the channel’s sending end. The Ownership and the Memory Model course established that dropping a value is one of the points where its life ends; here, the same mechanism turns into a channel’s closing signal. Rust’s channel implementation ends the receiving side’s next recv call with an error once every sending end has been dropped — the while let Ok(r) = ... form used in the program implicitly treats this error as the end of the loop. If log_sender2 were not dropped, the loop would assume the channel would never close and would wait forever; here, the drop is not just memory cleanup but a signal to the receiver that says “nothing more is coming”.

This is not an example of how a real application manages its own risk of waiting indefinitely — that question is close to the timeout mechanism covered by the Cancellation and Timeouts lesson, and it will be addressed there separately. What is measured here is only this: the difference between the expected count and the arrived count directly shows which record is missing (the third message, in this example), not when the third message will arrive — because it never does.

The call used to collect the remaining messages in the missing scenario also shows this boundary. The program collects the messages after joining the missing_giver thread; at this point everything that was going to be sent has already been sent, so there is no need for a blocking call — whatever is in the channel can be read and collected immediately. This ordering preserves determinism: the messages are collected in the same order and the same count on every run, because the collection step does not depend on whether any live sender might still send more. The third message of the expected count therefore never arrives, and the program does not try to wait for it; it only compares the expected count with the arrived count and prints the difference.

Summary

  • The ownership of a value sent over a channel moves to the receiver; the sending side can never access that value again, and the receiving side can mutate it without needing any lock.
  • The side that builds and sends the word advances on its own; the side waiting for word from the channel advances when something else finishes — this distinction holds separately for every recv call.
  • In the full scenario, the accumulated sequence of the three messages (6) comes out identical across three separate runs; this is the measure of a complete match between sending and receiving.
  • If the expected number of sends does not happen, the waiting side waits indefinitely; a missing record is measured by the difference between the expected and arrived counts, never by a waiting duration.
  • The boundary is left to the Mutex and Read-Write Lock lesson: there is no shared value here, only a value that changes hands.

Next Step

This lesson showed a value’s ability to cross from one thread to another with two examples: Vec<i32> and Message. Both crossed without any trouble, but that crossing has never once been questioned so far — as though every type could pass through a channel. In fact, this permission is a condition written into the type itself, and not every type satisfies it. The next lesson asks where that condition is held: which types can cross a thread boundary, which cannot, and how this is known at compile time.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close