Polyglot
Languages Rust concurrency
Rust § concurrency

Concurrency

Rust’s concurrency story has two principal threads: thread-based concurrency (std::thread) for the OS-level surface, and async/await for the lightweight, runtime-driven surface. The substance of Rust’s contribution is the type system’s enforcement of thread safety — the Send and Sync traits ensure that data races are caught at compile time, not at runtime. The conventional async runtime is tokio; alternatives include async-std and smol. The combination — OS threads with safe sharing primitives (Arc, Mutex, RwLock), async/await with runtime-provided executors, channels for message passing, the type system enforcing data-race freedom — admits substantial concurrency safely.

This page covers thread-based concurrency, async/await, channels, synchronisation primitives, and the conventional patterns.

OS threads

The std::thread module admits OS-thread-based concurrency:

use std::thread;

let handle = thread::spawn(|| {
    println!("hello from a thread");
    42
});

let result = handle.join().unwrap();             // wait for completion
println!("result: {}", result);

The thread::spawn returns a JoinHandle<T>; join() waits for the thread and returns its result.

Sharing data with threads

A spawned thread typically needs to access data from the spawning thread. The conventional approach:

use std::thread;

let v = vec![1, 2, 3];

let handle = thread::spawn(move || {
    println!("{:?}", v);                         // moves v into the thread
});

handle.join().unwrap();
// v is no longer accessible here

The move keyword admits capturing by value; the thread receives ownership.

Shared state

For shared mutable state across threads, Arc (atomic reference count) plus Mutex:

use std::sync::{Arc, Mutex};
use std::thread;

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());  // 10

The pattern:

  • Arc<T> — atomically-counted reference; admits shared ownership across threads.
  • Mutex<T> — admits exclusive access to the protected value.
  • Arc::clone — produces another reference to the same data.
  • mutex.lock() — acquires the lock; returns a MutexGuard that derefs to the protected value.

Treated in Smart pointers.

Send and Sync

The type system distinguishes thread-safe data:

TraitMeaning
SendSafe to move to another thread
SyncSafe to share by reference across threads

Most types are Send and Sync automatically; the principal exceptions:

  • Rc<T> — not Send (non-atomic refcount).
  • RefCell<T> — not Sync (interior mutability without locks).
  • Raw pointers (*const T, *mut T) — neither.

The compiler enforces these at the thread::spawn call:

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

let r = Rc::new(5);
thread::spawn(move || {                          // ERROR: Rc is not Send
    println!("{}", r);
});

// Use Arc instead:
use std::sync::Arc;
let r = Arc::new(5);
thread::spawn(move || {                          // OK
    println!("{}", r);
});

The mechanism produces compile-time data-race freedom.

Channels

The std::sync::mpsc (multi-producer, single-consumer) admits message-passing:

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

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    tx.send(42).unwrap();
});

let received = rx.recv().unwrap();              // 42
println!("got {}", received);

The conventional uses are producer-consumer patterns and worker pools:

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

let (tx, rx) = mpsc::channel();

for i in 0..10 {
    let tx = tx.clone();
    thread::spawn(move || {
        tx.send(i).unwrap();
    });
}

drop(tx);                                        // close the original sender

while let Ok(value) = rx.recv() {                // iterate until all senders dropped
    println!("got {}", value);
}

The tx.clone() admits multiple senders; the channel closes when all senders are dropped.

For more elaborate patterns, the third-party crossbeam crate provides multi-producer-multi-consumer channels and substantial additional concurrency primitives.

Atomics

The std::sync::atomic module admits lock-free shared state:

use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use std::thread;

let counter = Arc::new(AtomicI32::new(0));

let mut handles = vec![];
for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        counter.fetch_add(1, Ordering::SeqCst);
    }));
}

for h in handles { h.join().unwrap(); }

println!("{}", counter.load(Ordering::SeqCst));  // 10

The principal atomics are AtomicBool, AtomicI32/AtomicU32, AtomicI64/AtomicU64, AtomicUsize, AtomicPtr. The Ordering admits memory-ordering control:

  • SeqCst — sequentially consistent (the default conventional choice).
  • Acquire / Release — pair-wise synchronisation.
  • AcqRel — both for read-modify-write.
  • Relaxed — no synchronisation guarantees.

The conventional discipline is SeqCst unless profiling shows it as a bottleneck and a more relaxed ordering is provably correct.

