RustFearless Concurrency

Fearless Concurrency

"Fearless concurrency" is Rust's promise: you can write multithreaded code confidently because the type system catches entire classes of concurrency bugs at compile time, before your program ever runs.

In most languages, concurrency is a source of anxiety — data races, use-after-free in threads, forgotten synchronisation. In Rust, the compiler acts as a second pair of eyes on every concurrent access to shared data, and it refuses to compile code that would cause a data race.

What Is a Data Race?

A data race occurs when all three of the following are true simultaneously:

  1. Two or more threads access the same memory location.
  2. At least one of those accesses is a write.
  3. The accesses are not synchronised (no mutex, no atomic, no happens-before relationship).

Data races are undefined behaviour in C and C++. The program might produce wrong output, crash, or appear to work correctly while silently corrupting memory. They are notoriously hard to reproduce because they depend on exact thread scheduling, which varies between runs and machines.

Warning
A data race is not the same as a race condition. A race condition is any situation where correctness depends on thread scheduling order. Rust prevents data races (memory unsafety); logic-level race conditions still require careful design.
How Rust Prevents Data Races

Rust's ownership and borrowing rules — which you already use to prevent dangling references in single-threaded code — extend naturally to threads:

  • You can have many immutable (&T) references OR one mutable (&mut T) reference — never both at the same time.
  • The borrow checker enforces this across thread boundaries, not just within a function.

A data race requires at least one writer and at least one concurrent reader or writer. Rust's exclusive mutable reference rule makes that impossible in safe code: if one thread has &mut T, no other reference to T can exist anywhere.

RUST
// The borrow checker prevents simultaneous mutation in a single thread…
fn single_thread() {
    let mut data = vec![1, 2, 3];
    let r = &data;           // immutable borrow
    println!("{:?}", r);
    // data.push(4);         // compile error — cannot mutate while borrowed
    drop(r);
    data.push(4);            // fine once the borrow ends
}

// …and the same rules extend across thread boundaries.
// Sending &mut data to two threads simultaneously is impossible in safe Rust.
The Send and Sync Marker Traits

Rust encodes thread safety in the type system through two marker traits — traits with no methods that exist solely to carry a compile-time guarantee.

Trait

Meaning

Automatically derived?

Send

It is safe to transfer ownership of T to another thread

Yes, for almost all types

Sync

It is safe to share &T across threads (T: Sync implies &T: Send)

Yes, for almost all types

Most types are automatically Send + Sync because their data is either owned (one owner at a time) or immutably shared. The exceptions are types that use non-atomic internal mutation.

Type

Send

Sync

Reason

i32, f64, bool, …

Yes

Yes

Plain data — safe everywhere

String, Vec<T>

Yes

Yes

Owned, no shared interior mutation

Arc<T> (T: Send+Sync)

Yes

Yes

Atomic reference counting

Mutex<T> (T: Send)

Yes

Yes

Guards all mutable access

Rc<T>

No

No

Non-atomic reference count — use Arc instead

RefCell<T>

Yes

No

Runtime borrow checking is not thread-safe — use Mutex

Raw pointer *const T

No

No

Opt out of all guarantees; unsafe impl required

MutexGuard<T>

No

Yes

Must be unlocked on the same thread it was locked

What the Compiler Catches

The most common mistake — sending an Rc&lt;T&gt; to another thread — is a compile-time error. Rc uses non-atomic reference counting; if two threads incremented the count simultaneously, the count could be corrupted.

RUST
use std::rc::Rc;
use std::thread;

fn main() {
    let value = Rc::new(42);
    let value_clone = Rc::clone(&value);

    // ERROR: Rc<i32> cannot be sent between threads safely
    let handle = thread::spawn(move || {
        println!("{}", value_clone);
    });

    handle.join().unwrap();
}

RUST
// Compiler output:
// error[E0277]: Rc<i32> cannot be sent between threads safely
//   --> src/main.rs:8:18
//    |
//    |     let handle = thread::spawn(move || {
//    |                  ^^^^^^^^^^^^^
//    |                  Rc<i32> cannot be sent between threads safely
//    |
//    = help: the trait Send is not implemented for Rc<i32>
//    = note: required because it appears within the type closure

The fix is to replace Rc with Arc, which uses atomic operations for its reference count and is Send + Sync:

RUST
use std::sync::Arc;
use std::thread;

fn main() {
    let value = Arc::new(42); // Arc is Send + Sync
    let value_clone = Arc::clone(&value);

    let handle = thread::spawn(move || {
        println!("{}", value_clone); // compiles and works
    });

    handle.join().unwrap();
}
42
RefCell vs Mutex

RefCell&lt;T&gt; provides interior mutability with runtime borrow checking. It is useful in single-threaded code but is not Sync, so it cannot be shared across threads. The thread-safe equivalent is Mutex&lt;T&gt;, which provides the same interior mutability guarantee but enforces it with a lock that works across threads.

