Skip to content

Instantly share code, notes, and snippets.

@Eisenwave
Created August 2, 2023 16:39
Show Gist options
  • Save Eisenwave/0e59115cef62918325ddb8c887299a4f to your computer and use it in GitHub Desktop.
Save Eisenwave/0e59115cef62918325ddb8c887299a4f to your computer and use it in GitHub Desktop.
The C++ Memory Model and Multithreading - According to the Standard

The C++ Memory Model and Multithreading
According to the Standard

All code you write is ultimately designed to read and write memory. One aspect of this is the memory model of the language; something which is accompanying us as developers, but we rarely have to think about.

How come, for example, the following code does what we expect it to?

int x = 1;
x = 5;
std::println("{}", x); // prints 5, but why?!

This code prints 5, but why does it do so? Intuitively, x = 1 happens first, and then x = 5 stores 5 in the same variable.

However, who says so? How come x = 5 isn't executed first? How come x can't be 1 or some other value when it gets printed?

Single-threaded memory model

Statements are executed in sequence

In the introductory example, x = 1;, x += 5, and std::println("{}", x); are statements ([stmt.stmt]).

Except as indicated, statements are executed in sequence.

- [stmt.pre] p1

"Executed in sequence" refers to one statement being sequenced before another, which means that it is executed before another by the abstract machine. Furthermore, all three statements are full-expressions:

  • int x = 1; is an init-declarator (see 5.4)
  • x = 5; is an expression which is not a subexpression (see 5.6)
  • the same as above applies to std::println("{}", x);

It is important that they are full-expressions, because:

Every value computation and side effect associated with a full-expression is sequenced before every value computation and side effect associated with the next full-expression to be evaluated.

- [intro.execution] p9

Side effects and value computations may be sequenced

Intuitively, a value computation is a load from memory, such as the one necessary for x when printing. A side effect is (among other things) a write to memory. For the introductory example, it means that e.g. the side effect of writing 5 to memory during x = 5 is sequenced before the value computation of x which takes place during std::println("{}", x).

The sequenced before ([intro.execution]) relationship is defined as follows:

Sequenced before is an asymmetric, transitive, pair-wise relation between evaluations executed by a single thread ([intro.multithread]), which induces a partial order among those evaluations. Given any two evaluations A and B, if A is sequenced before B (or, equivalently, B is sequenced after A), then the execution of A shall precede the execution of B. If A is not sequenced before B and B is not sequenced before A, then A and B are unsequenced.

- [intro.execution] p8

What this means for our example is:

  • asymmeric - if x = 1 is sequenced before x = 5, then x = 5 cannot be sequenced before x = 1
  • transitive - if x = 1 is sequenced before x = 5, and x = 5 is sequenced before std::println("{}", x), then x = 1 is sequenced before std::println("{}", x)
  • irreflexive (not explicitly stated, but implied) - x = 5 cannot be sequenced before itself

Value computations see visible side effects

So far, we know that x = 1 is sequenced before x = 5, and due to transitivity, this is also sequenced before std::println("{}", x). Neither of these side effects may take place before the value computation, but it is not yet clear whether 1, 5 or some other value gets printed, i.e. which of these side effects is visible.

A visible side effect A on a scalar object or bit-field M with respect to a value computation B of M satisfies the conditions:

  • A happens before B and
  • there is no other side effect X to M such that A happens before
  • X and X happens before B.

The value of a non-atomic scalar object or bit-field M, as determined by evaluation B, shall be the value stored by the visible side effect A.

- [intro.races] p13

x = 1 happens before x = 5, because it is sequenced before x = 5 (more about happens before later). x = 1 and x = 5 both happen before std::println("{}", x), making both of them visible according to the first bullet. However, x = 1 happens before x = 5, which only makes x = 5 a visible side effect according to the second bullet.

As a result, the value of x in std::println("{}", x) must be 5, because storing 5 is the (one and only) visible side effect.

Conclusion

While the three-line example seems simple at first, there is a large amount of rules governing what happens. These rules are fairly intuitive, and mostly boil down to what memory operation is executed first. We can reason about our code by thinking of everything as a sequential execution from top to bottom in our program.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment