Possible false-positive in MIRI race detector

Should this code be legal? Main thread sends pointer to its local variable to another thread using relaxed AtomicPtr operations, second thread only writes to that variable then notifies main thread using Release - Acquire pair.

  1. Since first thread do single write before starting second thread and never does any writes after, there should not be any need to synchronize any reads from variable made in second thread even if it had read it.
  2. Since second thread does only writes to variable, it should not need to acquire it (e.g. make it current value visible to second thread) so it should not require synchronization with sender.

Why it is not allowed?

use std::sync::atomic::Ordering::*;
use std::sync::atomic::*;
use std::sync::*;

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());
static MUTEX: Mutex<()> = Mutex::new(());
static CONDVAR: Condvar = Condvar::new();
static IS_FINISHED: AtomicBool = AtomicBool::new(false);

fn main() {
    for _ in 0..1000 {
        P.store(core::ptr::null_mut(), Relaxed);
        IS_FINISHED.store(false, Relaxed);

        let mut val: u8 = 0;

        let _t1 = std::thread::spawn(|| {
            while P.load(Relaxed).is_null() {
                std::hint::spin_loop();
            }
            unsafe {
                let ptr = P.load(Relaxed);
                // Access 2
                *ptr = 127;
            }

            IS_FINISHED.store(true, Release);
            let _g = MUTEX.lock().unwrap();
            CONDVAR.notify_one();
        });

        // Access 1
        P.store(&mut val, Relaxed);
        let mut guard = MUTEX.lock().unwrap();
        while !IS_FINISHED.load(Acquire) {
            guard = CONDVAR.wait(guard).unwrap();
        }

        // Access 3
        assert_eq!(val, 127);
    }
}

rust version:

rustc 1.77.0-nightly (bf8716f1c 2023-12-24)
binary: rustc
commit-hash: bf8716f1cd6416266807706bcae0ecb2e51c9d4a
commit-date: 2023-12-24
host: x86_64-pc-windows-msvc
release: 1.77.0-nightly
LLVM version: 17.0.6

MIRI output:

error: Undefined Behavior: Data race detected between (1) non-atomic write on thread `main` and (2) non-atomic write on thread `<unnamed>` at alloc1927. (2) just happened here
  --> src\main.rs:24:17
   |
24 |                 *ptr = 127;
   |                 ^^^^^^^^^^ Data race detected between (1) non-atomic write on thread `main` and (2) non-atomic write on thread `<unnamed>` at alloc1927. (2) just happened here
   |
help: and (1) occurred earlier here
  --> src\main.rs:33:17
   |
33 |         P.store(&mut val, Relaxed);
   |                 ^^^^^^^^
   = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
   = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
   = note: BACKTRACE (of the first span):
   = note: inside closure at src\main.rs:24:17: 24:27

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error

I can’t comment on the question whether this is intended behavior, but I can minimize your example:

use std::sync::atomic::{AtomicPtr, Ordering::Relaxed};

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());

fn main() {
    let val: u8 = 0;

    // take reference here
    let r = &val;

    // start thread here
    let t = std::thread::spawn(|| loop {
        match P.load(Relaxed) {
            ptr if ptr.is_null() => std::hint::spin_loop(),
            ptr => {
                unsafe {
                    let _v = *ptr;
                }
                break;
            }
        }
    });

    P.store(r as *const u8 as _, Relaxed);

    t.join().unwrap();
}

miri is happy


use std::sync::atomic::{AtomicPtr, Ordering::Relaxed};

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());

fn main() {
    let val: u8 = 0;

    // start thread here
    let t = std::thread::spawn(|| loop {
        match P.load(Relaxed) {
            ptr if ptr.is_null() => std::hint::spin_loop(),
            ptr => {
                unsafe {
                    let _v = *ptr;
                }
                break;
            }
        }
    });

    // take reference here
    let r = &val;

    P.store(r as *const u8 as _, Relaxed);

    t.join().unwrap();
}
error: Undefined Behavior: Data race detected between (1) non-atomic write on thread `main` and (2) non-atomic read on thread `<unnamed>` at alloc1507. (2) just happened here
  --> src/main.rs:14:30
   |
14 |                     let _v = *ptr;
   |                              ^^^^ Data race detected between (1) non-atomic write on thread `main` and (2) non-atomic read on thread `<unnamed>` at alloc1507. (2) just happened here
   |
help: and (1) occurred earlier here
  --> src/main.rs:22:13
   |
22 |     let r = &val;
   |             ^^^^
   = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
   = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
   = note: BACKTRACE (of the first span):
   = note: inside closure at src/main.rs:14:30: 14:34

I’m not entirely sure why creating &val counts as a “non-atomic write” (to val), as far as I can tell.

@steffahn You example is unsound because you modify contents of immutable variable.

It should definitely trigger MIRI error as a write using readonly reference. Do you think we should open an issue about that?

The plot thickens… apparently, this alone makes a difference in my minimized example… (I’m not entirely sure what exactly is going on here, and whether or not it’s all intended behavior).

use std::sync::atomic::{AtomicPtr, Ordering::Relaxed};

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());

