Sitemap
Rustaceans

Insights and Expertise from Rustaceans

Beyond Mutex and RwLock: Tokio MPSC vs MPMC Channels in Rust

How message passing with Tokio mpsc and async-channel can replace shared-state locks, and how to pick the right one

10 min read2 days ago

--

Press enter or click to view image in full size
From shared-state locks to asynchronous message passing discover how Rust channels help multiple tasks communicate and work concurrently without fighting over the same lock.

In my previous articles, we explored different ways to handle concurrent data access in Rust, starting with Mutex, moving to RwLock, and eventually discovering why DashMap can be a better alternative to RwLock<HashMap> in highly concurrent workloads.

But not every problem requires a key-value store. Sometimes, all we need is a simple data structure to store values, queue them, and let other tasks consume them. The challenge begins when multiple tasks need to produce and consume those values concurrently.

How do we handle this efficiently in an asynchronous environment without unnecessarily relying on locks?

In this article, we’ll explore MPSC and MPMC channels, understand Tokio’s asynchronous mpsc implementation, and then move to async-channel to see how it handles multiple producers and multiple consumers.

1. Why Queues and Channels?

Consider a different scenario: multiple tasks are continuously producing data, and we simply need to store those values temporarily until another task consumes and processes them. We don’t need key-based lookups or random access. We just need an efficient way to pass data between tasks.

A queue is a natural fit for this kind of workload. But when multiple producers and consumers are involved, we need to think beyond a simple Vec or VecDeque protected by a lock.

This is where channels and message passing come into the picture. Instead of having multiple tasks directly access and modify shared data, they communicate by sending and receiving messages.

Now the interesting question is: What happens when we have multiple producers but only one consumer? And what if we need multiple consumers processing messages concurrently?

To answer that, let’s first understand MPSC and MPMC.

2. Understanding MPSC and MPMC

Before jumping into Tokio or any async channel implementation, let’s understand two common messaging patterns: MPSC (Multiple Producer, Single Consumer) and MPMC (Multiple Producer, Multiple Consumer).

The names pretty much explain how these models work.

MPSC (Multiple Producer, Single Consumer)

In an MPSC model, multiple producers can send messages to a shared channel, but only one consumer receives and processes those messages.

Imagine a job-processing system where multiple application tasks submit jobs to a queue, while a single background worker picks them up and processes them.

Press enter or click to view image in full size

This model is useful when you want multiple tasks to submit work to a single processing task without directly sharing mutable state.

MPMC (Multiple Producer, Multiple Consumer)

Now imagine that one consumer isn’t enough. Incoming messages are increasing, and we want multiple workers to process them concurrently.

That’s where MPMC comes in. Multiple producers can send messages, and multiple consumers can receive and process them.

Press enter or click to view image in full size

This model is particularly useful for worker pools, background job processing, and workloads where multiple consumers can independently process incoming messages.

The primary difference isn’t how many producers we have, both models support multiple producers. It’s how messages are consumed.

MPSC: One consumer receives messages.

MPMC: Multiple consumers can receive messages and distribute the workload.

In the next section, we’ll implement MPSC using Tokio and explore how bounded channels, unbounded channels, and backpressure affect our asynchronous applications.

3. Tokio MPSC Channel

Now that we understand MPSC, let’s see how we can implement it in Rust using Tokio.

Tokio provides an asynchronous MPSC channel through tokio::sync::mpsc. It allows multiple tasks to send messages to a single receiver without requiring us to manage shared mutable state manually.

You might wonder why we don’t just use std::sync::mpsc from the standard library. Its recv() blocks the entire OS thread while waiting, which also stops every other task scheduled on that Tokio worker thread. Tokio's version lets the task yield with .await instead.

One important thing to understand is that Tokio provides two types of MPSC channels:

Bounded channels

It has a fixed capacity. Once the channel reaches its capacity, additional send operations wait until the receiver consumes messages and frees up space.

Press enter or click to view image in full size

A bounded channel is created using mpsc::channel():

use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(10);
for id in 0..3 {
let tx = tx.clone();
tokio::spawn(async move {
for i in 0..10 {
tx.send(format!("Producer {id}: message {i}"))
.await
.unwrap();
}
});
}
// Drop the original sender so the channel closes
// once all producers are done.
drop(tx);
while let Some(message) = rx.recv().await {
println!("Received: {message}");
}
}

