Atomics in Rust

Atomics in Rust are fundamental building blocks for achieving safe concurrent programming. They enable multiple threads to share and modify data without causing data races. Atomics are particularly important in scenarios where performance is critical, and using locking mechanisms (like Mutex or RwLock) could lead to undesirable contention and overhead.

In computer science, Atomic is used to describe an operation that is indivisible: it is either fully completed, or it didn’t happen yet.


What Are Atomics?

Atomics are low-level data types that provide lock-free, thread-safe operations. They allow threads to perform read-modify-write operations in a single, uninterruptible step, known as an atomic operation. Unlike traditional locks, which can cause a thread to sleep while waiting, atomics enable non-blocking behavior, allowing multiple threads to work concurrently without needing to coordinate through a locking mechanism.


Atomic operations are:

Atomic operations are the main building block for anything involving multiple threads. All the concurrency primitives, such as mutexes and condition variables, are implemented using atomic operations.

  1. Lock-free: They don't require acquiring a lock, which can lead to blocking. This allows operations to proceed without waiting, improving performance in multi-threaded programs.
  2. Atomicity: Each operation is performed as a single, indivisible step, meaning it either fully completes or doesn't happen at all which are are implemented using the CPU’s native atomic instructions.


Atomic Types Overview

Rust provides several atomic types under the std::sync::atomic module:

  • AtomicBool: Atomic version of the boolean type (bool). Useful for toggling flags between true and false.
  • AtomicIsize / AtomicUsize: Atomic signed and unsigned integer types that match the pointer width of the target architecture (isize, usize).
  • AtomicI8, AtomicI16, AtomicI32, AtomicI64, AtomicI128: Atomic signed integers.
  • AtomicU8, AtomicU16, AtomicU32, AtomicU64, AtomicU128: Atomic unsigned integers.
  • AtomicPtr<T>: Atomic operations on raw pointers.


Common Atomic Operations:

Each of the available atomic types has the same interface with methods for storing and loading, methods for atomic "fetch-and-modify" operations, and some more advanced "compare-and-exchange" methods.

Function signatures are as follows, using AtomicI32 as an example::