fn main() {
    let val: u8 = 0;

    // already take a (unused) reference here
    let _ = &val;

    // take reference here
    let t = std::thread::spawn(|| loop {
        match P.load(Relaxed) {
            ptr if ptr.is_null() => std::hint::spin_loop(),
            ptr => {
                unsafe {
                    let _v = *ptr;
                }
                break;
            }
        }
    });

    // take the actual reference here
    let r = &val;

    P.store(r as *const u8 as _, Relaxed);

    t.join().unwrap();
}

Regarding your original code, I would just assume that Rust probably has a rule like “when &mut …expr… is evaluated, then …expr… must be valid for writes at that point”. And this means, for soundness purposes, a write happens at that point, i.e. after the thread was started, and that write is unsynchronized. E.g. this is accepted (though I would probably even make sure to create the pointer before the thread starts):

use std::sync::atomic::Ordering::*;
use std::sync::atomic::*;
use std::sync::*;

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());
static MUTEX: Mutex<()> = Mutex::new(());
static CONDVAR: Condvar = Condvar::new();
static IS_FINISHED: AtomicBool = AtomicBool::new(false);

fn main() {
    for _ in 0..10 {
        P.store(core::ptr::null_mut(), Relaxed);
        IS_FINISHED.store(false, Relaxed);

        let mut val: u8 = 0;
        let r = &mut val;

        let _t1 = std::thread::spawn(|| {
            while P.load(Relaxed).is_null() {
                std::hint::spin_loop();
            }
            unsafe {
                let ptr = P.load(Relaxed);
                // Access 2
                *ptr = 127;
            }

            IS_FINISHED.store(true, Release);
            let _g = MUTEX.lock().unwrap();
            CONDVAR.notify_one();
        });

        // Access 1
        P.store(r, Relaxed);
        let mut guard = MUTEX.lock().unwrap();
        while !IS_FINISHED.load(Acquire) {
            guard = CONDVAR.wait(guard).unwrap();
        }

        // Access 3
        assert_eq!(val, 127);
    }
}

but this isn’t

use std::sync::atomic::Ordering::*;
use std::sync::atomic::*;
use std::sync::*;

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());
static MUTEX: Mutex<()> = Mutex::new(());
static CONDVAR: Condvar = Condvar::new();
static IS_FINISHED: AtomicBool = AtomicBool::new(false);

fn main() {
    for _ in 0..10 {
        P.store(core::ptr::null_mut(), Relaxed);
        IS_FINISHED.store(false, Relaxed);

        let mut val: u8 = 0;
        let r = &mut val;

        let _t1 = std::thread::spawn(|| {
            while P.load(Relaxed).is_null() {
                std::hint::spin_loop();
            }
            unsafe {
                let ptr = P.load(Relaxed);
                // Access 2
                *ptr = 127;
            }

            IS_FINISHED.store(true, Release);
            let _g = MUTEX.lock().unwrap();
            CONDVAR.notify_one();
        });

        let r = &mut *r;

        // Access 1
        P.store(r, Relaxed);
        let mut guard = MUTEX.lock().unwrap();
        while !IS_FINISHED.load(Acquire) {
            guard = CONDVAR.wait(guard).unwrap();
        }

        // Access 3
        assert_eq!(val, 127);
    }
}

No I don’t? I just read… let _v = *ptr; in my “minimized” (one might even say “altered”) example.

At least NLL wise, &mut LV is a "deep write" of LV, and I assumed with my own tinkering that was what Miri was talking about with &mut val being a non-atomic write. But that doesn't explain why &val would also be called a write (in NLL it's a "deep read").

1 Like

Intuitively, I'd guess maybe the compiler wants to be allowed to initialize val only after the thread was started, if it had otherwise been unused before, and thus calls out the &val as the first use site as a write even though the true write operation is the initialization. I'm not familiar with the formal rules that may or may not validate this interpretation.


Edit: Some minor testing suggests it might be less about “first usage” of a variable and more about “first usage that forces the variable to have a location in memory”.

(see my “minor testing” here)

Like… this fails:

use std::sync::atomic::{AtomicPtr, Ordering::Relaxed};

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());

fn main() {
    let val: u8 = 0;

    let val2 = val;
    println!("{val2}");

    // take reference here
    let t = std::thread::spawn(|| loop {
        match P.load(Relaxed) {
            ptr if ptr.is_null() => std::hint::spin_loop(),
            ptr => {
                unsafe {
                    let _v = *ptr;
                }
                break;
            }
        }
    });

    // already take a (unused) reference here
    let _ = &val;

    // take the actual reference here
    let r = &val;

    P.store(r as *const u8 as _, Relaxed);

    t.join().unwrap();
}

but this doesn’t

use std::sync::atomic::{AtomicPtr, Ordering::Relaxed};

static P: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());

fn main() {
    let val: u8 = 0;

    let val2 = *&val; // or e.g. val.clone()
    println!("{val2}");

    // take reference here
    let t = std::thread::spawn(|| loop {
        match P.load(Relaxed) {
            ptr if ptr.is_null() => std::hint::spin_loop(),
            ptr => {
                unsafe {
                    let _v = *ptr;
                }
                break;
            }
        }
    });

    // already take a (unused) reference here
    let _ = &val;

    // take the actual reference here
    let r = &val;

    P.store(r as *const u8 as _, Relaxed);

    t.join().unwrap();
}

