TL;DR: Rust has a really strong type system that's designed to prevent common multithreading problem like data races. It's “borrow checking” plays a big role. Code that may have data races simply doesn't compile.

Many other languages have been designed for concurrent systems. Java made multithreading accessible in the 90s, and Golang has famously good support for writing concurrent programs. But Rust is the only mainstream language that uses its type system to encode thread safety guarantees. If your Rust code compiles, then it is free of certain problematic behaviors.

Specifically, data races. When multiple threads have access to the same mutable object, then it is really difficult to ensure consistency. Rust prevents such shared access by default, thus Rust code is free from data races by default.

Python example of a data race

Consider this Python program where multiple threads modify the same shared variable:

import concurrent.futures
x = 0

def incrementer():
    global x
    for _ in range(1_000_000):
        x += 1

with concurrent.futures.ThreadPoolExecutor() as pool:
    for _ in range(10):
        pool.submit(incrementer)

print(f"{x:_}")

This spawns 10 threads that increment x 1_000_000 times each, so 10M times total. Depending on what Python decides to do, your output might be 10_000_000 or e.g. 1_403_636 – quite a lot less. The underlying problem is that Python's in-place addition is not guaranteed to be atomic. Instead, it consists of two separate steps: (1) add 1 to the old value of x, and (2) update the variable x with the new result. But other threads are doing the same thing at the same time, so we might have read an outdated value and overwritten newer values.

Illustration via schedule diagrams

In distributed system theory, we illustrate this with schedule diagrams. Here's an example of how lost updates might look for 2 threads doing 2 increments each:

T1 | r(0)    w(1) r(1) w(2)
T2 |     r(0)              w(1) r(1) w(2)
   +------------------------------------> time

The result in that example is x=2 rather than the expected x=4 – updates from Thread 1 are overwritten.

Explanation of the diagram:

  • time flows towards the right on the x-axis
  • we have different threads/processes on the y-axis
  • a r() symbol indicates a read operation by that thread at that time
  • a w() symbol indicates a write operation

Thread safety vs memory safety

Python is thread-safe in the sense that such data races will not crash the interpreter. This is a basic memory safety requirement, but many languages such as C, C++, or even Golang (!) do not guarantee this. But even if the interpreter is memory safe, this doesn't mean the program it is executing is working correctly in a multithreaded context.

Rust does borrow checking

Rust, on the other hand, would not let such a program compile. Let's try it! Here's the equivalent Rust code:

use std::thread;
let mut x = 0;

thread::scope(|scope| {
    for _ in 0..10 {
        scope.spawn(|| {
            for _ in 0..1_000_000 {
                x += 1;
            }
        });
    }
});

println!("{x}");

This gives us a big error, which is a bit cryptic if you're not used to Rust:

error[E0499]: cannot borrow `x` as mutable more than once at a time
   --> src/main.rs:7:25
    |
  5 |       thread::scope(|scope| {
    |                      ----- has type `&'1 Scope<'1, '_>`
  6 |           for _ in 0..10 {
  7 |               scope.spawn(|| {
    |               -           ^^ `x` was mutably borrowed here in the previous iteration of the loop
    |  _____________|
    | |
  8 | |                 for _ in 0..1_000_000 {
  9 | |                     x += 1;
    | |                     - borrows occur due to use of `x` in closure
 10 | |                 }
 11 | |             });
    | |______________- argument requires that `x` is borrowed for `'1`

This "borrowing" is the first defense Rust brings, also known as aliasing-xor-mutability. Either there can be multiple references (aliases) to the same object, in which case it must not be modified while any such names are active. Or, there can be a single name through which the object may be mutated. Rust's "borrow checker" is the part of the type system that tracks how long each name is alive. In the above example, the loop means that there can be multiple instances of the || {...} lambda. All instances can all the same x object, which violates this exclusivity rule, and thus we get the error.

Rust tracks sync types

Rust has various (safe!) escape hatches to deal with problems like aliasing-xor-mutability. For example, a single-threaded program can use a Cell or RefCell to represent a mutable value that may be accessible through multiple names. Here's how that would look:

let mut x_cell = Cell::new(0);
thread::scope(|scope| {
    for _ in 0..10 {
        scope.spawn(|| {
            for _ in 0..1_000_000 {
                x_cell.update(|x| x + 1);
            }
        });
    }
});
let x = x_cell.get();
println!("{x}");

But again, we get an error, because Cell can only be used safely within a single thread – but we're trying to access it across multiple threads.

error[E0277]: `Cell<i32>` cannot be shared between threads safely
   --> src/main.rs:8:25
    |
  8 |               scope.spawn(|| {
    |  ___________________-----_^
    | |                   |
    | |                   required by a bound introduced by this call
  9 | |                 for _ in 0..1_000_000 {
 10 | |                     x_cell.update(|x| x + 1);
 11 | |                 }
 12 | |             });
    | |_____________^ `Cell<i32>` cannot be shared between threads safely
    |
    = help: the trait `Sync` is not implemented for `Cell<i32>`
    = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicI32` instead
    = note: required for `&Cell<i32>` to implement `Send`

For every type, Rust tracks whether it is Sync or not. Only types that are Sync may be shared across threads. So to do this safely, Rust forces us to look for a type that supports mutable aliasing and is Sync.

Using locks

One Rust type that satisfies these properties is a Mutex. In Rust, a Mutex always wraps some value, and acquiring a lock gives us a temporary mutable reference to that value. Rust will automatically release the lock when that mutable reference goes out of scope. Here is the code for that, which is our first correct solution that's guaranteed to print 10000000:

let x_mutex = Mutex::new(0);
thread::scope(|scope| {
    for _ in 0..10 {
        scope.spawn(|| {
            for _ in 0..1_000_000 {
                *x_mutex.lock().unwrap() += 1;
            }
        });
    }
});
let x = x_mutex.into_inner().unwrap();
println!("{x}");

At each of these steps, Rust's type system has prevented us from accidentally shooting ourselves in the foot.

We can now backport this code to the Python variant:

import concurrent.futures
import threading
x = 0
x_lock = threading.Lock()

def incrementer():
    global x
    for _ in range(1_000_000):
        with x_lock:
            x += 1

with concurrent.futures.ThreadPoolExecutor() as pool:
    for _ in range(10):
        pool.submit(incrementer)

print(f"{x:_}")

This is now also guaranteed to print the correct result.

Going lock-free with atomics

It is sometimes possible to avoid locks by using atomic operations.

Python's primitive operations like writing a variable are each atomic (they complete entirely, without getting interrupted by other threads), but Python doesn't give us atomic operations like compare-and-exchange to do safe atomic modifications.

In this example, we only need atomic updates, but do not need to guarantee any particular ordering between threads, so we can safely used the Relaxed memory order:

let x_atomic = AtomicU64::new(0);
thread::scope(|scope| {
    for _ in 0..10 {
        scope.spawn(|| {
            for _ in 0..1_000_000 {
                x_atomic.fetch_add(1, Ordering::Relaxed);
            }
        });
    }
});
let x = x_atomic.load(Ordering::Relaxed);
println!("{x}");

This too is perfectly safe and guarantees the correct output of 10000000.

Using atomics also lets us express the flawed update logic that the original Python code suffered from. Here, I will use the stricter SeqCst ordering since that's the best match for Python's GIL and free-threading semantics:

let x_atomic = AtomicU64::new(0);
thread::scope(|scope| {
    for _ in 0..10 {
        scope.spawn(|| {
            for _ in 0..1_000_000 {
                let x = x_atomic.load(Ordering::SeqCst);
                x_atomic.store(x + 1, Ordering::SeqCst);
            }
        });
    }
});
let x = x_atomic.load(Ordering::SeqCst);
println!("{x}");

In one example run, I got the output 3272455.

So Rust doesn't magically guarantee “correct” output – but we had to go out of our way to mess up, and Rust's type system has guided us towards safer patterns (such as using mutexes where necessary).