impl AtomicI32 {
    pub fn fetch_add(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_sub(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_or(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_and(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_nand(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_xor(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_max(&self, v: i32, ordering: Ordering) -> i32;
    pub fn fetch_min(&self, v: i32, ordering: Ordering) -> i32;
    pub fn swap(&self, v: i32, ordering: Ordering) -> i32; // "fetch_store"
}        

  1. Fetch-and-Update: Fetch Operations (Add, Sub, And, Or, Xor)

fetch_add, fetch_sub, fetch_and, fetch_or, fetch_xor: Atomically performs arithmetic or bitwise operations and updates the value.

Example :

use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let atomic_counter = AtomicUsize::new(1);

    // Add 2 atomically
    let old_val = atomic_counter.fetch_add(2, Ordering::SeqCst);
    println!("Old Value: {}, New Value: {}", old_val, atomic_counter.load(Ordering::SeqCst));
}        

The following are some commonly used atomic operations:


2. Load and Store:

  • load(Ordering): Reads the value of the atomic variable.
  • store(val, Ordering): Writes a value to the atomic variable.

use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let atomic_val = AtomicUsize::new(5);

    // Load the current value
    let current_val = atomic_val.load(Ordering::Relaxed);
    println!("Current Value: {}", current_val);

    // Store a new value
    atomic_val.store(10, Ordering::Relaxed);
    println!("Updated Value: {}", atomic_val.load(Ordering::Relaxed));
}
/*
Current Value: 5
Updated Value: 10
*/        


3. Compare-and-Swap (CAS):

  • compare_and_swap(current, new, Ordering): Atomically compares the current value with the provided current value, and if they match, sets it to new. Otherwise, no operation is performed. This is a useful primitive for lock-free data structures.

compare_exchange(current, new, Ordering, Ordering): This is a more flexible version of CAS that allows specifying different memory orderings for success and failure.

use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let atomic_val = AtomicUsize::new(5);

    // Attempt to change the value from 5 to 10
   // let result = atomic_val.compare_and_swap(5, 10, Ordering::SeqCst); // compare_and_swap is deprecated
    let result = atomic_val.compare_exchange(5, 10, Ordering::SeqCst, Ordering::SeqCst).unwrap();
    println!("Old Value: {}", result);
    println!("New Value: {}", atomic_val.load(Ordering::SeqCst));
}
/*
Old Value: 5
New Value: 10
*/        

If I use it like below see error is returned :

use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let atomic_val = AtomicUsize::new(5);

    // Attempt to change the value from 5 to 10
   // let result = atomic_val.compare_and_swap(5, 10, Ordering::SeqCst); // compare_and_swap is deprecated
    let result = atomic_val.compare_exchange(8, 10, Ordering::SeqCst, Ordering::SeqCst);
    println!("Old Value: {:?}", result);
    println!("New Value: {}", atomic_val.load(Ordering::SeqCst));
}
/*
Old Value: Err(5)
New Value: 5
*/        

Note: compare_and_swap is deprecated, and the preferred method is compare_exchange:Compare-and-Exchange (Modern Replacement for CAS):


4. Swap:

swap(new, Ordering): Atomically replaces the current value with the new value and returns the old one.

use std::sync::atomic::{AtomicUsize, Ordering};


fn main() {
    // Create an AtomicUsize with an initial value of 5.
    let atomic_value = AtomicUsize::new(5);

    // Swap the value to 10 and retrieve the old value.
    let old_value = atomic_value.swap(10, Ordering::SeqCst);

    println!("Old value: {}", old_value);
    println!("New value: {}", atomic_value.load(Ordering::SeqCst));
}
/*
Old value: 5
New value: 10
*/        

Swapping across multiple threads :

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

fn main() {
    // Use Arc to enable sharing of the AtomicUsize between threads.
    let shared_value = Arc::new(AtomicUsize::new(100));

    let handles: Vec<_> = (0..5).map(|i| {
        let shared_value = Arc::clone(&shared_value);
        thread::spawn(move || {
            // Each thread will swap a new value into the atomic variable.
            let old = shared_value.swap(i, Ordering::SeqCst);
            println!("Thread {} swapped out old value: {}", i, old);
        })
    }).collect();

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

    println!("Final value: {}", shared_value.load(Ordering::SeqCst));
}
/*
Thread 0 swapped out old value: 100
Thread 1 swapped out old value: 0
Thread 2 swapped out old value: 1
Thread 3 swapped out old value: 2
Thread 4 swapped out old value: 3
Final value: 4
*/        

In the above code Vec<_> is a shorthand that allows the compiler to infer the type for the elements in the vector, rather than specifying the type explicitly.

Ex:

let handles: Vec<_> = vec![];        

the represents an unspecified type, so Rust will infer the type based on context. Here, Vec<> essentially means "a Vec of some type, to be determined later." If you later add elements to handles (e.g., handles.push(some_value)), the compiler will deduce the correct type of the vector based on the type of some_value.


Memory Ordering

One of the key concepts in atomic operations is memory ordering. This determines how atomic operations on different threads are observed. Rust's atomic methods require a memory ordering argument, such as:

  • Ordering::Relaxed: No synchronization, only atomicity is guaranteed.
  • Ordering::Acquire: Guarantees that no writes in the current thread can be reordered before the atomic read.
  • Ordering::Release: Guarantees that no reads or writes in the current thread can be reordered after the atomic write.
  • Ordering::AcqRel: AcquireRelease - Combines the effects of Acquire and Release.
  • Ordering::SeqCs: -> SeqCst (Sequentially Consistent): Provides the strongest ordering, ensuring a total ordering of operations across threads. This is the most restrictive but simplest to reason about.


Example: Basic Atomic Counter

The following example demonstrates how to use AtomicUsize to create a basic atomic counter.

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

fn main() {
    // Create an AtomicUsize counter wrapped in an Arc
    let counter = Arc::new(AtomicUsize::new(0));

    let mut handles = vec![];

    // Spawn 10 threads, each incrementing the counter 10 times
    for _ in 0..10 {
        let counter_clone = Arc::clone(&counter);

        let handle = thread::spawn(move || {
            for _ in 0..10 {
                // Increment the counter atomically
                println!(" {}", counter_clone.load(Ordering::SeqCst));
                counter_clone.fetch_add(1, Ordering::SeqCst);
            }
        });

        handles.push(handle);
    }

    // Wait for all threads to finish
    for handle in handles {
        handle.join().unwrap();
    }

    // Print the final counter value
    println!("Final counter value: {}", counter.load(Ordering::SeqCst));
}

/*
0
 1
 2
::::
 97
 98
 99
Final counter value: 100
*/
        

  • fetch_add: Atomically increments the counter.
  • Ordering::SeqCst: Ensures a strict order of execution across threads.


Atomic vs Mutex/RefCell/Cell

  • Mutex: More flexible, can handle non-primitive types, but incurs the cost of locking. Suitable for more complex data structures where exclusive access is required.
  • RefCell/Cell: Used to get around Rust's borrowing rules, but only safe for single-threaded contexts.
  • Atomic: Only works for primitive types, but allows fine-grained control over concurrency without the need for locking. Faster than Mutex when applicable.

Use Cases

  • Counters: Atomic types are ideal for implementing shared counters across threads.
  • Flags: Use AtomicBool for toggling state flags.
  • Lock-free data structures: Building efficient concurrent data structures without the performance overhead of locks.

Below example shows that we are process 100 items one by one on a background thread, while the main thread gives the user regular updates on the progress:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Duration;

fn process_item(item: usize) {
    // Simulate processing time
    println!("Processing item {item}");
    thread::sleep(Duration::from_millis(100));
}

fn main() {
    let num_done = AtomicUsize::new(0);

    thread::scope(|s| {
        // A background thread to process all 100 items.
        s.spawn(|| {
            for i in 0..100 {
                process_item(i); // Assuming this takes some time.
                num_done.store(i + 1, Ordering::Relaxed);
            }
        });

        // The main thread shows status updates, every second.
        loop {
            let n = num_done.load(Ordering::Relaxed);
            if n == 100 { break; }
            println!("Working.. {n}/100 done");
            thread::sleep(Duration::from_secs(1));
        }
    });

    println!("Done!");
}
/*
Working.. 0/100 done
Processing item 0
Processing item 1
Processing item 2
Processing item 3
Processing item 4
Working.. 4/100 done
Processing item 5
Processing item 6
Processing item 7
Processing item 8
Processing item 9
Working.. 9/100 done
::::
::::
Processing item 98
Processing item 99
Working.. 99/100 done
Done!
*/        


Every time the background thread finishes processing an item, it stores the number of processed items in an AtomicUsize. Meanwhile, the main thread shows that number to the user to inform them of the progress, about once per half second. Once the main thread sees that all 100 items have been processed, it exits the scope, which implicitly joins the background thread, and informs the user that everything is done.


There is a problem with above design :

Once the last item is processed, it might take up to half second(or predefined time) for the main thread to know, introducing an unnecessary delay at the end. To solve this, we can use thread parking to wake the main thread from its sleep whenever there is new information it might be interested in.

Improved example where syncronization is achived:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Duration;

fn process_item(item: usize) {
    // Simulate processing time
    println!("Processing item {item}");
    thread::sleep(Duration::from_millis(100));
}

fn main() {
    let num_done = AtomicUsize::new(0);

    let main_thread = thread::current();

    thread::scope(|s| {
        // A background thread to process all 100 items.
        s.spawn(|| {
            for i in 0..100 {
                process_item(i); // Assuming this takes some time.
                num_done.store(i + 1, Ordering::Relaxed);
                main_thread.unpark(); // Wake up the main thread.
            }
        });

        // The main thread shows status updates.
        loop {
            let n = num_done.load(Ordering::Relaxed);
            if n == 100 { break; }
            println!("Working.. {n}/100 done");
            thread::park_timeout(Duration::from_secs(1));
        }
    });

    println!("Done!");
}
/*
Working.. 0/100 done
Processing item 0
Processing item 1
Working.. 1/100 done
Processing item 2
Working.. 2/100 done
Processing item 3
Working.. 3/100 done
Processing item 4
::::
::::
Processing item 98
Working.. 98/100 done
Processing item 99
Working.. 99/100 done
Done!
*/        


Practical Use Cases for Atomics

  1. Shared Counters:Useful for counting events, such as the number of processed tasks or connections, without locking mechanisms.
  2. Implementing Spinlocks:Atomics are often used to implement spinlocks, which continuously check a condition until it is met.
  3. Lock-Free Data Structures:Atomics allow building efficient data structures like stacks, queues, or linked lists without locks, reducing contention.

Example: Spinlock Using Atomics

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;

struct Spinlock {
    locked: AtomicBool,
}

impl Spinlock {
    fn new() -> Self {
        Spinlock {
            locked: AtomicBool::new(false),
        }
    }

    fn lock(&self) {
        // Attempt to acquire the lock by setting `locked` to `true`.
        // If `locked` was `false`, it will change to `true` and proceed.
        // If `locked` was `true`, it will keep retrying until it can acquire the lock.
        while self.locked.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed).is_err() {}
    }

    fn unlock(&self) {
        // Release the lock by setting `locked` back to `false`.
        self.locked.store(false, Ordering::Release);
    }
}

fn main() {
    let spinlock = Spinlock::new();

    let handle = thread::spawn(move || {
        spinlock.lock();
        println!("Locked by thread");
        thread::sleep(Duration::from_secs(2));
        spinlock.unlock();
        println!("Unlocked by thread");
    });

    handle.join().unwrap();
}
/*
Locked by thread
Unlocked by thread
*/        

  • The Spinlock struct simulates a simple spinlock mechanism using AtomicBool.
  • The compare_exchange function ensures that the lock is acquired only if it was not previously locked.
  • This type of spinlock can be useful in scenarios where a short critical section does not justify the overhead of a more complex locking mechanism.

To view or add a comment, sign in

Insights from the community

Others also viewed

Explore topics