Is write guaranteed with one thread writing and another reading non-atomic

172 Views Asked by At

Say I have

bool unsafeBool = false;

int main()
{
    std::thread reader = std::thread([](){ 
        std::this_thread::sleep_for(1ns);
        if(unsafeBool) 
            std::cout << "unsafe bool is true" << std::endl; 
    });
    std::thread writer = std::thread([](){ 
        unsafeBool = true; 
    });
    reader.join();
    writer.join();
}

Is it guaranteed that unsafeBool becomes true after writer finishes. I know that it is undefined behavior what reader outputs but the write should be fine as far as I understand.

3

There are 3 best solutions below

0
On BEST ANSWER

UB is and stays UB, there can be reasoning about why things are happinging the way they happen, though, you ain't allowed to rely on that.

You have a race condition, fix it by either: adding a lock or changing the type to an atomic.

Since you have UB in your code, your compiler is allowed to assume that doesn't happen. If it can detect this, it can change your complete function to a noop, as it can never be called in a valid program.

If it doesn't do so, the behaviour will depend on your processor and the caching linked to it. There, if the code after the joins uses the same core as the thread that read the boolean (before the join), you might even still have false in there without the need to invalidate the cache.

In practice, using Intel X86 processors, you will not see a lot of side effects from the race conditions as it has been made to invalidate the caches on write.

14
On

After writer.join() it guaranteed that unsafeBool == true. But in reader thread the access to it is a data race.

0
On

Some implementations guarantee that any attempt to read the value of a word-size-or-smaller object that isn't qualified volatile around the time that it changes will either yield an old or new value, chosen arbitrarily. In cases where this guarantee would be useful, the cost for a compiler to consistently uphold it would generally be less than the cost of working around its absence (among other things, because any ways by which programmers could work around its absence would restrict a compiler's freedom to choose between an old or new value).

In some other implementations, however, even operations that would seem like they should involve a single read of a value might yield code that combines the results from multiple reads. When ARM gcc 9.2.1 is invoked with command-line arguments -xc -O2 -mcpu=cortex-m0 and given:

#include <stdint.h>
#include <string.h>
#if 1
uint16_t test(uint16_t *p)
{
    uint16_t q = *p;
    return q - (q >> 15);
}

it generates code which reads from *p and then from *(int16_t*)p, shifts the latter right by 15, and adds that to the former. If the value of *p were to change between the two reads, this could cause the function to return 0xFFFF, a value which should be impossible.

Unfortunately, many people who design compilers so that they will always refrain from "splitting" reads in such fashion think such behavior is sufficiently natural and obvious that there's no particular reason to expressly document the fact that they never do anything else. Meanwhile, some other compiler writers figure that because the Standard allows compilers to split reads even when there's no reason to (splitting the read in the above code makes it bigger and slower than it would be if it simply read the value once) any code that would rely upon compilers refraining from such "optimizations" is "broken".