18

Consider the following situation

// Global
int x = 0; // not atomic

// Thread 1
x = 1;

// Thread 2
if (false)
    x = 2;

Does this constitute a data race according to the standard? [intro.races] says:

Two expression evaluations conflict if one of them modifies a memory location (4.4) and the other one reads or modifies the same memory location.

The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.

Is it safe from a language-lawyer perspective, because the program can never be allowed to perform the "expression evaluation" x = 2;?

From a technical standpoint, what if some weird, stupid compiler decided to perform a speculative execution of this write, rolling it back after checking the actual condition?

What inspired this question is the fact that (at least in Standard 11), the following program was allowed to have its result depend entirely on reordering/speculative execution:

// Thread 1:
r1 = y.load(std::memory_order_relaxed);
if (r1 == 42) x.store(r1, std::memory_order_relaxed);
// Thread 2:
r2 = x.load(std::memory_order_relaxed);
if (r2 == 42) y.store(42, std::memory_order_relaxed);
// This is allowed to result in r1==r2==42 in c++11

(compare https://en.cppreference.com/w/cpp/atomic/memory_order)

JMC
  • 1,723
  • 1
  • 11
  • 20
  • 1
    which decent compiler will emit code from if (0) ? – OznOg Jun 09 '22 at 14:04
  • 1
    None, but the question is tagged "language-lawyer". Consider the question as: Would a compiler that did not delete if(0) foo(); and then cause a data race to occur through speculative execution or any other transformation still technically fulfill the standard as a contract? Does the standard force the behavior of this, or does it fall under "undefined behavior" giving any compliant compiler license to do anything? – JMC Jun 09 '22 at 14:07
  • 1
    Even if the compiler doesn't optimize it out, there is no data race here because the condition evaluates to `false` so the code will never be executed anyway (consequently there will never be unprotected concurrent access to the same memory location). – Fareanor Jun 09 '22 at 14:13
  • 3
    @Fareanor, Re, "the code will never be executed anyway." The question is not about what any sane implementation _would_ do. The question is about what a [tag:language-lawyer] thinks the standard might _allow_ an implementation to do. OP asked specifically about an implementation that starts to perform the `x=2` assignment concurrently with testing the `if` condition, and which then cancels or "rolls back" the operation upon discovering that the condition is false. – Solomon Slow Jun 09 '22 at 14:20
  • 3
    Relevant question for C: [Can code that will never be executed invoke undefined behavior?](https://stackoverflow.com/q/18385020/580083) – Daniel Langr Jun 09 '22 at 14:29
  • You quoted the Standard saying "potentially concurrent conflicting actions".... code that is never executed is not potentially concurrent. – Ben Voigt Jun 09 '22 at 15:38
  • 2
    @SolomonSlow: Your idea about "rolling back" is not far-fetched; an `[[likely]]` annotation might reasonably cause a compiler to use a rollback for the unlikely scenario. But the C++ standard uses a theoretical execution model in which such rollbacks do not happen. In that theoretical model , Fareanor is right, the code is not executed. Language-lawyers care about this theoretical model. And implementations generally must behave as-if they implement the theoretical execution model. – MSalters Jun 09 '22 at 15:55
  • @MSalters, Implementations must behave as if they implement the theoretical execution model \*IF\* the program does not depend on UB. The question for the language lawyers is about whether or not this particular case depends on UB. OP seems to be asking whether the fact that the program must behave as if `x=2` never happens takes priority over the fact that would be UB if it _did_ happen. – Solomon Slow Jun 09 '22 at 17:54
  • Technically the compiler would have to speculatively execute `t = 2` and only execute `x = t` when the branch is actually taken to avoid data races with threads. Practically on every CPU that has registers worth a damn `x` will be a register and any speculative execution will happen purely in the register. You would maybe see a final write back conditional on the condition of the `if`, which would never write. So I would say you are save, any speculative execution will still follow the theoretical execution model. – Goswin von Brederlow Jun 09 '22 at 21:20
  • 2
    @DanielLangr: Also highly related: [What formally guarantees that non-atomic variables can't see out-of-thin-air values and create a data race like atomic relaxed theoretically can?](https://stackoverflow.com/a/58049046) - the out-of-thin-air problem is just a gap in the formalism for `mo_relaxed`, and *not* something that applies to plain objects. (And not something that any real implementation will allow for atomics either; the C++ committee intends to forbid it.) Introducing data races that affect behaviour would violate the as-if rule. (See also https://lwn.net/Articles/793253/) – Peter Cordes Jun 09 '22 at 23:50
  • @SolomonSlow: One difficulty is that the Standard doesn't recognize that certain details of program behavior may be observable in some execution environments but not others. If, for example, a compiler which is targeting a single-core X86 platform replaces "if (x) myThing+=x;` with `mov ax,[x] / add [myThing],ax`, such a substitution would only be observable if a DMA operation wrote to `myThing` during the execution of the "add" instruction. For the Standard to allow such a substitution in cases where it couldn't interfere with DMA, but forbid it in cases where it would, it would have to... – supercat Jun 14 '22 at 21:09
  • ...use an abstraction model that specifies more operations in terms of underlying platform primitives. – supercat Jun 14 '22 at 21:13

2 Answers2

19

The key term is "expression evaluation". Take the very simple example:

int a = 0;
for (int i = 0; i != 10; ++i) 
   ++a;

There's one expression ++a, but 10 evaluations. These are all ordered: the 5th evaluation happens-before the 6th evaluation. And the evaluations of ++a are interleaved with the evaluations of i!=10.

So, in

int a = 0;
for (int i = 0; i != 0; ++i) 
   ++a;

there are 0 evaluations. And by a trivial rewrite, that gets us

int a = 0;
if (false)
   ++a;

Now, if there are 10 evaluations of ++a, we need to worry for all 10 evaluations if they race with another thread (in more complex cases, the answer might vary - say if you start a thread when a==5). But if there are no evaluations at all of ++a, then there's clearly no racing evaluation.

user694733
  • 15,208
  • 2
  • 42
  • 68
MSalters
  • 173,980
  • 10
  • 155
  • 350
6

Does this constitute a data race according to the standard?

No, data races are concerned with access to storage locations in expressions which are actually evaluated as your quote states. In if (false) x = 2; the expression x = 2; is never evaluated. Hence it doesn't matter at all to determining the presence of data races.

Is it safe from a language-lawyer perspective, because the program can never be allowed to perform the "expression evaluation" x = 2;?

Yes.

From a technical standpoint, what if some weird, stupid compiler decided to perform a speculative execution of this write, rolling it back after checking the actual condition?

It is not allowed to do that if it could affect the observable behavior of the program. Otherwise it may do that, but it is impossible to observe the difference.

What inspired this question is the fact that (at least in Standard 11), the following program was allowed to have its result depend entirely on reordering/speculative execution:

That's a completely different situation. This program also doesn't have any data races, since the only variables that are accessed in both threads are atomics, which can never have data races. It merely has potentially multiple valid results, meaning a race condition. A data race would always imply undefined behavior, not merely unspecified behavior.

Also the out-of-thin-air issue appears only as a result of the circular dependence of the accesses between multiple atomics. In your initial example there is only one variable, non-atomic and without any such circular dependence.

user17732522
  • 53,019
  • 2
  • 56
  • 105
  • _"if it could affect the observable behavior of the program"_ — but it doesn't affect the observable behavior if the program is well-formed (e.g. if the threads are separated in time). So the compiler actually can do this speculative execution. – Ruslan Jun 10 '22 at 10:51
  • @Ruslan The program is also well-formed if the threads are executing at the same time, in which case the compiler may not introduce such a speculative store if that could on the machine level e.g. result in tearing with a final value of `x` different from `1` being observable. Of course that paragraph of my answer is actually pretty pointless, since it is simply restating the general as-if rule that a compiler can translate the program in any way that preserves the observable behavior under the rules of the abstract machine. – user17732522 Jun 10 '22 at 10:59
  • The point is that the final value is expected to be `1` in presence of speculation. E.g. the speculative execution (however idiosyncratic it may be) could do like this: `int t=x; x=2; if(true) x=t;` This will write the original value into `x` in the end, but write `2` during the execution, which would be non-observable if the other thread didn't exist. – Ruslan Jun 10 '22 at 14:24
  • @Ruslan Yes, but if the other thread does exist, then there can be an observable difference (depending on the architecture and how exactly the statements are translated). If so, the compiler is not allowed to make that speculation, otherwise it is. That's all I am saying. – user17732522 Jun 10 '22 at 16:26
  • If the other thread does exist, and it accesses the same variable as the first thread, they must use some means of synchronization, otherwise we have a data race, whose behavior is undefined. Thus, from the point of view of the Standard, the value of `x` is still not observable. – Ruslan Jun 10 '22 at 16:41
  • @Ruslan In the question there is only one thread accessing `x`. Thread 2 never actually performs an access to `x`. So it always has well-defined behavior: After joining both threads the value of `x` is `1` and that is observable (e.g. by printing it). If adding a speculative store to `x`'s memory location in thread 2 will cause the program to potentially print any other value, then this is not a valid translation for the compiler to make. The compiler is not allowed to introduce new data races that are not in the code as written. – user17732522 Jun 10 '22 at 16:49