RwLock

For read-heavy workloads, RwLock admits multiple readers or one writer:

use std::sync::{Arc, RwLock};
use std::thread;

let data = Arc::new(RwLock::new(vec![1, 2, 3]));

// Readers (multiple at once):
let r1 = Arc::clone(&data);
thread::spawn(move || {
    let v = r1.read().unwrap();
    println!("{:?}", *v);
});

// Writers (exclusive):
let w = Arc::clone(&data);
thread::spawn(move || {
    let mut v = w.write().unwrap();
    v.push(4);
});

The conventional choice: Mutex for general-purpose locking; RwLock for read-heavy workloads where contention matters.

OnceLock and OnceCell

For one-time initialisation:

use std::sync::OnceLock;

static GLOBAL: OnceLock<String> = OnceLock::new();

fn global() -> &'static String {
    GLOBAL.get_or_init(|| {
        std::env::var("CONFIG").unwrap_or_else(|_| "default".to_string())
    })
}

The OnceLock admits thread-safe lazy initialisation; treated in Smart pointers.

Barrier

For synchronising multiple threads at a point:

use std::sync::{Arc, Barrier};
use std::thread;

let barrier = Arc::new(Barrier::new(10));

let mut handles = vec![];
for _ in 0..10 {
    let barrier = Arc::clone(&barrier);
    handles.push(thread::spawn(move || {
        // ... do some work ...
        barrier.wait();                          // wait for all 10
        // ... continue ...
    }));
}

The mechanism is rare in idiomatic code; conventional alternatives are channels and join handles.

async/await

Async functions return futures — values that produce a result asynchronously:

async fn fetch_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    let response = reqwest::get(url).await?;
    let text = response.text().await?;
    Ok(text)
}

The principal features:

  • async fn — declares an async function; the return type is wrapped in impl Future<Output = T>.
  • .await — suspends the current task until the future resolves; admits chaining async operations.
  • async {} — async block; produces an anonymous future.

Running async code

Async code requires a runtime (executor); the standard library does not include one. The conventional choice is tokio:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let body = reqwest::get("https://example.com").await?
        .text().await?;
    println!("{}", body);
    Ok(())
}

The #[tokio::main] attribute macro rewrites main to set up the runtime; the body of main becomes an async block run by the runtime.

For non-main runtimes:

fn main() {
    let runtime = tokio::runtime::Runtime::new().unwrap();
    runtime.block_on(async {
        // ... async work ...
    });
}

Spawning async tasks

The tokio::spawn spawns a concurrent task:

let handle = tokio::spawn(async {
    do_work().await
});

let result = handle.await.unwrap();

Tasks are similar to threads but cooperatively scheduled; many tasks share a small thread pool.

join! and try_join!

For waiting on multiple futures:

use tokio::join;

let (a, b, c) = join!(
    fetch_a(),
    fetch_b(),
    fetch_c(),
);

// or with ? on errors:
use tokio::try_join;
let (a, b, c) = try_join!(
    fetch_a(),
    fetch_b(),
    fetch_c(),
)?;

The macros admit concurrent execution with structured waiting.

select!

Wait for the first of several futures:

use tokio::select;

select! {
    result = fetch_url(url) => {
        println!("got: {:?}", result);
    }
    _ = tokio::time::sleep(Duration::from_secs(5)) => {
        println!("timeout");
    }
}

The conventional uses are timeouts, race conditions, and event loops.

Async channels

Tokio’s mpsc channels:

use tokio::sync::mpsc;

let (tx, mut rx) = mpsc::channel(100);

tokio::spawn(async move {
    for i in 0..10 {
        tx.send(i).await.unwrap();
    }
});

while let Some(value) = rx.recv().await {
    println!("got {}", value);
}

Tokio also provides oneshot (single-message), broadcast (one producer, many consumers), and watch (last-value broadcast) channels.

Async traits

Until Rust 1.75, async functions in traits required workarounds (async-trait macro). Since Rust 1.75:

trait Fetcher {
    async fn fetch(&self, url: &str) -> Result<String, FetchError>;
}

The form is admitted natively; the principal restriction is that async-trait methods cannot be called through dyn Trait (trait objects) without the async-trait crate as of 2026.

Common patterns

Worker pool

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

