Skip to content
rusthacks.dev
Testing

Testing Concurrent Rust Code With Loom Interleavings

Stress tests find concurrency bugs by luck. Loom explores every legal ordering of your threads and memory operations, so a missing Acquire fails deterministically.

3 min read
Summarize in AI
Flat illustration for the post “Testing Concurrent Rust Code With Loom Interleavings”, composed around memory.

Concurrency bugs depend on timing. A test that passes a million times can still hide a race that appears on a different CPU. Loom is a model checker for Rust: it runs your code under every ordering that the memory model allows within a bound. It is how Tokio tests its synchronization primitives. Versions: Rust 1.90, loom 0.7.

What goes wrong without it

rust
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;

#[test]
fn publish_data() {
    let data = Arc::new(AtomicUsize::new(0));
    let ready = Arc::new(AtomicBool::new(false));

    let (d, r) = (data.clone(), ready.clone());
    let writer = thread::spawn(move || {
        d.store(42, Ordering::Relaxed);
        r.store(true, Ordering::Relaxed); // bug: should be Release
    });

    while !ready.load(Ordering::Relaxed) {} // bug: should be Acquire
    assert_eq!(data.load(Ordering::Relaxed), 42);
    writer.join().unwrap();
}

On x86 this nearly always passes, because the hardware orders stores strongly. On ARM or under compiler reordering the reader may see ready before data. A normal test cannot show it.

The loom version

Add a cfg switch so the crate uses loom's types in tests:

TOML
[target.'cfg(loom)'.dependencies]
loom = "0.7"

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
rust
#[cfg(loom)]
use loom::{sync::{atomic::{AtomicBool, AtomicUsize, Ordering}, Arc}, thread};
#[cfg(not(loom))]
use std::{sync::{atomic::{AtomicBool, AtomicUsize, Ordering}, Arc}, thread};

Library code then uses these imports instead of std::sync directly. The test:

rust
#[cfg(loom)]
#[test]
fn publish_data_loom() {
    loom::model(|| {
        let data = Arc::new(AtomicUsize::new(0));
        let ready = Arc::new(AtomicBool::new(false));

        let (d, r) = (data.clone(), ready.clone());
        let writer = thread::spawn(move || {
            d.store(42, Ordering::Relaxed);
            r.store(true, Ordering::Relaxed);
        });

        while !ready.load(Ordering::Relaxed) {
            loom::thread::yield_now();
        }
        assert_eq!(data.load(Ordering::Relaxed), 42);
        writer.join().unwrap();
    });
}
Shell
RUSTFLAGS="--cfg loom" cargo test --release publish_data_loom
Text
thread 'publish_data_loom' panicked at src/lib.rs:31:9:
assertion `left == right` failed
  left: 0
 right: 42

Loom found an execution where ready is true and data is still 0. Change the stores and loads to Release and Acquire:

rust
r.store(true, Ordering::Release);
while !ready.load(Ordering::Acquire) { loom::thread::yield_now(); }

and the model passes, having explored all legal orderings. Spin loops need yield_now(), otherwise loom cannot progress the other thread. Run in --release, since the exploration is CPU heavy.

Types you can model

| std | loom | |---|---| | std::sync::atomic::* | loom::sync::atomic::* | | std::sync::{Mutex, RwLock, Condvar} | loom::sync::* | | std::sync::Arc | loom::sync::Arc | | std::thread::spawn | loom::thread::spawn | | std::cell::UnsafeCell | loom::cell::UnsafeCell (detects data races) |

The loom::cell::UnsafeCell variant has with and with_mut methods that panic if two threads would access the data concurrently without synchronization. That check is the strongest reason to use loom with lock-free code (atomics explained).

Keeping models small

The number of schedules explodes with each thread and each operation. Practical rules:

  • Two or three threads, a few operations each.
  • Test one primitive at a time: a queue, a latch, a reference count.
  • Bound preemptions: LOOM_MAX_PREEMPTIONS=3 cargo test (the default checks exhaustively within the bound the library sets).
  • Use LOOM_LOG=trace to see the failing schedule, and LOOM_CHECKPOINT_FILE to replay it.

What loom does not cover

  1. Async runtimes. Tokio provides its own loom integration. For application async code use deterministic scheduling tools instead (testing graceful shutdown, paused time).
  2. Larger systems. Models of whole programs do not finish.
  3. Platform details. It models the C11 memory model, which is the contract Rust gives you.
  4. Performance. It says nothing about speed.

When it is worth it

Reach for loom when you write code with atomics, UnsafeCell, custom locks or lock-free structures, where a bug means a rare crash in production. For ordinary mutex-protected state, stress tests and careful design are enough ([Arc<Mutex<T>>](/library/rust-arc-mutex-pattern)). The companion for undefined behavior in unsafe code is Miri (Miri).

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.