---
title: 'Message Passing with Channels'
source: 'https://academia.sh/en/courses/rust-concurrency/message-passing-with-channels'
course: 'Concurrency and Asynchronous Rust'
language: en
updated: '2026-09-15T04:58:44+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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`.

```rust
// 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.

```rust
// 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.