Of course, we might or might not be also dealing with some false negatives here, maybe that first usage by-reference does not actually/officially guarantee a stable memory position like that, but miris implementation works with that approach.

1 Like

Stacked Borrows has this rule, creation of a mutable reference triggers a "phantom" write. However, Tree Borrows is more permissive, and only triggers a phantom read. (Which will still cause your final example to fail)

1 Like

Oh, sorry, I missed reversing of assignment in second thread.

The difference in built MIR between the two is tiny, and seems due just to the additional scope entered for the extra &val. The relevant bits:

-// fail
+// pass
 let _1: u8;
+let mut _3: &u8;
 // …
 let _11: &u8;
 // …
 scope 1 {
     debug val => const 0_u8;
-    scope 2 {
         let _2: u8;
-        scope 3 {
+        scope 2 {
             debug val2 => _2;
-            scope 4 {
+            scope 3 {
                 // …
-                scope 5 {
+                scope 4 {
                     let _13: &u8;
-                    scope 6 {
+                    scope 5 {
                         debug r => _13;
                     }
                 }
             }
         }
-    }
 }

 bb0: {
     // …
     _1 = const 0_u8; // val
+    _3 = &_1;
-    _2 = _1; // val2
+    _2 = *_3; // val2
     // …
     _11 = &_2; // for println!
     // …
     _12 = spawn::<{closure}, ()>(const ZeroSized: {closure}) -> [return: bb4, unwind continue];
 }
 bb4: {
     _13 = &_1; // r
     // …
 }

(Where scopes actually cover isn't notated in the human consumption only MIR format.) This doesn't seem like it should have semantic impact; everything w.r.t. the place should be happening in the same causual order in both cases.

Without having dived into Miri, it likely is a false positive due to places not being allocated into memory until their reference is taken. It's an optimization for most cases (there's a lot of bookkeeping happening for allocations in Miri) but results in a false positive here. Assuming that's a correct assessment, the fix would be to assign a timestamp corresponding to the local place initialization when reifying into an allocation, instead of the timestamp of the reification itself.

3 Likes

The original program here is not a false positive, Miri is right here. &mut val is, conceptually, a write to val, and that write races with the write in the other thread.

However, this version with a raw pointer still shows the same error. So I think @CAD97 is on to something, the "delayed allocation" trick doesn't interact properly with the data race detection. This could be tricky to fix...

I opened a Miri issue to track this:

3 Likes

What's the rationale for considering &mut x a write to the location of x? Isn't this just the possibility of a write?

I imagine it is something along the lines of my analysis in a previous RFC:

https://rust-lang.github.io/rfcs/3323-restrictions.html#where-does-a-mutation-occur

The idea is that the compiler should be allowed to insert spurious writes even if you didn't do any explicit write. Having &mut act as a write ensures that this is possible.

As was mentioned above, in Tree Borrows this got relaxed to having &mut x (and &x) just be a read, not a write. This avoids a bunch of UB found in the wild. That gives up on spurious writes but still allows the compiler to insert spurious reads (we set the dereferenceable attribute to inform LLVM about this). The "implicit write on &mut" is somewhat experimental, but the implicit read is almost certainly going to happen, and that is sufficient to make the original program UB.

(Tree Borrows is another experimental aliasing model, a successor of sorts to Stacked Borrows. Where Stacked Borrows probably rejects too many programs, Tree Borrows probably accepts too many.)

2 Likes

But why could allowing spurious writes be desirable? Would it help optimizations? Are those optimizations done in practice today, or are they just theoretical?

If it reduces the number of UB found in the wild, while not giving up some very important optimization, it seems that tree borrows is right on this one.

(Spurious reads on the other hand helps enabling speculative reads / reads that are done in advance whether we will actually need them or not; this seems much more useful)

Allowing turning

unknown();
*p = 5;

into

*p = 5;
unknown();

and other code motion along those lines. The most obvious way doing so is beneficial is when it reduces register pressure.

Doing the write earlier counts as spurious because unknown() could read the memory location and then diverge, at least per TB's rules. The write after would be UB in that case, but it never happens if unknown() doesn't return normally.

3 Likes
fn foo(x: usize, y: &mut usize) {
    for _ in 0..x {
        *y += 1;
    }
}

Allowing spurious writes through &mut allows optimizing that to *y += x, otherwise it has to branch for the case without a write: if x != 0 { *y += x; }

5 Likes

A classic example would be something like

for x in … {
    *p = 1;
    foo(x);
}
*p = 0;

where being able to LICM it to

*p = 1;
for x in … {
    foo(x);
}
*p = 0;

can save a bunch of work.

But if the for loop never actually ran the body, that *p = 1 wouldn't have happened, so in order to be allowed to do that you need to be allowed to do spurious writes.

(The read version of that is more commonly applicable, but the write version exists too.)

2 Likes