What Hacking Concurrent Code in Rust Is Like

The hands-on experience of writing multi-threaded Rust, how the compiler turns data races into compile-time errors, and why that changes the way you design concurrent software.

The first time you write a multi-threaded program in a language like C or C++, you compile it, run it, and it might work. A week later it crashes in production on a Tuesday at 3 a.m. with a stack trace that makes no sense. That feeling of "I have no idea if this is correct" is what concurrency programming feels like in most languages.

Rust replaces that uncertainty with something much more productive: the compiler argues with you until your design is sound. It will not let you ship code with a data race. The compiler stops you, prints a clear error, and forces you to think about who owns which data and how it’s shared. The process of writing concurrent Rust feels less like fighting mysterious runtime bugs and more like solving a well-defined puzzle where the rules are known up front. That is what "hacking" concurrent code in Rust is like.

The Compiler as a Thread-Safety Gate

When you write a threaded program in Rust, you are not just writing code that the compiler translates to machine instructions. You are providing a proof that your code follows the ownership and borrowing rules across thread boundaries. The compiler checks that proof every time you run cargo build.

Two traits, Send and Sync, are the gatekeepers. The compiler automatically implements them for types that are safe to transfer between threads or share across threads. If your code tries to move a non-Send value to another thread, or share a non-Sync value across threads, compilation fails. You never get a runtime data race because the code that would cause it never becomes an executable.

Send and Sync are marker traits:

You rarely implement Send or Sync by hand. The compiler derives them automatically based on the fields of your types. A struct is Send if all its fields are Send; it’s Sync if all its fields are Sync. This means the safety check composes automatically through the types you define.

This changes the development loop. Instead of writing code, running it, and watching a race condition corrupt a counter after ten thousand iterations, you write code and immediately see a compiler error that explains exactly which type is not thread-safe and why. You fix it, recompile, and only when the compiler is satisfied do you run the program. The first run typically works as intended, because the compiler already caught the class of bugs that normally take days to debug.

The Flow of Writing a Concurrent Rust Function

Picture a common task: you have a vector of numbers and want to increment each element in parallel using multiple threads. In a language without safety guarantees, you might spin up threads that all write to overlapping slices and hope synchronization is correct. In Rust, the process looks like this:

  1. You write the code that seems natural, using thread::spawn and closures that capture data.
  2. The compiler rejects it with a message about Send not being implemented or a value being moved.
  3. You read the error and realize you need to share ownership with Arc or protect the data with a Mutex, or maybe restructure to use message passing instead.
  4. You adjust, recompile, and repeat until the compiler accepts it.
  5. You run the program and it behaves predictably, even under heavy load.

This loop is not a sign that you are fighting the language. It is the language actively teaching you how to structure concurrent code safely. Over time, you internalize the patterns and the compiler errors become rarer.

The First Wall: move Closures and Ownership

The most common first compiler error when spawning a thread is about a closure that borrows data from its environment but the borrow might outlive the scope. This happens because closures capture variables by reference by default, and the spawned thread could outlive the function that created the data.

use std::thread;
fn main() {
    let numbers = vec![1, 2, 3];
    thread::spawn(|| {
        println!("{:?}", numbers);
    });
}

This fails with something like:

error[E0373]: closure may outlive the current function, but it borrows `numbers`

The fix is to force the closure to take ownership of the variables it uses by adding the move keyword.

use std::thread;
fn main() {
    let numbers = vec![1, 2, 3];
    thread::spawn(move || {
        println!("{:?}", numbers);
    })
    .join()
    .unwrap();
}

Now numbers is moved into the closure, guaranteeing that the thread owns the data and no dangling reference occurs. The compiler error was not a nuisance; it prevented a use-after-free that would have been undefined behavior in other languages.

Forgetting move is a compile-time error, but forgetting it conceptually is a runtime crash elsewhere:

In C or C++, forgetting that a reference outlives the data it points to leads to a dangling pointer. The program might crash, or worse, silently read garbage. Rust turns that entire category of bug into a compiler error you see before the program ever runs.

Sharing Mutable State — The Arc<Mutex<T>> Pattern

Many concurrent programs need multiple threads to read and modify the same piece of data. The Rust compiler forces you to be explicit about the sharing and the mutation, leading to the standard pattern: wrap the data in a Mutex for safe mutation and wrap that in an Arc for shared ownership.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Result: {}", *counter.lock().unwrap());
}

This code compiles and prints Result: 10. The compiler accepted it because every piece of the puzzle satisfies the thread-safety traits: Arc is Send and Sync when the inner type is, Mutex is Send and Sync regardless of the inner type (it provides its own synchronization), and i32 is both Send and Sync.