RefCell<T>

Mutex<T>

Thread-safe

No

Yes

Borrow checking

Runtime panic on violation

Blocking lock — no violation possible

Overhead

Two small counters

OS lock (heavier)

Use in

Single-threaded interior mutation

Shared mutable state across threads

Implementing Send and Sync Manually

If you write a type that wraps a raw pointer or other non-Send/Sync primitive, the compiler will not automatically derive Send or Sync for it. You can opt in manually with unsafe impl, but you are then responsible for proving the safety guarantee.

RUST
// A wrapper around a raw pointer — not Send by default
struct MyBuffer(*mut u8);

// We know our buffer is not aliased across threads,
// so we opt in manually. This is unsafe — we must be right.
unsafe impl Send for MyBuffer {}
unsafe impl Sync for MyBuffer {}
Warning
Only use unsafe impl Send or unsafe impl Syncwhen you have thoroughly verified that no data races are possible. A wrong implementation can produce all the same bugs as C++ — use-after-free, torn reads, memory corruption.
Comparing to Other Languages

Fearless concurrency is one of Rust's defining differentiators. Here is how the same data race scenario plays out in different languages.

Language

Data race detection

When caught

C / C++

None by default — undefined behaviour

Maybe at runtime with tsan, maybe never

Go

Race detector (-race flag)

Runtime — only if the racing path is exercised

Java

Programmer must use volatile/synchronized correctly

Never caught automatically — logic error

Python (CPython)

GIL prevents true parallel threads

Hidden by the GIL — not a general solution

Rust

Type system — Send, Sync, borrow checker

Compile time — before the program runs

The key insight is that Rust's guarantees are unconditional: they apply to every build, every run, every platform. Go's race detector only fires if the racing code path is actually executed during a test with the flag enabled. C's sanitizers are similar — they only catch races that are triggered. Rust catches them all, always.

The Three Concurrency Models in Rust

Rust does not mandate one concurrency style. It supports three composable models, and real programs often use all three together.

  1. Threads + mutexes — OS threads (std::thread::spawn) with Arc<Mutex<T>> or Arc<RwLock<T>> for shared data. Best for CPU-bound parallelism.

  2. Channelsstd::sync::mpsc for message passing between threads. Data moves rather than being shared. Best for producer/consumer pipelines.

  3. Async tasks — lightweight futures driven by a runtime such as Tokio. Many tasks multiplexed on a small thread pool. Best for I/O-bound concurrency (web servers, HTTP clients, database queries).

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

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

    thread::spawn(move || {
        for i in 0..5 {
            tx.send(i * i).unwrap();
        }
    });

    for received in rx {
        println!("got: {}", received);
    }
}
got: 0
got: 1
got: 4
got: 9
got: 16
A Data Race That Rust Prevents: Illustrated

Here is the classic data race — two threads incrementing a shared counter without synchronisation — and how Rust refuses to compile it, then the correct version using Arc&lt;Mutex&lt;T&gt;&gt;.

RUST
// This does NOT compile — Rust prevents the data race
use std::thread;

fn main() {
    let mut counter = 0u32;

    // ERROR: cannot borrow 'counter' as mutable,
    // and cannot close over &mut across thread boundaries
    let t1 = thread::spawn(|| { counter += 1; });
    let t2 = thread::spawn(|| { counter += 1; });

    t1.join().unwrap();
    t2.join().unwrap();
}

RUST
// Correct version — the type system guides you to Arc<Mutex<T>>
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0u32));
    let c1 = Arc::clone(&counter);
    let c2 = Arc::clone(&counter);

    let t1 = thread::spawn(move || { *c1.lock().unwrap() += 1; });
    let t2 = thread::spawn(move || { *c2.lock().unwrap() += 1; });

    t1.join().unwrap();
    t2.join().unwrap();

    println!("counter = {}", counter.lock().unwrap()); // 2
}
counter = 2
Tip
Notice that the compiler error in the first version does not say "data race" — it says something like "cannot borrow as mutable" or "Send not implemented". These ownership errors are the mechanism that prevents data races. Fix the ownership error and the data race is gone by definition.
Summary
  • A data race requires concurrent unsynchronised access with at least one write.

  • Rust's ownership rules — one writer OR many readers — make data races impossible in safe code.

  • The Send marker trait ensures a type can be moved to another thread.

  • The Sync marker trait ensures a shared reference &T can be used from multiple threads.

  • Rc<T> and RefCell<T> are not thread-safe; their thread-safe equivalents are Arc<T> and Mutex<T>.

  • Rust catches these errors at compile time — no runtime race detector needed.

  • The three concurrency models (threads + mutexes, channels, async) are all safe and composable.

Success
Fearless concurrency is not magic — it is ownership and borrowing applied to threads. Once you understand that Send and Sync are just the borrow checker extended to cross-thread boundaries, concurrent Rust feels as natural and safe as single-threaded Rust.