Lesson 10.2 · 18 min
Answers that arrive in pieces
Lesson 10.1 ended on a wall: a Box<dyn Replier> cannot hold a trait whose reply is an async fn. The way round it is to change what reply hands back. Instead of the answer, it returns at once with the receiving end of a pipe, and the answer comes down that pipe later, a word at a time. By the end, Chatterbox v1.1 prints the pretend model's answer as it is typed.
Adds to ChatterboxAnswers arrive through a channel; the pretend model types word by word.
Compiler errors you will meet E0373
Hand back a pipe, not an answer
At a busy café you do not stand at the till while your coffee is made. You get a buzzer straight away, sit down, and it tells you when something is ready. Chatterbox wants the same deal from a replier: something now, and the answer when it is ready.
In Rust that something is a channel: a pipe with two ends. Whatever goes in at one end comes out of the other, in the same order. So the new promise in the Replier trait is a plain, non-async method:
fn reply(&self, history: &[Message]) -> Pieces, wherePiecesis the receiving end of a channel whose items areResult<String, ChatError>: each one a piece of text, or what went wrong.
Because reply is an ordinary function again, Box<dyn Replier> works exactly as it did in lesson 8.4. The waiting has moved out of reply and into the pipe.
A channel has two ends
let (tx, rx) = mpsc::channel(8); makes one. It gives back a tuple (lesson 2.6) of its two ends. The short names are a habit: tx for "transmit" and rx for "receive".
mpscstands for multiple producer, single consumer: many senders may put things in, and exactly one receiver takes them out. It lives intokio::sync.txis the Sender.tx.send(piece).awaitputs one item in. The8is how many items may wait in the pipe; when it is full,sendpauses until there is room, so a fast writer cannot drown a slow reader.rxis the Receiver.rx.recv().awaitwaits for the next item and gives anOption:Some(piece), orNoneonce the pipe is empty and every Sender has gone.
A task: work that carries on by itself
Something has to put the words into the pipe after reply has already returned. That is a task: a piece of async work that the tokio runtime runs alongside the rest of your program. tokio::spawn(...) starts one and returns immediately, without waiting for it to finish.
What you give spawn is an async block: async { … } is code that can .await, wrapped up as a future (lesson 10.1) without being given a name. The word move in front of it means "this block takes ownership of every variable it uses". Why it is needed comes next. First, see what "returns immediately" means.
Each word waits 100 milliseconds before it is sent. What is the FIRST line this program prints?
reply has returnedgot: yougot: you said: hello there
Reveal the answer
The program prints: reply has returned tokio::spawn only starts the task; it does not wait for it. So reply returns the Receiver at once, and main prints its line before any word is sent. Then recv().await waits for each word in turn, and when the task finishes, its Sender is dropped and the loop ends.
Why the task must own what it uses
Look at the order of events. reply returns, and its local variables text and tx are dropped at its closing brace. The task is still running, and it still needs both. If it had only borrowed them, it would be reading values that no longer exist, which lesson 4.4's rules forbid. Take move away and the compiler says so.
This is the exact output of the real compiler. Toggle the English, then pick a fix and see what actually happens.
error[E0373]: async block may outlive the current function, but it borrows `tx`, which is owned by the current function --> src/main.rs:6:18 | 6 | tokio::spawn(async { | ^^^^^ may outlive borrowed value `tx` 7 | for word in text.split(' ') { 8 | let _ = tx.send(word.to_string()).await; | -- `tx` is borrowed here | = note: async blocks are not executed immediately and must either take a reference or ownership of outside variables they use help: to force the async block to take ownership of `tx` (and any other referenced variables), use the `move` keyword | 6 | tokio::spawn(async move { | ++++
E0373 in the compiler error atlas →
async block may outlive the current function, but it borrows `tx`: the task can still be running afterreplyhas returned and droppedtx.- A borrow must end before its owner is dropped, and nothing promises that the task ends first.
- The help line says what to do: to force the async block to take ownership of `tx` (and any other referenced variables), write
async move. Here the other one istext.
Fixes that were compiled for real
- async move: the task owns text and tx — compiles. Ownership of both moves into the task, the way a value moves into a function (lesson 4.3). They are dropped when the task ends, and dropping tx is what closes the channel.
- Trap: move a borrowed message — still E0521. <code>message</code> is a <code>&str</code>, a borrow of text that belongs to the caller. Moving the borrow into the task still leaves the task pointing at text it does not own. That is why <code>reply</code> builds an owned <code>String</code> first.
- Trap: use text again after the move — still E0382. Once <code>text</code> has moved into the task, <code>reply</code> no longer owns it. The same rule as moving into a function.
- Clone what you still need here — compiles, with a cost. The task gets its own copy and <code>reply</code> keeps the original. Chatterbox's real model does this with its <code>url</code> and <code>key</code>, because a task cannot borrow from <code>self</code> either.
How an answer ends
There is no "the end" message. The channel itself says when an answer is complete: once every Sender has been dropped and the pipe is empty, recv() gives None. When the task finishes, it drops the tx it owns, so while let Some(piece) = pieces.recv().await stops on its own. In the program below, drop(tx) does the same thing by hand: it ends a value's life on the spot, as if it had reached the end of its block.
What is the second line this program prints?
NoneSome("hello")Some("")
Reveal the answer
The program prints: None The first recv still gets Some("hello"): dropping the Sender does not throw away what is already in the pipe. The second finds the pipe empty and no Sender left, so nothing can ever arrive. Instead of waiting forever, it gives None.
It also works the other way round. If the Receiver is dropped, nobody is listening, and send returns an Err that hands the item back. That is why the pretend model's task checks every send: if tx.send(Ok(piece)).await.is_err() { return; }. If Chatterbox stops reading, the task stops typing.
What does this program print?
sentnobody is listeninghello
Reveal the answer
The program prints: nobody is listening With the Receiver gone, nothing could ever take the item out, so send fails at once instead of putting it in a pipe nobody reads.
Chatterbox v1.1
The trait hands back Pieces
In replier.rs, pub type Pieces = mpsc::Receiver<Result<String, ChatError>>; gives that long type a short name, and reply returns it.
Echo answers all at once
all_at_once makes a channel with room for one, puts a whole answer (or an error) in it with try_send, and returns the Receiver. try_send sends without waiting. It fails if the pipe is full, or if the Receiver has already gone; here neither can happen, because the channel is brand new and all_at_once still holds the Receiver. .expect("…") is like unwrap() (lesson 5.4), with your own message if it ever fails. The Sender is dropped at the end of the function, so the channel is already closed: one piece, then None.
Pretend types
Pretend builds its sentence as before, then spawns a task that sends it a word at a time, with a 40 millisecond pause after each, so you can watch it arrive.
Model asks inside a task
Model moves its HTTP call into an async fn ask, using reqwest's async client: .send().await? and .json().await?. Its task owns clones of url, key and the request body, and sends the whole answer as one piece.
main waits for the keyboard
In main.rs, main becomes #[tokio::main] async fn main, and two use lines matter:
use std::io::{self, Write};brings inioitself (that is theself) andWrite, the trait that givesflush. A trait must be in scope before you can call its methods.use tokio::io::{AsyncBufReadExt, BufReader};: aBufReaderreads input in big blocks and hands it out a line at a time, andAsyncBufReadExtis the trait that adds.lines()andnext_lineto async readers.
BufReader::new(tokio::io::stdin()).lines() reads the keyboard; lines.next_line().await? gives Some(line), or None when input ends.
answer prints as pieces arrive
async fn answer asks the replier for its Pieces and prints each one with print!, which, unlike println!, adds no new line. The catch: the terminal is normally handed text only when a line ends. Until then it waits in a buffer, a holding area in memory. io::stdout().flush()? pushes out what is waiting, right now. answer also gathers the pieces into one String for the history, or None if a piece was an error.
Tests that can wait
The tests become #[tokio::test] async fn: like #[test], but each test gets a runtime of its own, so it can .await.
Going deeper Small print in v1.1
type makes a new name for an existing type, not a new type: a Pieces and a mpsc::Receiver<Result<String, ChatError>> are the same thing, and either name works anywhere.
In Model's task, let _ = tx.send(answer).await; ignores the result on purpose: if nobody is listening, there is no one to tell.
For your name, unwrap_or_default() takes the line, or empty text if input had already ended. The chat line and /again both call answer with replier.as_ref(), which borrows the boxed replier as a &dyn Replier.
Sam hello! /remember pizza extra cheese /recall pizza /model pretend can you hear me? /stats /history /quit
Chatterbox v1.1 (replies by: echo) What should I call you? Hi Sam! I'm Chatterbox. Ask me anything. bot: you said: hello! bot: got it: pizza is extra cheese bot: pizza is extra cheese bot: replies now come from pretend bot: (pretend model) I read all 3 messages. You last said: can you hear me? bot: your top words: can (1), hear (1), hello! (1) 1. you: hello! 2. bot: you said: hello! 3. you: can you hear me? 4. bot: (pretend model) I read all 3 messages. You last said: can you hear me? bot: bye, Sam! messages so far: 2 saved 4 messages to chatterbox.json
Show the whole program (open it after you have tried the exercise below: it contains the answer)
src/command.rsunchanged
src/message.rsunchanged
src/store.rsunchanged
Download this version (zip)Unzip, then cargo run inside the folder.
The real model still works, through the same stand-in server as lesson 9.2. Its answer arrives as one piece, because Chatterbox never asked for anything else: the request does not ask for streaming, and .json().await waits for the whole body before it hands anything back. Asking with "stream": true, and reading the answer as the model writes it, is the job of the next lesson.
Sam hello model and again /quit
Chatterbox v1.1 (replies by: real) What should I call you? Hi Sam! I'm Chatterbox. Ask me anything. bot: mock model saw 1 message; last: hello model bot: mock model saw 3 messages; last: and again bot: bye, Sam! messages so far: 2 saved 4 messages to chatterbox.json
This is the whole of Chatterbox v1.1 with the second half of Pretend's reply taken out: the sentence text is built, and nothing sends it. Make a channel, spawn a task that sends text one word at a time (the first word as it is, every later one with a space in front), pausing 40 milliseconds after each, and return the Receiver straight away. Joined back together, the pieces must be exactly text.
This is the whole of Chatterbox v1.1 with the second half of Pretend's reply taken out: the sentence text is built, and nothing sends it. Make a channel, spawn a task that sends text one word at a time (the first word as it is, every later one with a space in front), pausing 40 milliseconds after each, and return the Receiver straight away. Joined back together, the pieces must be exactly text.
Hidden tests (4): an_empty_history_is_still_an_error pretend_reads_the_whole_history pretend_answers_a_word_at_a_time a_long_answer_fits_through_a_small_pipe
This exercise runs real Rust in your browser through the official Rust Playground, so it needs JavaScript.
If you have coded before
In Python's asyncio, tokio::spawn is close to asyncio.create_task, and an mpsc channel is close to an asyncio.Queue with a maxsize. The difference is the ending: a Queue has no idea when its writers are done, so you send a sentinel such as None yourself. A tokio channel closes when the last Sender is dropped, so ownership tells the reader that the answer is over.
What you have now
Chatterbox v1.1 prints each piece the moment it arrives, whichever replier it is. Ownership did two quiet jobs: move let the task outlive reply, and dropping the last Sender says an answer is finished.
Say it out loud, then reveal
What are the two ends of <code>mpsc::channel(8)</code>, and what does the 8 mean?Reveal
tx), which puts items in with send, and a Receiver (rx), which takes them out with recv. The 8 is how many items may wait in the pipe before send has to pause.Why does <code>tokio::spawn</code> need <code>async move</code> here?Reveal
move the compiler stops with E0373.How does the reader of a channel know the answer is complete?Reveal
recv().await gives None once the pipe is empty and every Sender has been dropped. The task dropping its Sender when it finishes is what ends the answer.Why does <code>answer</code> call <code>io::stdout().flush()</code> after <code>print!</code>?Reveal
That is lesson 10.2. Tick it off so the course remembers where you are.
Go deeper
Questions people ask
What is the difference between a tokio task and a thread?
Both are work that runs alongside the rest of the program. A thread is given to you by the operating system and is fairly heavy. A tokio task is much lighter: the tokio runtime runs many tasks on a few threads, switching between them each time one is waiting at an await.
What does mpsc stand for?
Multiple producer, single consumer. A channel can have many Senders, made by cloning the first one, but only one Receiver.
Why does reply return a channel instead of being an async fn?
A trait with an async fn cannot be used as Box dyn Replier, which Chatterbox needs to swap repliers while it runs. A plain function that returns a Receiver at once can, and the answer still arrives later, down the channel.
What happens if I forget to flush after print?
Nothing goes wrong, but the pieces sit in the output buffer until a new line is printed, so the whole answer appears at once at the end and the streaming effect is lost.