Here, we’ve created a channel with a capacity of 10. The capacity is shared by all senders, so the three producers together can have up to 10 messages waiting in the buffer. Once the buffer is full, any producer calling send().awaitwaits until the receiver frees up space

This mechanism is called backpressure.

Backpressure is important because it prevents producers from overwhelming consumers when messages arrive faster than they can be processed.

Notice how the while let loop ends. rx.recv() returns None only after every sender has been dropped and the buffer is empty. That’s how the receiver knows no more messages are coming.

Unbounded channels

It has no predefined capacity limit. Producers can continue sending messages without waiting for the receiver.

Press enter or click to view image in full size

Tokio also provides mpsc::unbounded_channel():

use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
for i in 0..20 {
tx.send(format!("Message {i}"))
.unwrap();
}
});
while let Some(message) = rx.recv().await {
println!("Received: {message}");
}
}

Unlike bounded channels, unbounded channels don’t require producers to wait for buffer capacity. The send() operation is not async, so there’s no .await. It returns immediately with a Result, which is only an error if the receiver has already been dropped.

Get byte&borrow’s stories in your inbox

Join Medium for free to get updates from this writer.

However, there’s a catch: if producers continuously send messages faster than consumers process them, the channel can grow indefinitely and consume increasing amounts of memory.

Prefer bounded channels for most production workloads. Having a capacity limit gives you control over memory consumption and naturally applies backpressure when consumers cannot keep up.Unbounded channels are useful in specific situations, but you should understand the memory implications before using them.

Next, let’s explore an important limitation of Tokio MPSC: what happens when a single consumer is no longer enough?

4. When MPSC Isn’t Enough

Press enter or click to view image in full size
Producers keep sending new tasks, but a single worker can only process them one at a time. As incoming work outpaces processing capacity, the queue keeps growing.

Tokio’s MPSC works well when multiple producers send messages to a single consumer. But what happens when that single consumer becomes a bottleneck?

Imagine a background job-processing system where incoming jobs are increasing faster than one worker can process them. Naturally, we’d want to spawn multiple workers to distribute the workload.

However, Tokio’s mpsc has one limitation: it supports only a single receiver. We cannot directly clone the receiver and let multiple workers consume messages from the same channel.

Of course, we could wrap the receiver inside Arc<Mutex<Receiver<T>>> and share it across multiple workers. This is a valid approach and ensures that only one worker accesses the receiver at a time.

Note that this needs tokio::sync::Mutex , not std::sync::Mutex .The lock is held across recv().await and a std::sync::MutexGuard can’t be held across an .await inside tokio::spawn because it isn’t Send. The compiler will reject it.

But there’s a catch: every worker must acquire the same mutex before receiving a message. This introduces lock contention, and workers may spend time waiting for the lock instead of doing useful work. It can become an unnecessary bottleneck, especially when message consumption is frequent.

There’s also an easy mistake to make here. If you write:

while let Some(job) = rx.lock().await.recv().await { ... }, the lock guard lives until the end of the loop body. That means only one worker can process a job at a time, and your “worker pool” quietly becomes a single worker.

Press enter or click to view image in full size
One worker holds the lock, while everyone else waits in line. More workers don’t necessarily mean more throughput when access to shared resources becomes the bottleneck.

To avoid this, receive the message in its own statement so the guard is dropped before processing:

loop {
// The guard is dropped at the end of this statement.
let job = rx.lock().await.recv().await;
match job {
Some(job) => process(job).await,
None => break,
}
}

This works, but it’s easy to get wrong and it still funnels every worker through one lock. A cleaner approach is to use a channel that natively supports multiple consumers.

This brings us to MPMC (Multiple Producer, Multiple Consumer) and Rust’s library.

5. MPMC in Rust with async-channel

Press enter or click to view image in full size
One channel, multiple producers, multiple consumers. Let Rust handle the concurrency while your workers focus on getting things done.

Now that we understand why a single receiver can become a bottleneck, let’s explore async-channel, a Rust crate that provides asynchronous MPMC channels.

Unlike Tokio’s MPSC, async-channel allows us to clone both the sender and receiver. This means multiple producers can send messages, while multiple consumers can independently receive messages from the same channel.

Quick note: async-channel isn't the only option. Crates like and also provide MPMC channels that work in async code, and is a popular choice for synchronous, thread-based code.

Let’s look at a practical example:

