Home > Software engineering >  Atomic wrappers vs primitives
Atomic wrappers vs primitives

Time:01-31

I'm trying to understand few differences between std::sync::atomic::Atomic* structs and primitives such as i32, usize, bool in scope of multithreading.

First question, will another thread see changes to the non atomic type from another thread?

fn main() {
    let mut counter = 0;

    std::thread::scope(|scope| {
        scope.spawn(|| counter  = 1)
    });

    println!("{counter}");
}

Can I be sure that counter will be 1 right after another thread will write this value into it, or thread could cache this value? If not, will it work only with atomic type only?

fn main() {
    let counter = AtomicI32::new(0);

    std::thread::scope(|scope| {
        scope.spawn(|| counter.store(1, Ordering::Release))
    });

    println!("{}", counter.load(Ordering::Acquire)); // Ordering::Acquire to prevent from reordering previous instructions
}

Second question, does Ordering type affects when value in store will be visible in other threads, or it will be visible right after store, even if Ordering::Relaxed was applied? As for example, will same code but with Ordering::Relaxed and no instructions reorder show 1 in counter?

fn main() {
    let counter = AtomicI32::new(0);

    std::thread::scope(|scope| {
        scope.spawn(|| counter.store(1, Ordering::Relaxed))
    });

    println!("{}", counter.load(Ordering::Relaxed));
}

I understand difference between atomic and non atomic writes to same variable, I'm only interested if another thread will see changes, even if this changes won't be consistent.

CodePudding user response:

First question, will another thread see changes to the non atomic type from another thread?

Yes. The difference between atomic and non-atomic variables is that you can change atomic variables using shared references, &AtomicX, and not just using mutable references, &mut X. This means that they can be changed in parallel in different threads. For primitives, the compiler will reject attempting that, e.g.:

fn main() {
    let mut counter = 0;

    std::thread::scope(|scope| {
        scope.spawn(|| counter  = 1);
        scope.spawn(|| counter  = 1);
    });

    println!("{counter}");
}

Or even the following, where we use the variable on the main thread but before the spawned thread is joined:

fn main() {
    let mut counter = 0;

    std::thread::scope(|scope| {
        scope.spawn(|| counter  = 1);
        counter  = 1;
    });

    println!("{counter}");
}

While with atomics this will work:

fn main() {
    let counter = AtomicI32::new(0);

    std::thread::scope(|scope| {
        scope.spawn(|| counter.store(1, Ordering::Relaxed));
        scope.spawn(|| counter.store(1, Ordering::Relaxed));
    });

    println!("{}", counter.load(Ordering::Relaxed));
}

Second question, does Ordering type affects when value in store will be visible in other threads, or it will be visible right after store, even if Ordering::Relaxed was applied? As for example, will same code but with Ordering::Relaxed and no instructions reorder show 1 in counter?

No. Ordering does not change what other threads will observe with this variable. And therefore, your usage of Release and Acquire is wrong.

On the other hand, Relaxed here will suffice, for other reasons.

You are guaranteed to see the value 1 in your code no matter what ordering you will use, because std::thread::scope() implicitly joins all spawned threads on exit, and joining a thread forms a happens-before relationship between everything done in this thread and the code after the join. In other words, you are guaranteed that everything done in the thread (and the includes storing to counter) will happen before everything you do after you join it (and that includes reading counter).

If there was not a join, for example, in this code:

fn main() {
    let counter = AtomicI32::new(0);

    std::thread::scope(|scope| {
        scope.spawn(|| counter.store(1, Ordering::Release));
        scope.spawn(|| println!("{}", counter.load(Ordering::Acquire)));
    });
}

Then you are not guaranteed, despite the Release and Acquire orderings, to read the updated value. It may happen so, or may happen that you will read the old value.

Orderings are useful to create a happens-before relationship with different variables and code. But this is a complicated subject. I recommend reading this book (written by a Rust libs team member).

  • Related