Are std::atomic loads and stores both required?

Viewed 438

According to this article:

Any time two threads operate on a shared variable concurrently, and one of those operations performs a write, both threads must use atomic operations.

However, if a lower-priority thread is the writer, and a higher-priority thread is the reader, does the lower-priority thread need to enforce atomic stores? It seems to me that only the higher-priority thread needs to enforce an atomic load:

#include <atomic>

std::atomic<T*> ptr; // assume initialized to some non-null value

void highPriThreadFcn(void)
{
    T* local_ptr = ptr.load(); // need atomic load here in case lowPriThread write/store was interrupted
}

void lowPriThreadFcn(T* some_ptr)
{
    ptr = some_ptr; // do I need an atomic store here? I'd think not, as ptr is not written to by highPriThread
}

A similar question would apply in the "reverse" case (write in high-priority thread, read from low-priority thread):

void highPriThreadFcn(T* some_ptr)
{
    ptr = some_ptr; // do I need an atomic store here? I'd think not, as write to ptr cannot be interrupted
}

void lowPriThreadFcn(void)
{
    T* local_ptr = ptr.load(); // need atomic load here in case read/load was interrupted
}
2 Answers

You cannot perform a non-atomic store or load on an atomic variable. The API ensures that there is no way to attempt it. Your allegedly "non-atomic store",

ptr = some_ptr;

is actually an atomic store with sequentially consistent ordering. See atomic::operator= on cppreference.

By the way, in case you were thinking about changing the atomic variable to a non-atomic variable on which only some operations are performed atomically: don't do that. Whenever a load and a store to the same memory location are "potentially concurrent", the standard requires that they both be atomic. Otherwise, the behaviour is undefined. That means the compiler is allowed to "optimize" in a way that breaks your code. Whether or not a thread is "higher" priority than another doesn't affect this.

The Standard is designed to be supportable on platforms with weird and bizarre memory semantics. Too accommodate this, it avoids imposing any requirements upon how implementations handle any constructs which might be hard for some platforms to handle predictably. Some implementations interpret this as license to behave in arbitrary and unpredictably fashion in such cases even when targeting platforms that would naturally support stronger memory semantics. This facilitates some kinds of optimization, but can make it necessary for programmers to take explicit actions to tie optimizers' hands, in some cases making things less efficient than they could have been if compilers provided (and programmers exploited) stronger semantics than the Standard requires.

As an example of a situation where things might go wrong, consider something like:

extern int foo;

int x=foo;
.... some code that modifies neither x nor foo
int y=x;
.... some code that modifies neither y nor foo (but might modify x)
doSomething(x, y);

It would be entirely plausible that an optimizing compiler might replace the last function call with doSomething(x, foo) on the assumption that foo must equal the value that y would have had at the time of the original assignment. Rather than use an abstraction model that would allow for bounded non-determinism, the authors of the Standard simply declined to mandate any sort of behavioral guarantees about the effects of having objects' values change unexpectedly.

Related