0x55aa
← Back to Blog

Rust Channels: Stop Sharing State, Start Sharing Messages 🦀📨

8 min read

Rust Channels: Stop Sharing State, Start Sharing Messages 🦀📨

Hot take: Most concurrency bugs don't come from bad programmers. They come from threads sharing too much brain.

Two threads. One HashMap. Zero synchronization. Infinite suffering. We've all been there — or we've been spared because our language pretended threading didn't exist (looking at you, PHP 👀).

Coming from 7 years of Laravel and Node.js, my mental model for concurrency was basically: "Node.js does one thing at a time and I pray to the event loop." Laravel? Queues handle it. Simple. Safe. Blissfully naive.

Then I started learning Rust for my RF/SDR hobby projects — where I needed to decode radio signals in one thread while processing them in another — and I found channels. And honestly? It changed how I think about concurrent code entirely.

The Problem With Sharing State 🧠💥

Here's the classic threading nightmare, translated to something relatable:

// PHP (Swoole/parallel): the danger zone
$sharedData = [];

// Thread 1 writes
$sharedData['count'] = $sharedData['count'] + 1;

// Thread 2 ALSO writes, at the same time
$sharedData['count'] = $sharedData['count'] + 1;

// Result: count is 1, not 2. Data race. Chaos.

You add a mutex. Now you're debugging deadlocks. You add more locks. Now your "concurrent" code is slower than single-threaded code. It's turtles all the way down.

What excited me about Rust: The compiler literally won't let you write a data race. If you try to share mutable state between threads without proper synchronization, the code won't compile. Not a runtime error. Not a crash. A compile-time refusal. The borrow checker is the bouncer that memory safety forgot to invite to the PHP party. 🎉

Enter Channels: Don't Share Memory, Share Messages 📬

Rust's channels come from Go's famous mantra:

"Don't communicate by sharing memory; share memory by communicating."

The idea: instead of threads fighting over the same data, they pass messages through a channel. One thread sends. One thread receives. Clean. Safe. Composable.

Rust's standard library gives you std::sync::mpsc — which stands for Multiple Producer, Single Consumer. Fancy name, simple concept:

use std::sync::mpsc;
use std::thread;

fn main() {
    // Create a channel — like a pipe with two ends
    let (sender, receiver) = mpsc::channel();

    // Spawn a thread and give it the SENDER end
    thread::spawn(move || {
        sender.send("Signal decoded: 433.92 MHz 🔊").unwrap();
    });

    // Main thread RECEIVES from the other end
    let message = receiver.recv().unwrap();
    println!("Got: {}", message);
}

That's it. Thread talks to thread via a message. No shared HashMap. No mutex dance. No data race. The compiler ensures that once you move the sender into the thread, you can't use it elsewhere. Ownership protects you again. 🦀

The RF/SDR Use Case That Made This Click 📡

For my RTL-SDR hobby projects, I need a signal processing pipeline:

  1. Thread 1: Read raw IQ samples from the USB dongle
  2. Thread 2: Decode the signal and extract data
  3. Thread 3: Log or display the results

Before channels, I was imagining some nightmare of shared buffers and mutexes. With channels, it maps beautifully:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (raw_tx, raw_rx) = mpsc::channel::<Vec<f32>>();
    let (decoded_tx, decoded_rx) = mpsc::channel::<String>();

    // Thread 1: "SDR reader" — sends raw samples
    thread::spawn(move || {
        loop {
            let samples = read_from_sdr(); // your SDR library call
            raw_tx.send(samples).unwrap();
        }
    });

    // Thread 2: "Decoder" — receives samples, sends decoded strings
    thread::spawn(move || {
        for samples in raw_rx {
            let decoded = decode_signal(&samples);
            decoded_tx.send(decoded).unwrap();
        }
    });

    // Main thread: display results
    for message in decoded_rx {
        println!("📻 {}", message);
    }
}

Each thread has one job. They talk through channels. No shared mutable state anywhere. The Rust compiler verifies this at compile time. If I accidentally tried to use raw_tx from two places without cloning it, the compiler would say no.

That's the dream. That's the pipeline. 🛠️

Multiple Producers: The mpsc "Multiple" Part 📤📤📤

The "multiple producer" in mpsc means you can have many senders feeding one receiver. Clone the sender, hand copies to different threads:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    // Clone the sender for each thread
    for band in ["FM", "AM", "ADS-B"] {
        let tx_clone = tx.clone();

        thread::spawn(move || {
            // Each thread monitors a different radio band
            let result = monitor_band(band);
            tx_clone.send(format!("{}: {}", band, result)).unwrap();
        });
    }

    // Drop the original sender so the channel closes when all threads finish
    drop(tx);

    // Collect all results
    for msg in rx {
        println!("📡 {}", msg);
    }
}