use async_channel::unbounded;
#[tokio::main]
async fn main() {
let (tx, rx) = unbounded::<String>();
// Clone the receiver for multiple workers.
let rx1 = rx.clone();
let rx2 = rx.clone();
let worker1 = tokio::spawn(async move {
while let Ok(message) = rx1.recv().await {
println!("Worker 1: {message}");
}
});
let worker2 = tokio::spawn(async move {
while let Ok(message) = rx2.recv().await {
println!("Worker 2: {message}");
}
});
// Send messages.
for i in 0..10 {
tx.send(format!("Job {i}")).await.unwrap();
}
drop(tx);
let _ = tokio::join!(worker1, worker2);
}

Here, both workers share cloned receivers. Whenever a message arrives, one of the waiting workers receives it and processes it.

If you run this, don’t be surprised if one worker handles most of the jobs. Since the work here is just a println!, a worker can grab several messages before the other even wakes up. The channel guarantees each message goes to exactly one receiver, not that the work is split evenly.

Notice that we don’t need to wrap the receiver inside Arc<Mutex<_>> ourselves. The channel handles the coordination internally.

Like Tokio, async-channel supports both bounded and unbounded channels. You can create a bounded one with async_channel::bounded(capacity), and the same backpressure rules we discussed earlier apply.

What if every worker needs the same message?

There’s an important distinction here. MPMC channels distribute messages among consumers, they don’t broadcast the same message to every consumer.

If you want every worker to receive a copy of the same message, you need a broadcast or publish-subscribe pattern instead.

For example, Tokio provides tokio::sync::broadcast, where multiple receivers can subscribe to a channel and receive copies of published messages. Keep in mind that slow receivers can lag and miss messages when the bounded broadcast buffer overwrites older messages.

Remember this: In MPMC, consumers compete to receive messages. It doesn’t mean every consumer receives every message. That’s broadcast or pub/sub behavior, which is a different messaging pattern.

6. Performance and Trade-offs

Now that we’ve explored Tokio MPSC and async-channel MPMC, let's discuss when each approach makes sense.

It’s tempting to assume that MPMC is always better because it allows multiple workers to consume messages. But that’s not necessarily true.

Consider these trade-offs:

  • MPSC: A single receiver makes message consumption straightforward. It’s a good choice when one task can comfortably handle the incoming workload.
  • MPMC: Multiple receivers allow us to distribute messages across workers. This is useful when processing tasks independently and a single consumer cannot keep up.
  • Memory management: Prefer bounded channels when you need predictable memory usage and backpressure. Unbounded channels can grow indefinitely if producers outpace consumers.
  • Synchronization overhead: MPMC requires coordination between multiple consumers. If your workload is small or processing is very lightweight, the additional coordination might not provide any meaningful benefit.
  • Message ordering: With a single consumer, messages are processed in the order they were received. With multiple consumers, that’s no longer true. Job 5 might finish before job 3. If order matters for your workload, MPMC may not be the right fit.

The important takeaway is that more consumers don’t automatically mean better performance.

If one consumer is sufficient, MPSC is often the simpler choice. If you need parallel message processing, MPMC is worth considering.

Ultimately, benchmark both approaches with your actual workload before making a decision.

Quick Note: If your application requires stronger reliability, message persistence, acknowledgements, message retries, delivery guarantees, or advanced messaging features, consider using dedicated messaging systems such as Redis Streams, RabbitMQ, or Apache Kafka. In-memory channels like Tokio MPSC and async-channel are great for communication between tasks within an application, but they aren't designed to provide durable message storage or survive application restarts.

Conclusion

We’ve explored how Rust provides different ways to handle concurrent message passing, from Tokio’s MPSC channels to async-channel's MPMC implementation.

The key takeaway is that there’s no universally best solution. If you have multiple producers and a single consumer, Tokio MPSC is a great choice. If you need multiple consumers to process messages concurrently, MPMC is worth considering.

Don’t introduce unnecessary complexity by choosing a more advanced primitive when a simpler one gets the job done. Understand your workload, choose the right synchronization model, and benchmark when performance matters.

As always, the goal isn’t to avoid locks everywhere, it’s to choose the right tool for the problem.

--

--

byte&borrow
byte&borrow

Written by byte&borrow

Backend engineer who tears systems apart to see how they work: OS, databases, networks. I practice offensive security, so I build assuming attackers.

Rustaceans
Rustaceans

Published in Rustaceans

Insights and Expertise from Rustaceans