What is the difference between Rust's thread safety guarentees and atomic variables?

Seems to me like Rust's restriction that only only thread can write to a variable at a time, but any thread can read it is the same thing as an atomic variable.. what's the difference?

An atomic variable purpose is precisely that of being able to withstand writes that happen in parallel of other accesses (writes or reads), so Rust's &mut is rather the opposite!

That being said, you question is not too far off of an important realization regarding Rust, regarding a comparison between &mut references vs. mutable &_ references. Given your mention of atomics, I think the following article is an obligatory reading for this post :slightly_smiling_face: dtolnay::_02__reference_types - Rust

2 Likes

Very interesting:

&T is not an "immutable reference" or "const reference" to data of type T — it is a "shared reference". And &mut T is not a "mutable reference" — it is an "exclusive reference".

An exclusive reference means that no other reference to the same value could possibly exist at the same time. A shared reference means that other references to the same value might exist, possibly on other threads (if T implements Sync ) or the caller's stack frame on the current thread. Guaranteeing that exclusive references really are exclusive is one of the key roles of the Rust borrow checker.

Still though, when ownership changes hands during threading, does it need to be tracked via an atomic variable somehow that determines who owns it?

Which means that multiple writes would be faster because they don't need to wait for a lock/atomic. Maybe reads too, though reads seem like they should be just as fast either way? Either way, doesn't matter who is modifying, just want to know current value which might be before or after another thread writes.

Am I understanding the advantage to Rust's version correctly?

There's no ownership tracking at runtime -- it's all statically determined, at compile time.

  • If a function has a value T, it owns that value.
    • If it moves T somewhere, then that becomes the new owner
      • unless T: Copy, then it's just copied, and they're independent owners.
    • If it lends a &T somewhere, then the owner is also restricted to shared access while that reference is alive.
    • If it lends a &mut T, then the owner can't access the value at all while that reference is alive.
  • If a function has a &mut T, it doesn't own the value T, but it can assume that it has exclusive access to that value for the lifetime of that reference.
    • It can share &T from that, within the scope of the borrowed lifetime, but will also be restricted to shared access itself while that's alive.
    • It can reborrow a &mut to lend elsewhere, but can't access the reference again until that's gone.
  • If a function has a &T, it must assume that the value T could be shared elsewhere.
    • If T: Sync, it could even be shared between multiple threads.
    • It can reshare &T further, within the scope of the borrowed lifetime.

All of the statements of "while"/"until"/etc. are statically determined regions of code -- i.e. lifetimes.

(I've probably oversimplified this somewhere...)

2 Likes

Also read: Rust: A unique perspective

1 Like

Good article. So as I'm not understanding it, Send is saying that one thread can write to it, the others can read from it. Which I'm sure is higher performance and doesn't require a lock. Sync is basically the same as saying that you need an atomic pointer for that variable. Is that right?

I was understanding Send as sending ownership from one thread to the next, that makes more sense.

Send is used to say it's safe to pass ownership of something to another thread. This is important for things that may rely on thread-local variables, because passing that object to another thread means you'll actually be using something different.

Sync means something is safe to be accessed from multiple threads at a time, but no ownership is passed around.

No. You just need some sort of shared reference. That may be something like &T or Arc<T>, or your own custom shared pointer type.

The Send and Sync traits don't care about what mechanism you use to maintain thread safety (atomic variables, locks, channels, etc.), but under the hood a lot of things which are Sync and intended to be used concurrently will need to use atomics/locks/whatever to implement operations which need to change internal state.

No, Send only means that accessing the value from a single thread at a time is allowed. This means that you can transfer unique ownership from one thread to another. It also means you can transfer a unique reference (&mut T) from one thread to another. It does not mean that multiple threads can read from the value.

Basically all "normal" Rust types (from f32 to String to HashSet<Vec<u8>>) are Send. The only types that are !Send are ones that could cause data races when accessed on a different thread, because they can contain aliased pointers to mutable data without synchronization (like raw pointers or Rc<T>).

Sync is the trait that means multiple threads are allow shared (&T) access to a value. Since normal Rust types are immutable when shared, this means that, again, all "normal" Rust types are Sync. Shared access to these types is what allows multiple threads to read from the same value. Sharing immutable access across threads doesn't require any atomics.

Atomics or locks are needed only when something is both shared and mutable. So types that allow shared mutation must either use primitives that provide synchronization (like AtomicUsize, Mutex<T>, and RwLock<T>), or they must be marked !Sync to restrict them to one thread at a time (like Cell<T> and RefCell<T>).

To summarize:

These are three distinct concepts.

  • Send: you can transfer unique access to a different thread.
  • Sync: you can transfer shared access to multiple threads.
  • Atomics: allow you to mutate a value while multiple threads have shared access.
4 Likes

Ok, yeah that's more what I thought it was.

Any example code you guys know of that clearly shows this in action? I may not understanding because of traits.

Do you know how it works behind the scenes, like "machine code" level? Doesn't sending ownership require an atomic operation to say this thread owns the variable, this thread does not? In which case, you reduce atomic operations but don't actually eliminate them. Or does Rust somehow accomplish passing ownership without an atomic operation?

In fact, almost seems like a lock would be needed, because now that another that owns that value, I need to wait until ownership is sent back to me before I can write to a variable, meaning a "lock".

Ownership doesn't exist in assembly. In that world, threads can just arbitrarily access stuff they have a pointer to.

Giving ownership doesn't involve any waiting. If you move something into a newly spawned thread, then all you do is give that thread a pointer or similar to the object, and then call the spawn function. Then the compiler ensures that you don't access the value later after it was moved, but in the assembly, all that happened was a move of a pointer, and it did not involve any locks.

Sending ownership is not an assembly instruction.

1 Like

Send and Sync are "marker traits," which means they don't have any methods. This means there is no code generated for them. They simply state a fact about whether a type is thread-safe or not. They don't generate any machine code; instead they are used only at compile time to decide which programs are valid.

When I write code like this:

let x = Foo::new();
thread::spawn(move || x.do_something());

The compiler doesn't generate different code depending on whether Foo is Send. Instead, if Foo is !Send, it fails at compile time with an error and doesn't generate any code at all!

Similarly, if I try to access x after it was moved into the closure above, I will get an error at compile time.

In most cases developers don't even need to write any code to get the correct compile-time behavior. Send and Sync are automatically implemented for all new types, except ones that contain fields that are known to be !Send or !Sync.

In the rare cases where this default behavior is incorrect (for example, when a struct contains a raw pointer, which the compiler can't tell is thread-safe or not), all the developer does is write an empty impl, like so:

unsafe impl Send for Foo {}

That's it; that's the whole code! Now Foo implements the Send trait, so there are no more compiler errors when you pass it to methods that require Send (like thread::spawn).

3 Likes

Since sending stuff to other threads is done through closures, I think you may be confused by the closure sugar too.

When you do:

let mut x = 42; // <---borrows-----+
let at_x_exclusive = &mut x; // |--+-until------------------+
thread::scope(|thread_spawner| { //                         |
    thread_spawner.spawn(move /* at_x_exclusive */ |_| { // |
        *at_x_exclusive += 27; //                           |
    }); //                                                  |
    // x = 0; // <- Error, can't access while excl borrowed |
}).expect(".join() failed"); // <-----------here------------+
assert_eq!(dbg!(x), 42 + 27);

you are borrowing the value x in an exclusive manner, a property which is "captured" / owned by the at_x_exclusive: it is the "owner" of the exclusive borrow over x. This may be confusing, but that's how it is: each binding and each type is owned in and on itself, but may borrow other values.
Then, we move / give ownership of at_x_exclusive to the move |_| { ... } closure, which is something the compiler can track and follow before generating any machine code.
And it so happens that the signature of the thread spawning functions (thread::scope and thread_spawner.spawn) involve type-level (i.e., again, purely at compile-time analysis) properties so that what the closure may capture must involve borrows that outlive the .expect(".join() failed") line.
This means that the compiler annotates a constraint whereby x must be exclusively borrowed at least until that point, so up until that point, the only way to access (read / modify) x's value is through the owner of that borrow, at_x_exclusive, which, itself, happens to only be usable from within that move |_| { ... } closure (since, as I said, we have given ownership of at_x_exclusive to it).
This effectively means that only that thread can access x, and thus that the other threads cannot.

So it's "merely" a very very clever usage of "enhanced type-checking", of sorts, that ensures that this neither-atomic-nor-mutexed-and-thus-not-thread-safe integer can nevertheless be mutated by another thread without danger, since it is guaranteed at compile-time / by the control flow of the program that no other thread can access x concurrently (which implies it cannot access it in parallel either).


This also happens, but in a less obvious way, when the closure sent to another thread captures by reference rather than by value:

let mut x = 42;
thread::scope(|thread_spawner| {
    thread_spawner.spawn(/* captures __anon__ = &mut x */ |_| {
        x += 27; // *__anon__ += 27;
    });
    // x = 0; // <- Error, can't access while excl borrowed
}).expect(".join() failed");
assert_eq!(dbg!(x), 42 + 27);

For more info about closure captures, and what they unsugar to, see Closures: Magic functions.


But sometimes one cannot guarantee that one thread has exclusive access to our variable, since it may depend on a runtime property.

That is, the following code fails to compile, despite it being programatically sound:

let mut x = 42;
let runtime_condition: bool = ::rand::random();
thread::scope(|thread_spawner| {
    thread_spawner.spawn(|_| {
        if runtime_condition {
            x += 27;
        }
    });
    if runtime_condition.not() {
        x = 0; // <- Error, can't access while excl borrowed
    }
}).expect(".join() failed");
assert_eq!(dbg!(x), 42 + 27);

The reason for it failing to compile is that, from the point of view of compile-time analysis / "enhanced" type-checking, this program is no different than the previous one.

  • This is the same property that makes

    let x: i32 = if true { 42 } else { "Not an integer!" };
    
    • Or, in C:

      int x = true ? 42 : "Not an int!";
      

    also fail to compile.

So compile-time analysis does have its limits. It just so happens that it is usually not that hard to refactor the code into something that Rust ends up accepting, rather than having a loose compiler that would let you write the code I just wrote but would therefore also let you write the code before that.

Back to:

But sometimes one cannot guarantee that one thread has exclusive access to our variable, since it may depend on a runtime property.

when that's the case, we need to share access to x or something wrapping x, so we will need to use &x shared references.

And the issue is, that, by default / for most types, one can only perform reads through a shared reference, hence it being abusingly called "immutable reference".

  • Note that when the type involved T is so that one can only perform reads through a &T, a shared reference to a pointee of type T, then such shared references are safe to Copy (all shared references, by definition, can be copied, that's the point of it being shared / shareable) and Send to other threads. This property is encoded at "enhanced type-level" by the trait Sync, and we say that T is Sync exactly when &T is Sendable across threads.

So, we need to be able to perform mutation through a shared reference, which is possible through a mechanism called Interior Mutability, but that I'll be calling Shared Mutability since it conveys a much clearer meaning.

  • Shared Mutability is the mechanism that allows one to be able to perform some form of mutation through a shared reference &_ (concurrent mutation).

The most straight forward examples of such shared mutability are:

  • atomics (mutating and reading them involves some special CPU intructions that are guaranteed to be thread-safe, i.e., it is fine to have multiple CPUs perform such operations in parallel)

  • values protected by some locking mechanism, such as a Mutex or a RwLock (exclusive access is then indeed checked at runtime, usually through some form of atomic flag)

Such examples of shared mutability happen to be thread safe, so we can say that atomic integers are Sync, and so are Mutex<_> and RwLock<_> too (provided the value they wrap is "not too crazy").

But there is yet another form of shared mutability, which one may not even realize exists: if we "promise" not to be running multiple threads, then there are cases, such as when mutating a simple integer (and more generally, when dealing with things that don't involve pointer indirection), where the whole "you can only use an exclusive reference &mut to perform a mutation" becomes excessive.

  • For instance, how do you write in Rust the following C code:

    int x = 42;
    int * at_x = &x;
    x = x + 27;
    printf("%d\n", *at_x);
    

So, to solve that issue, there exist other wrappers that also offer shared mutability, albeit a non-thread-safe (non-Sync) one, such as Cell (or RefCell if your type is not so simple and you are willing to do peform some (fallible!) runtime checks):

use ::core::cell::Cell as NonThreadSafeMut;

let x = NonThreadSafeMut::new(42);
let at_x = &x; // shared access to `x`
x.set(x.get() + 27); // mutation through another shared access to x!
println!("{}", at_x.get());
8 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.