Coming from Node.js: this is like Promise.all() but for threads, and with actual parallelism. No event loop bottleneck. Multiple cores, all working, all talking back to one place. 🚀

Blocking vs Non-Blocking Receives ⏳

The receiver has two modes:

// recv(): blocks until a message arrives (or channel closes)
let msg = receiver.recv().unwrap();

// try_recv(): returns immediately — Ok(msg) or Err(Empty)
match receiver.try_recv() {
    Ok(msg) => println!("Got: {}", msg),
    Err(_) => println!("No message yet, doing other work..."),
}

Coming from PHP async patterns: recv() is like await — you're waiting for something. try_recv() is like Promise.race() with a "nothing ready yet" option. For real-time signal monitoring, try_recv() means your main thread doesn't freeze waiting for data. It can update a UI, check for user input, and still process incoming radio packets.

Bounded Channels: Don't Let the Buffer Explode 💣

The default channel is unbounded — the sender can flood it with millions of messages before the receiver catches up. For some use cases (like my SDR pipeline), you want backpressure: if the decoder can't keep up with the raw data, slow down the reader.

std::sync::mpsc only has unbounded channels. But the crossbeam crate gives you bounded:

// crossbeam-channel: add to Cargo.toml first
// crossbeam-channel = "0.5"

use crossbeam_channel::bounded;

let (tx, rx) = bounded(100); // max 100 messages buffered

// Sender BLOCKS when buffer is full — natural backpressure!
// Perfect for: don't process faster than you can decode

For SDR work this is essential. If your decoder can handle 50 packets/second but your SDR reader captures 10,000/second, an unbounded channel eats your RAM in minutes. A bounded channel makes the reader wait. Backpressure as a feature, not a hack.

The Comparison That Made Me Laugh 😂

// PHP "concurrency" (Swoole coroutines — close enough for vibes)
$chan = new Swoole\Coroutine\Channel(10);

go(function() use ($chan) {
    $chan->push("hello from coroutine");
});

$msg = $chan->pop();
echo $msg; // "hello from coroutine"
// Rust channels — actual OS threads, actual parallelism
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    tx.send("hello from actual OS thread").unwrap();
});

let msg = rx.recv().unwrap();
println!("{}", msg); // "hello from actual OS thread"

PHP's coroutines are cooperative (they yield). Rust's threads are preemptive (the OS schedules them). In PHP, you still have one core at a time doing the work. In Rust, your threads run on DIFFERENT CORES SIMULTANEOUSLY. For my radio signal decoder, that's not a philosophical difference — it's a real performance difference I can measure. 🔥

When NOT to Use Channels 🚫

Channels are great, but they're not always the answer:

  • Simple shared counters? Use std::sync::atomic::AtomicUsize. Channels add overhead.
  • Read-heavy data with rare writes? Use Arc<RwLock<T>> — multiple readers, exclusive writers.
  • Complex state shared across 10 threads? You might need channels AND some shared state. It's fine to mix.

The rule of thumb: channels are best for pipelines and work distribution. Shared state with Mutex/RwLock is best for small, frequently-accessed data that many threads need to read.

TL;DR: The Channel Mindset 📋

  1. Channels pass ownership — the sender gives up the value, the receiver takes it. No shared mutable state.
  2. mpsc = many senders, one receiver — clone the sender for multiple producer threads.
  3. recv() blocks, try_recv() doesn't — pick based on whether you can afford to wait.
  4. Bounded channels prevent runaway buffers — use crossbeam-channel for backpressure in pipelines.
  5. Channels + threads = safe parallelism the compiler verifies — not a runtime guarantee, a compile-time one.

Coming from 7 years of Laravel and Node.js, I spent years either avoiding threading entirely or trusting frameworks to do it safely. Rust channels gave me the language-level guarantee I never knew I needed: if it compiles, your threads aren't fighting over the same data.

For my SDR projects, that's not just a nice property — it's the difference between a signal decoder that works and one that randomly corrupts packets at 3am while monitoring aircraft. 📡

The threads have their own lanes now. And everyone's happier for it. 🦀


Building concurrent Rust programs for fun? Find me on LinkedIn — I'm probably arguing with the borrow checker about which thread owns my SDR buffer.

Curious about the RF/SDR signal pipeline? Check out my GitHub for hobby projects where channels actually decode real radio signals.

Send messages. Not mutable references. 🦀📨