Channels implement the actor model — concurrent tasks communicate by sending messages rather than Sharing memory. Rust provides several channel types, each optimized for different communication Patterns. The sender and receiver are separate endpoints; messages are moved from sender to Receiver, transferring ownership.
Type Producers Consumers Buffering Use Case std::sync::mpscMultiple Single Bounded/Unbounded Simple work distribution tokio::sync::mpscMultiple Single Bounded/Unbounded Async work distribution oneshotSingle Single None Single response broadcastSingle Multiple Bounded Pub/sub notifications watchSingle Multiple Single value Configuration updates
The standard library”s channel is synchronous (blocking) and designed for OS threads:
let (tx, rx) = mpsc :: channel ();
let val = String :: from ( "hello" );
// val is moved — no longer accessible here
let received = rx . recv () . unwrap ();
assert_eq! (received, "hello" );
Clone the sender to create multiple producers:
let (tx, rx) = mpsc :: channel ();
tx . send ( "from thread 1" ) . unwrap ();
tx1 . send ( "from thread 2" ) . unwrap ();
println! ( "got: {}" , received);
When all senders are dropped, recv() returns Err and the iterator terminates.
let (tx, rx) = mpsc :: channel (); // unbounded — grows as needed
let (tx, rx) = mpsc :: sync_channel ( 10 ); // bounded — capacity 10
Method Blocking? Returns tx.send(val)Yes (if bounded and full) Result<(), SendError<T>>rx.recv()Yes (if empty and senders exist) Result<T, RecvError>rx.try_recv()No Result<T, TryRecvError>rx.recv_timeout(dur)Yes (with timeout) Result<T, RecvTimeoutError>
Tokio’s async channel uses .await instead of blocking:
let (tx, mut rx) = mpsc :: channel ( 32 );
tokio :: spawn ( async move {
tx . send ( "hello" ) .await. unwrap ();
while let Some (msg) = rx . recv () .await {
let (tx, rx) = mpsc :: channel ( 32 ); // bounded — capacity 32
let (tx, rx) = mpsc :: unbounded_channel (); // unbounded — grows as needed
Prefer bounded channels with an appropriate buffer size. If the buffer fills, backpressure Slows producers. let (tx, mut rx) = mpsc :: channel ( 1 );
tokio :: spawn ( async move {
if let Err (e) = tx . send ( "message" ) .await {
eprintln! ( "send failed: receiver dropped: {}" , e);
tx.send() returns Err(SendError<T>) when the receiver has been dropped. The error contains the Unsent value.
Oneshot channels send a single value from producer to consumer:
use tokio :: sync :: oneshot;
let (tx, rx) = oneshot :: channel ();
tokio :: spawn ( async move {
let result = compute_value () .await ;
let _ = tx . send (result); // ignore send error if receiver dropped
Ok (value) => println! ( "got: {}" , value),
Err (_) => println! ( "sender dropped" ),
Oneshot channels are zero-cost — they use a single slot with no buffer. They are ideal for Request-response patterns where each request gets exactly one response.
use tokio :: sync :: oneshot;
let (cancel_tx, cancel_rx) = oneshot :: channel ();
tokio :: spawn ( async move {
_ = tokio :: time :: sleep ( Duration :: from_secs ( 1 )) => {
tokio :: time :: sleep ( Duration :: from_millis ( 2500 )) .await ;
cancel_tx . send (()) . unwrap ();
Broadcast channels deliver each message to all active receivers:
use tokio :: sync :: broadcast;
let (tx, _) = broadcast :: channel ( 16 );
let mut rx1 = tx . subscribe ();
let mut rx2 = tx . subscribe ();
tokio :: spawn ( async move {
while let Ok (msg) = rx1 . recv () .await {
println! ( "receiver 1: {}" , msg);
tokio :: spawn ( async move {
while let Ok (msg) = rx2 . recv () .await {
println! ( "receiver 2: {}" , msg);
tx . send ( "hello" ) . unwrap ();
tx . send ( "world" ) . unwrap ();
tokio :: time :: sleep ( Duration :: from_millis ( 100 )) .await ;
Each subscribe() creates a new receiver Receivers that lag behind (buffer full) receive RecvError::Lagged(n) indicating how many messages were skipped The sender does NOT wait for receivers — messages are fire-and-forget The buffer is per-channel, not per-receiver use tokio :: sync :: broadcast;
let (tx, _) = broadcast :: channel ( 2 );
tx . send ( 3 ) . unwrap (); // pushes out message 1
let mut rx = tx . subscribe ();
assert_eq! (rx . recv () .await. unwrap (), 2 ); // message 1 was lagged
assert_eq! (rx . recv () .await. unwrap (), 3 );
Watch channels broadcast the latest value to all receivers. Unlike broadcast, watch retains only the Most recent value — there is no message queue:
let (tx, rx) = watch :: channel ( "initial" );
let mut rx1 = rx . clone ();
tokio :: spawn ( async move {
rx1 . changed () .await. unwrap ();
println! ( "watcher 1: {}" , * rx1 . borrow ());
let mut rx2 = rx . clone ();
tokio :: spawn ( async move {
rx2 . changed () .await. unwrap ();
println! ( "watcher 2: {}" , * rx2 . borrow ());
tx . send ( "update 1" ) . unwrap ();
tx . send ( "update 2" ) . unwrap ();
tokio :: time :: sleep ( Duration :: from_millis ( 100 )) .await ;
Property watchbroadcastMessages Single latest value All messages in a queue Buffer 1 (always) Configurable Lag handling No lag — always latest RecvError::LaggedUse case Configuration updates Event streams, logs
let (tx, mut rx) = mpsc :: channel ( 32 );
tokio :: spawn ( async move {
let result = process (i) .await ;
tx . send (result) .await. unwrap ();
let mut results = vec! [];
while let Some (result) = rx . recv () .await {
let (in_tx, mut in_rx) = mpsc :: channel ( 32 );
let (out_tx, mut out_rx) = mpsc :: channel ( 32 );
let mut in_rx = in_rx . resubscribe ();
let out_tx = out_tx . clone ();
tokio :: spawn ( async move {
while let Some (work) = in_rx . recv () .await {
let result = process (work) .await ;
out_tx . send (result) .await. unwrap ();
in_tx . send (i) .await. unwrap ();
let mut results = vec! [];
while let Some (result) = out_rx . recv () .await {
Bounded channels provide backpressure — when the buffer is full, send() blocks (or Awaits) until the receiver consumes a message:
let (tx, mut rx) = mpsc :: channel ( 4 );
tokio :: spawn ( async move {
tx . send (i) .await. unwrap (); // blocks when buffer is full
while let Some (value) = rx . recv () .await {
The actor model encapsulates state in an actor that processes messages sequentially from a mailbox:
receiver : mpsc :: Receiver < Message >,
GetCount ( tokio :: sync :: oneshot :: Sender < usize >),
fn new (receiver : mpsc :: Receiver < Message >) -> Self {
Actor { receiver, counter : 0 }
async fn run ( &mut self ) {
while let Some (msg) = self . receiver . recv () .await {
Message :: GetCount (reply) => {
let _ = reply . send ( self . counter);
sender : mpsc :: Sender < Message >,
let (tx, rx) = mpsc :: channel ( 8 );
tokio :: spawn ( async move {
let mut actor = Actor :: new (rx);
ActorHandle { sender : tx }
async fn increment ( & self ) {
self . sender . send ( Message :: Increment ) .await. unwrap ();
async fn get_count ( & self ) -> usize {
let (reply_tx, reply_rx) = tokio :: sync :: oneshot :: channel ();
self . sender . send ( Message :: GetCount (reply_tx)) .await. unwrap ();
async fn shutdown ( & self ) {
self . sender . send ( Message :: Shutdown ) .await. unwrap ();
let actor = ActorHandle :: new ();
let count = actor . get_count () .await ;
select! waits on multiple channel operations simultaneously:
use tokio :: time :: {sleep, Duration };
let (tx1, mut rx1) = mpsc :: channel ( 32 );
let (tx2, mut rx2) = mpsc :: channel ( 32 );
tokio :: spawn ( async move {
sleep ( Duration :: from_millis ( 100 )) .await ;
tx1 . send ( "from channel 1" ) .await. unwrap ();
tokio :: spawn ( async move {
sleep ( Duration :: from_millis ( 50 )) .await ;
tx2 . send ( "from channel 2" ) .await. unwrap ();
msg = rx1 . recv () => println! ( "channel 1: {:?}" , msg),
msg = rx2 . recv () => println! ( "channel 2: {:?}" , msg),
SendError occurs when the receiver has been dropped:
let (tx, rx) = mpsc :: channel ( 1 );
match tx . send ( "hello" ) .await {
Err ( mpsc :: error :: SendError (msg)) => {
println! ( "receiver dropped, message was: {}" , msg);
Ok (()) => println! ( "sent" ),
RecvError occurs when all senders have been dropped and the channel is empty:
let (tx, mut rx) = mpsc :: channel ( 1 );
Err ( mpsc :: error :: RecvError ) => println! ( "all senders dropped" ),
Ok (msg) => println! ( "received: {}" , msg),
let (tx, mut rx) = mpsc :: channel ( 1 );
Ok (msg) => println! ( "received: {}" , msg),
Err ( mpsc :: error :: TryRecvError :: Empty ) => println! ( "channel empty" ),
Err ( mpsc :: error :: TryRecvError :: Disconnected ) => println! ( "disconnected" ),
Buffer Size Behavior 0 Synchronous handoff — sender waits for receiver 1 Minimal buffering — good for ping-pong 10-100 General purpose — balances throughput and latency 1000+ High throughput — producers rarely block Unbounded No backpressure — risk of memory exhaustion
Bounded channels with larger buffers generally have higher throughput because senders block less Often. However, larger buffers increase memory usage and latency (messages sit in the buffer longer Before being processed).
// High-throughput scenario — large buffer
let (tx, rx) = mpsc :: channel ( 10_000 );
// Low-latency scenario — small buffer
let (tx, rx) = mpsc :: channel ( 1 );
Each message sent through a channel is moved (not copied). For large messages, consider sending Arc<T> to avoid expensive moves:
let (tx, mut rx) = mpsc :: channel ( 32 );
let large_data = Arc :: new ( vec! [ 0 u8 ; 1_000_000 ]);
tx . send ( Arc :: clone ( & large_data)) .await. unwrap ();
// DEADLOCK: both tasks wait for each other
let (tx1, mut rx1) = mpsc :: channel ( 1 );
let (tx2, mut rx2) = mpsc :: channel ( 1 );
// Task 1: sends to tx1, then waits on rx2
// Task 2: sends to tx2, then waits on rx1
// If both channels are bounded with capacity 1 and both tasks send before receiving, deadlock
Use unbounded channels if backpressure is not required Ensure send and receive operations alternate (no circular dependencies) Use select! with timeouts to break potential deadlocks Use try_send() with backoff instead of blocking send() Forgetting to drop the sender. The receiver’s recv() loop never terminates if any sender is still alive. Drop all senders when done producing.
Unbounded channels causing OOM. Unbounded channels grow without limit if producers outpace consumers. Use bounded channels with appropriate buffer sizes.
Blocking send in async code. std::sync::mpsc::Sender::send() blocks the thread. Use tokio::sync::mpsc::Sender::send().await in async contexts.
Broadcast receivers lagging. If a broadcast receiver is too slow, messages are dropped and it receives RecvError::Lagged. Handle this error explicitly.
Watch channels and initial values. watch::channel() takes an initial value. The first changed().await returns immediately because the initial value counts as a “change.” Use rx.borrow() to check the current value without waiting.
Channel leaks. If a task holding a channel sender panics without dropping it, the channel stays open. Use scopeguard or explicit drop() in cleanup code.
Sending non-Send types across async channels. tokio::sync::mpsc requires T: Send. Use tokio::sync::mpsc::unbounded_channel() for local channels within a single task, or wrap the type in Arc.
Ignoring SendError. tx.send() returns Result. If the receiver is dropped, the send fails. Ignoring this error silently loses messages.
Using channels for fine-grained communication. Channels have overhead (allocation, atomic operations, context switches). For very frequent communication between tasks, consider shared state with Arc<Mutex<T>> or atomics.
Actor mailbox overflow. If messages arrive faster than the actor processes them, the channel buffer fills up and senders block. Size the buffer appropriately and consider backpressure mechanisms.
graph TD
A[Need inter-task communication?] --> B{How many senders?}
B -->|One| C{One-time message?}
C -->|Yes| D[oneshot channel]
C -->|No| E{How many receivers?}
B -->|Multiple| F{Sync or async?}
F -->|Sync| G[std::sync::mpsc]
F -->|Async| H{All receivers need all messages?}
E -->|One| H
H -->|Yes| I{Latest value only?}
I -->|Yes| J[watch channel]
I -->|No| K[broadcast channel]
H -->|No, one consumer| L[tokio::sync::mpsc]
E -->|Multiple| I Combine mpsc for requests and oneshot for responses:
use tokio :: sync :: {mpsc, oneshot};
GetData { key : String , respond : oneshot :: Sender < Option < String >> },
SetData { key : String , value : String , respond : oneshot :: Sender < bool > },
receiver : mpsc :: Receiver < Request >,
data : std :: collections :: HashMap < String , String >,
fn new (receiver : mpsc :: Receiver < Request >) -> Self {
data : std :: collections :: HashMap :: new (),
async fn run ( &mut self ) {
while let Some (req) = self . receiver . recv () .await {
Request :: GetData { key, respond } => {
let _ = respond . send ( self . data . get ( & key) . cloned ());
Request :: SetData { key, value, respond } => {
self . data . insert (key, value);
let _ = respond . send ( true );
sender : mpsc :: Sender < Request >,
fn new (sender : mpsc :: Sender < Request >) -> Self {
async fn get ( & self , key : & str ) -> Option < String > {
let (tx, rx) = oneshot :: channel ();
self . sender . send ( Request :: GetData {
async fn set ( & self , key : & str , value : & str ) -> bool {
let (tx, rx) = oneshot :: channel ();
self . sender . send ( Request :: SetData {
value : value . to_string (),
rx .await. unwrap_or ( false )
Chain multiple stages, each consuming from one channel and producing to the next:
let (input_tx, input_rx) = mpsc :: channel ( 32 );
let (stage1_tx, stage1_rx) = mpsc :: channel ( 32 );
let (stage2_tx, stage2_rx) = mpsc :: channel ( 32 );
tokio :: spawn ( async move {
input_tx . send (i) .await. unwrap ();
tokio :: spawn ( async move {
while let Some (value) = input_rx . recv () .await {
stage1_tx . send (value * 2 ) .await. unwrap ();
tokio :: spawn ( async move {
while let Some (value) = stage1_rx . recv () .await {
stage2_tx . send (value + 1 ) .await. unwrap ();
let mut results = vec! [];
while let Some (value) = stage2_rx . recv () .await {
Merge multiple channels into one:
async fn merge_channels () {
let (out_tx, mut out_rx) = mpsc :: channel ( 32 );
let (tx, rx) = mpsc :: channel ( 32 );
let mut handles = vec! [];
let out_tx = out_tx . clone ();
handles . push ( tokio :: spawn ( async move {
while let Some (value) = rx . recv () .await {
if out_tx . send (value) .await. is_err () {
while let Some (value) = out_rx . recv () .await {
println! ( "merged: {}" , value);
Batch incoming messages before processing:
use tokio :: time :: {sleep, Duration };
async fn batch_processor () {
let (tx, mut rx) = mpsc :: channel ( 100 );
let timeout = Duration :: from_millis ( 100 );
tokio :: spawn ( async move {
if let Some (value) = msg {
while batch . len () < batch_size {
Ok (value) => batch . push (value),
process_batch ( & batch) .await ;
async fn process_batch (batch : & [ i32 ]) {
println! ( "processing batch of {} items" , batch . len ());
When a receiver drops and the sender detects it, you can create a new channel pair:
struct ReconnectableSender < T > {
current_tx : Option <mpsc :: Sender < T >>,
impl < T > ReconnectableSender < T > {
ReconnectableSender { current_tx : None }
fn connect ( &mut self ) -> mpsc :: Receiver < T > {
let (tx, rx) = mpsc :: channel ( 32 );
self . current_tx = Some (tx);
async fn send ( &mut self , value : T ) -> Result <(), mpsc :: error :: SendError < T >> {
Some (tx) => tx . send (value) .await ,
None => Err ( mpsc :: error :: SendError (value)),
Use a dedicated shutdown signal to coordinate channel closure:
use tokio :: sync :: {mpsc, broadcast};
async fn graceful_shutdown () {
let (work_tx, mut work_rx) = mpsc :: channel ( 32 );
let (shutdown_tx, _) = broadcast :: channel ( 1 );
let mut shutdown_rx = shutdown_tx . subscribe ();
let worker = tokio :: spawn ( async move {
work = work_rx . recv () => {
if let Some (job) = work {
_ = shutdown_rx . recv () => {
println! ( "worker received shutdown signal" );
println! ( "worker finished" );
work_tx . send ( Job :: Task ( "work item" )) .await. unwrap ();
shutdown_tx . send (()) . unwrap ();
Decoupling producer and consumer timing Work distribution across tasks Pipeline architectures Actor model patterns Simple flags or counters (use AtomicBool``AtomicUsize) Configuration that changes infrequently (use Arc<RwLock<T>> or watch) Data structures that need coordinated access (use Arc<Mutex<T>>) Combine channels for task distribution with shared state for configuration:
use tokio :: sync :: {mpsc, watch};
let (tx, mut rx) = mpsc :: channel ( 32 );
let (config_tx, config_rx) = watch :: channel ( Config :: default ());
tokio :: spawn ( async move {
let config = config_rx . borrow () . clone ();
while let Some (work) = rx . recv () .await {
process_with_config (work, & config) .await ;
config_tx . send ( Config { verbose : true }) . unwrap ();
This topic covers the core concepts of channels and message passing, including underlying theory, practical implementation, and key applications.
Key concepts include:
core concepts and terminology algorithms and computational thinking practical implementation security and ethical considerations applications in the real world Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
## Intuition
Channels are Rust’s message-passing primitive, inspired by Go’s CSP model. mpsc channels allow multiple producers but single consumption. Crossbeam provides multi-producer multi-consumer channels with better performance. Messages are moved through channels, transferring ownership and preventing shared state. This pattern logically serializes access to shared resources without locks, and the type system ensures messages cannot be used after being sent.
[[rust/06-concurrency/concurrency]] - Thread creation and management [[rust/02-ownership-borrowing/ownership]] - Ownership transfer through channels [[rust/05-traits-generics/traits-and-generics]] - Generic channel type parameters [[rust/04-error-handling/error-handling]] - Error propagation in concurrent code