When you write this, the mental load is different from other languages. You are not wondering, "Did I lock this mutex in every code path? Did I unlock it at the right time?" Rust’s Mutex returns a MutexGuard that unlocks automatically when it goes out of scope. The compiler ensures you cannot access the protected data without holding the lock because the data is only accessible through the guard. This eliminates both forgotten unlocks and accidental unlocked access.

If this compiles, you have eliminated data races:

The program might still have logical race conditions (wrong order of operations), but data races — unsynchronized concurrent access where at least one is a write — are impossible in safe Rust. The compiler guarantees it.

When the Compiler Rejects a Design You Thought Was Sound

The experience of hacking on concurrent Rust often involves the compiler telling you no to something that looks reasonable. For example, you might try to use Rc (a reference-counted pointer) instead of Arc because you are familiar with it from single-threaded Rust.

use std::rc::Rc;
use std::thread;
fn main() {
    let data = Rc::new(5);
    let data_clone = Rc::clone(&data);
    thread::spawn(move || {
        println!("{}", data_clone);
    });
}

The compiler says:

error[E0277]: `Rc<i32>` cannot be sent between threads safely

The reason is that Rc's reference count uses non-atomic operations. It is fast in single-threaded code but would cause a data race if two threads incremented the count simultaneously. The compiler prevents you from making that mistake. The error message points you directly to the solution: use Arc instead.

This kind of guidance is not a limitation; it's the compiler acting as a senior engineer who has seen every concurrency bug in the book and stops you before you commit them. Over time, you start anticipating these rejections. You reach for Arc instinctively when threads are involved, and you understand why Rc doesn't implement Send.

Message Passing: When You Stop Sharing Memory Entirely

There is a different mental model that Rust encourages, captured in the phrase "Do not communicate by sharing memory; instead, share memory by communicating." Using channels, you send data from one thread to another, transferring ownership with the message. The compiler checks that the sent type is Send and that the receiving thread has exclusive ownership.

use std::sync::mpsc;
use std::thread;
fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let val = String::from("hello");
        tx.send(val).unwrap();
    });
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

After tx.send(val), the original thread can no longer use val because ownership was transferred to the channel. The receiving thread gets it, and no synchronization beyond the channel itself is needed. This pattern eliminates entire categories of shared-mutable-state bugs because there is no shared mutable state to begin with.

When you work this way, the compiler’s job is simpler, and your job as a programmer is simpler. You think in terms of data flowing between isolated threads rather than data being juggled under locks. Many concurrent designs that feel awkward with mutexes become clean and obvious with channels.

Channels don't prevent deadlocks:

If you have a cycle of threads waiting on each other through channels, you can still deadlock. Rust protects against data races, not all concurrency pitfalls. You still need to reason about your program’s communication topology.

What Rust Does Not Protect Against (And What That Feels Like)

Rust’s safety guarantees are not a silver bullet. It eliminates data races at compile time, but it does not prevent:

  • Deadlocks: Two mutexes acquired in different orders can still deadlock.
  • Race conditions: Logical errors where the correct outcome depends on the timing of threads, even though all accesses are properly synchronized.
  • Starvation and unfair scheduling: Threads might wait indefinitely for a lock if other threads keep holding it.

The experience, however, is that these remaining problems are far easier to reason about when you know data races are off the table. You can focus on the order of lock acquisition or the design of your state machine without simultaneously worrying about whether a torn write corrupted your memory. Debugging a deadlock with a debugger that shows who holds which mutex is a qualitatively different experience from chasing a memory corruption that only manifests on a specific CPU.

The compiler doesn’t pretend to solve these; it just removes the most insidious class of concurrency bugs so you can concentrate on the rest.

The Feeling When It Finally Compiles and Runs

After the cycle of compiler rejections, rethinking ownership, and possibly restructuring into channels or finer-grained locks, you reach the point where cargo build succeeds and you run the program. In many languages, that moment is tense: you expect a segfault or a race. In Rust, if the program involves only safe code, it is extremely unlikely to crash from memory corruption or data races. The first run often behaves correctly, and high-load testing reveals performance bottlenecks rather than corruption.

There is a shift in confidence. You start trusting that if the compiler says it’s okay, the fundamental thread-safety properties hold. That trust allows you to experiment more aggressively with parallelism because the cost of a mistake is a compiler error, not a 3 a.m. production outage.

Summary

Hacking concurrent code in Rust is a conversation with the compiler about who owns what and how it’s shared. The compiler forces you to make those decisions explicit up front, and in exchange it eliminates data races entirely. The process can be challenging, especially when your first instinct involves shared mutable state, but the resulting code is robust, predictable, and easier to refactor. The skill you develop is not just writing thread-safe code, but designing systems where thread-safety falls out of the ownership model naturally.