← thecodex.expert · The Codex Family of Knowledge
Rust

Error Handling and Concurrency

Errors as values, concurrency as type-safe — Rust makes both impossible to ignore and impossible to misuse.

Rust 2024 edition Memory safe Last verified:
Canonical Definition

Rust error handling uses Result<T, E> as an explicit return value — the ? operator propagates errors automatically via From conversions — while concurrency uses OS threads with Arc<Mutex<T>> for shared state and mpsc channels for message passing, with Send and Sync marker traits enforcing thread safety at compile time.

Where this bites you

"Fearless concurrency" means the compiler rules out data races. It does not rule out deadlocks — and the easiest one to write needs only one thread. A MutexGuard created inside a match scrutinee is a temporary, and temporaries in a scrutinee live until the end of the whole match. So the lock taken to read the queue is still held inside the arm, and locking it again there waits forever for a lock this same thread already owns. It compiles without a warning and simply stops.

Rustmatch_deadlock_bug.rs
use std::sync::Mutex;

let queue = Mutex::new(vec![1, 2, 3]);

match queue.lock().unwrap().pop() {      // guard lives until the end of the match
    Some(job) => {
        println!("got job {}", job);
        queue.lock().unwrap().push(job * 10);   // same thread locks again
        println!("re-queued");
    }
    None => println!("empty"),
}
println!("done");
// got job 3
// ...then nothing: the program hangs here (killed by a 3-second timeout,
// edition 2021 and 2024, debug and release)
🟩 Beginner
One sentence

Rust makes errors explicit: recoverable errors are values of type Result<T, E> that callers must handle, and concurrency safety is enforced by the type system — shared state requires explicit synchronisation, and the compiler verifies it.

Result and the ? operator

Rustresult.rs
use std::fs;
use std::io;
use std::num::ParseIntError;

// Result<T, E> — either Ok(value) or Err(error)
fn parse_number(s: &str) -> Result<i32, ParseIntError> {
    s.trim().parse::<i32>()   // returns Result<i32, ParseIntError>
}

// The ? operator — propagate error up the call stack
// If the value is Err, return early with that error
// If Ok, unwrap and continue
fn double_parse(s: &str) -> Result<i32, ParseIntError> {
    let n = s.trim().parse::<i32>()?;   // early return on parse error
    Ok(n * 2)
}

// File I/O with error handling
fn read_number_from_file(path: &str) -> Result<i32, io::Error> {
    let content = fs::read_to_string(path)?;    // Err propagated
    let n: i32 = content.trim().parse()
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(n)
}

fn main() {
    match parse_number("42") {
        Ok(n)  => println!("Parsed: {n}"),
        Err(e) => println!("Error: {e}"),
    }

    // unwrap() — panics on Err (use in tests/prototypes, not production)
    let n = parse_number("10").unwrap();

    // unwrap_or — default value on error
    let n = parse_number("bad").unwrap_or(0);

    // unwrap_or_else — compute default from the error
    let n = parse_number("bad").unwrap_or_else(|_| -1);

    // map and and_then for chaining
    let doubled = parse_number("21")
        .map(|n| n * 2);      // Ok(42) if parse succeeds
}

Custom errors and thiserror

Rustcustom_errors.rs
use std::fmt;

// Manual error type
#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    InvalidInput(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Io(e)          => write!(f, "IO error: {e}"),
            AppError::Parse(e)       => write!(f, "Parse error: {e}"),
            AppError::InvalidInput(s) => write!(f, "Invalid input: {s}"),
        }
    }
}

// From conversions — enables ? to auto-convert error types
impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }
}

// With the From impls, ? auto-converts io::Error and ParseIntError to AppError
fn load_and_parse(path: &str) -> Result<i32, AppError> {
    let content = std::fs::read_to_string(path)?;   // io::Error -> AppError::Io
    let n: i32 = content.trim().parse()?;           // ParseIntError -> AppError::Parse
    if n < 0 {
        return Err(AppError::InvalidInput("Negative numbers not allowed".into()));
    }
    Ok(n)
}

Concurrency

Rustconcurrency.rs
use std::thread;
use std::sync::{Arc, Mutex};

fn main() {
    // Spawn a thread — closure captures environment
    let handle = thread::spawn(|| {
        println!("Hello from a thread!");
    });
    handle.join().unwrap();   // wait for thread to finish

    // Move closure — transfer ownership of variables into thread
    let data = vec![1, 2, 3];
    let handle = thread::spawn(move || {
        println!("data: {:?}", data);   // data moved into thread
    });
    handle.join().unwrap();

    // Arc<Mutex<T>> — shared mutable state across threads
    // Arc = Atomically Reference Counted (thread-safe Rc)
    // Mutex = mutual exclusion lock
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut num = counter.lock().unwrap();  // acquire lock
            *num += 1;
        }));  // lock released here when `num` goes out of scope (RAII)
    }

    for h in handles { h.join().unwrap(); }
    println!("Counter: {}", *counter.lock().unwrap());  // 10
}
🔵 Intermediate

Channels — message passing

Rustchannels.rs
use std::sync::mpsc;   // multiple producer, single consumer
use std::thread;