fn worker_pool<F>(num_workers: usize, work: F) -> mpsc::Sender<Job>
where F: Fn(Job) + Send + Sync + Clone + 'static
{
    let (tx, rx) = mpsc::channel::<Job>();
    let rx = Arc::new(Mutex::new(rx));

    for _ in 0..num_workers {
        let rx = Arc::clone(&rx);
        let work = work.clone();
        thread::spawn(move || {
            while let Ok(job) = rx.lock().unwrap().recv() {
                work(job);
            }
        });
    }

    tx
}

Read-mostly cache

use std::sync::RwLock;
use std::collections::HashMap;

struct Cache {
    data: RwLock<HashMap<String, String>>,
}

impl Cache {
    fn get(&self, key: &str) -> Option<String> {
        self.data.read().unwrap().get(key).cloned()
    }

    fn set(&self, key: String, value: String) {
        self.data.write().unwrap().insert(key, value);
    }
}

Async server (tokio)

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;

    loop {
        let (mut socket, _) = listener.accept().await?;

        tokio::spawn(async move {
            let mut buf = [0u8; 1024];
            let n = socket.read(&mut buf).await.unwrap();
            socket.write_all(&buf[..n]).await.unwrap();
        });
    }
}

Concurrent fetches

use futures::future::join_all;

async fn fetch_all(urls: Vec<&str>) -> Vec<Result<String, FetchError>> {
    let futures = urls.iter().map(|u| fetch(u));
    join_all(futures).await
}

Timeout

use tokio::time::{timeout, Duration};

let result = timeout(Duration::from_secs(5), fetch_url(url)).await;
match result {
    Ok(Ok(body)) => println!("{}", body),
    Ok(Err(e)) => println!("fetch error: {}", e),
    Err(_) => println!("timeout"),
}

Cancellation

Async tasks are cancelled by dropping the future:

let handle = tokio::spawn(async {
    long_running_task().await
});

// ... later ...
handle.abort();                                  // cancels the task

The convention is that async functions check cancellation at .await points (the runtime drops the future at that point).

A note on rayon

For data-parallel computation, the third-party rayon crate admits parallel iterators:

use rayon::prelude::*;

let v: Vec<i32> = (1..=1_000_000).collect();
let sum: i64 = v.par_iter().map(|&x| x as i64).sum();

let processed: Vec<i32> = v.par_iter()
    .filter(|&&x| x % 2 == 0)
    .map(|&x| x * x)
    .collect();

The par_iter() produces a parallel iterator; the API mirrors Iterator. The conventional discipline is to use rayon for CPU-bound parallel work; tokio for I/O-bound async work.

A note on Send boundaries

Most types are Send automatically; the principal exceptions to know:

  • Rc<T> — not Send; use Arc<T> for cross-thread sharing.
  • RefCell<T> — not Sync; use Mutex<T> or RwLock<T>.
  • Cell<T> — not Sync; use atomics.

The compiler reports these as errors at the thread::spawn or tokio::spawn call:

use std::rc::Rc;

let r = Rc::new(42);
thread::spawn(move || {                          // ERROR: Rc<i32> is not Send
    println!("{}", r);
});

The diagnostics admit substantial guidance to the conventional defences.

A note on async vs threads

The conventional choice:

WorkloadChoice
CPU-bound, parallelThreads (or rayon)
I/O-bound, many concurrent connectionsAsync (tokio)
MixedBoth — async for I/O, threads for CPU

Async excels at high-concurrency I/O (thousands of connections); threads excel at CPU-bound parallelism. For most servers and clients, async is the conventional choice; for compute-heavy work, threads or rayon.

A note on the conventional discipline

The contemporary Rust concurrency advice:

  • Use Arc<Mutex<T>> for shared mutable state across threads.
  • Use Arc<RwLock<T>> for read-heavy shared state.
  • Use channels (mpsc) for producer-consumer patterns.
  • Use atomics for simple shared counters and flags.
  • Use tokio for async I/O.
  • Use rayon for data-parallel computation.
  • Trust the type systemSend and Sync catch data races at compile time.
  • Avoid sharing where possible — message passing via channels is conventionally clearer.
  • Use parking_lot::Mutex (third-party) for somewhat faster mutex performance.

The combination — OS threads with safe sharing primitives, async/await with tokio, channels for message passing, atomics for low-overhead state, rayon for parallel computation, and the type system’s data-race freedom — is the substance of Rust’s concurrency story. The mechanism admits substantial concurrent code with compile-time safety guarantees.