Skip to Content
Course content

156: Tokio Channels: mpsc, oneshot, broadcast

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this pattern countless times: a developer wants to build a "manager" task that handles requests from various parts of an application. They reach for tokio::sync::mpsc because "multi-producer, single-consumer" sounds exactly like what they need. But then they hit a wall when they realize they need the manager to actually answer the requester.

Take a look at this snippet. The goal is to have a DatabaseManager that processes queries and returns the result to the caller.

async fn database_manager(mut rx: mpsc::Receiver<String>, tx: mpsc::Sender<String>) {
    while let Some(query) = rx.recv().await {
        let result = format!("Result for {}", query);
        // Send the result back through the channel
        tx.send(result).await.unwrap();
    }
}

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);
    let (resp_tx, resp_rx) = mpsc::channel(32); // Wait, this is getting messy...

    tokio::spawn(database_manager(rx, tx.clone())); 
    
    tx.send("SELECT * FROM users".to_string()).await.unwrap();
    // How do I get the result back specifically for THIS query?
}

The "Who is this for?" problem

The code above is fundamentally broken for a request-response pattern. First, the developer is trying to use an mpsc channel for the response. Since mpsc is single-consumer, you can't easily have ten different tasks all listening on one resp_rx to see if their specific query finished. If you try to share the receiver, you'll realize the compiler won't let you because the receiver isn't Clone.

Even if you wrapped the receiver in an Arc<Mutex<...>>, you'd have a race condition: Task A sends a query, Task B sends a query, and Task A might accidentally pop Task B's result off the channel. It's a nightmare to coordinate.

Pairing mpsc with oneshot for targeted responses

The "pro move" here is to realize that while the request is many-to-one, the response is one-to-one. This is where tokio::sync::oneshot comes in. A oneshot channel is designed for exactly one message. Instead of sending just a string through the mpsc channel, you send a tuple (or a struct) containing the request and a oneshot::Sender.

Here is how I would rewrite this to actually work:

use tokio::sync::{mpsc, oneshot};

struct Command {
    query: String,
    resp: oneshot::Sender<String>,
}

async fn database_manager(mut rx: mpsc::Receiver<Command>) {
    while let Some(cmd) = rx.recv().await {
        let result = format!("Result for {}", cmd.query);
        // Send the result back specifically to the requester
        let _ = cmd.resp.send(result);
    }
}

#[tokio::main]
async fn main() {
    let (tx, rx) = mpsc::channel(32);
    tokio::spawn(database_manager(rx));

    let (resp_tx, resp_rx) = oneshot::channel();
    let cmd = Command {
        query: "SELECT * FROM users".to_string(),
        resp: resp_tx,
    };

    tx.send(cmd).await.unwrap();
    let response = resp_rx.await.unwrap();
    println!("Got response: {}", response);
}

By embedding the oneshot::Sender inside the message, the manager doesn't need to know who the caller is; it just knows that whatever is at the other end of that specific sender is waiting for the answer. This is the standard architecture for "actor-like" patterns in Rust.

Broadcasting to every listener

Now, mpsc and oneshot are great for targeted communication. But what if you need to tell everyone that the system is shutting down, or push a global configuration update? You can't use mpsc because that would require you to maintain a list of every single sender and loop through them, which is tedious and error-prone.

That's where tokio::sync::broadcast fits in. Unlike mpsc, a broadcast channel is multi-producer, multi-consumer. Every receiver gets a copy of every message sent.

use tokio::sync::broadcast;

#[tokio::main]
async fn main() {
    let (tx, _) = broadcast::channel(16);

    for i in 0..3 {
        let mut rx = tx.subscribe();
        tokio::spawn(async move {
            while let Ok(msg) = rx.recv().await {
                println!("Worker {} received: {}", i, msg);
            }
        });
    }

    tx.send("Global Shutdown!".to_string()).unwrap();
    // Give tasks a moment to print
    tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}

A quick warning: broadcast channels can lag. If a receiver is too slow and the internal buffer fills up, the receiver will get a RecvError::Lagged. I usually suggest keeping your broadcast messages small and your processing logic fast to avoid this.




📋 Practical Task

Exercise: Build a Multi-User Notification Hub

Your task is to build a small notification system that demonstrates all three channel types. You need to implement a Hub that manages the following:

  • Private Messages: Use an mpsc channel to send requests to the Hub. Each request should include a oneshot::Sender so the Hub can confirm the message was delivered.
  • Global Announcements: Use a broadcast channel so that any "client" subscribed to the Hub receives every global announcement.

Requirements:

  1. Create a Message enum that can either be a PrivateRequest (containing the text and a oneshot sender) or a GlobalAnnouncement (containing the text).
  2. Implement a hub_task that processes these messages. If it's a private request, it should send "Delivered!" back via the oneshot channel. If it's a global announcement, it should send that text via the broadcast channel.
  3. In your main function, spawn two separate "client" tasks. Each client should subscribe to the broadcast channel.
  4. The main function should send one private message (and await the confirmation) and one global announcement (which both clients should print to the console).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.