fn main() {
    // Create a channel — tx sends, rx receives
    let (tx, rx) = mpsc::channel();

    // Multiple producers — clone the sender
    let tx2 = tx.clone();

    thread::spawn(move || {
        tx.send("message from thread 1").unwrap();
    });

    thread::spawn(move || {
        tx2.send("message from thread 2").unwrap();
    });

    // Receive messages — recv() blocks until a message arrives
    // The channel closes when all senders are dropped
    for received in rx {   // rx is an iterator
        println!("Got: {received}");
    }
    println!("Channel closed");

    // try_recv() — non-blocking receive
    // recv_timeout() — receive with timeout

    // Sync channel — bounded capacity (blocks sender when full)
    let (tx, rx) = mpsc::sync_channel::<i32>(10);  // buffer of 10
    tx.send(1).unwrap();
    println!("{}", rx.recv().unwrap());
}

Send and Sync — thread safety via the type system

Rust's concurrency safety is enforced by two marker traits:

  • Send: a type is safe to transfer ownership to another thread. Most types are Send. Rc<T> is not Send (use Arc). Raw pointers are not Send.
  • Sync: a type is safe to share a reference to across threads (i.e., &T is Send if T is Sync). Most types are Sync. Cell<T> and RefCell<T> are not Sync.

These traits are automatically derived for types whose fields are all Send/Sync. The compiler uses them to reject code that would cause data races — at compile time, with no runtime overhead.

Rustsend_sync.rs
// Compiler prevents sharing Rc across threads
use std::rc::Rc;
use std::sync::Arc;

fn needs_send<T: Send>(_: T) {}

let rc = Rc::new(42);
// needs_send(rc);   // ERROR: Rc<i32> is not Send

let arc = Arc::new(42);
needs_send(arc);     // OK: Arc<i32> is Send + Sync

// The type system prevents data races:
// You cannot share &mut T across threads
// because &mut T is only Send if T: Send,
// and borrowing rules prevent aliasing anyway
Commonly confused
Rust has no null and no exceptions — errors are values. Recoverable errors use Result<T, E>. Unrecoverable errors use panic!(), which unwinds the stack (or aborts). There is no try/catch. The ? operator propagates errors up the call stack. This forces callers to explicitly decide what to do with errors.
unwrap() is not error handling — it is a deliberate panic choice. Calling .unwrap() on a Result or Option says: "I am certain this is Ok/Some, and if it isn't, panic." It is appropriate in tests, prototypes, and truly impossible cases. In production paths, use ?, match, or the combinator methods.
Mutex::lock() returns a LockResult — it can fail. A mutex is "poisoned" if a thread panics while holding it. lock().unwrap() propagates the poison. In most applications, treating a poisoned mutex as an error (or simply propagating the panic) is correct. The lock guard implements Drop — it is released automatically when it goes out of scope.
How this connects
Requires first
See also
🔴 Expert

The ? operator and the Try trait

The ? operator desugars to a match expression: if the value is Err(e), it calls From::from(e) to convert the error type to the function's return type, then returns early with that error. If it is Ok(v), it evaluates to v. The From conversion means you can use ? to propagate different error types as long as you implement From<SourceError> for TargetError. The nightly Try trait (RFC 3058) generalises this beyond Result/Option to any type implementing the protocol — work in progress as of Rust 2024.

Fearless concurrency: the type system proof

Rust's "fearless concurrency" claim is backed by a formal guarantee: if your Rust program compiles without unsafe, it cannot have data races. The proof: a data race requires two threads to access the same memory location simultaneously, at least one access being a write, without synchronisation. The ownership system prevents the "simultaneous access" case — you cannot have two mutable references to the same data (&mut T is exclusive). The Send/Sync marker traits prevent transferring non-thread-safe types across thread boundaries. Mutex, RwLock, and atomic operations provide the synchronised access path. Together, these form a complete exclusion of the data race condition.

That proof covers data races only. Deadlock is a liveness bug, not a memory-safety bug, so nothing in Send, Sync or the borrow checker looks for it — and locking a std::sync::Mutex the current thread already holds is documented as "might deadlock or panic"; on Linux it hung. The fix for the scrutinee version is to end the guard's life before the match: take what you need in its own let statement, whose temporaries are dropped at the semicolon, then match on the plain value:

Rustmatch_deadlock_fix.rs
let next = queue.lock().unwrap().pop();   // guard dropped at this semicolon
match next {
    Some(job) => {
        println!("got job {}", job);
        queue.lock().unwrap().push(job * 10);   // lock is free again
        println!("re-queued");
    }
    None => println!("empty"),
}
println!("done");
println!("{:?}", queue.lock().unwrap());
// got job 3
// re-queued
// done
// [1, 2, 30]
Source confidence: High Last verified: Primary source: The Rust Book · doc.rust-lang.org/book/

Sources

1
The Rust Book — Ch 9: Error Handling. doc.rust-lang.org/book/ch09-00-error-handling.html.
2
The Rust Book — Ch 16: Fearless Concurrency. doc.rust-lang.org/book/ch16-00-concurrency.html.
3
Rust std — std::sync. doc.rust-lang.org/std/sync/.
4
RFC 3058 — Try Trait v2. rust-lang.github.io/rfcs/3058-try-trait-v2.html.