In the 4th edition of the C++ Programming Language book by Bjarne Stroustrup, in section 15.4.2, there is the following text:
Consider:
int x = 3; int y = sqrt(++x);
What could be the values of x and y? The obvious answer is "3 and 2!" Why? The initialization of a statically allocated object with a constant expression is done at link time, so x becomes 3. However, y’s initializer is not a constant expression (sqrt() is no constexpr), so y is not initialized until run time. However, the order of initialization of statically allocated objects in a single translation unit is well defined: they are initialized in definition order (§15.4.1). So, y becomes 2.
The flaw in this argument is that if multiple threads are used (§5.3.1, §42.2), each will do the run-time initialization. No mutual exclusion is implicitly provided to prevent a data race. Then, sqrt(++x) in one thread may happen before or after the other thread manages to increment x. So, the value of y may be sqrt(4) or sqrt(5).
I'm wondering, how can it actually happen to have multiple threads initializing a global variable? If I have a std::thread
global variable launching the thread before the main()
function, does this affect the initialization of the global variables?
I tried this:
#include <iostream>
#include <thread>
using namespace std;
extern int x;
extern double y;
struct Foo {
Foo() { cout << "Foo::Foo()" << '\n'; }
};
void threadFunc() {
cout << "x = " << x << '\n';
cout << "y = " << y << '\n';
}
thread t{threadFunc};
int x = 3;
double y = sqrt(double(++x));
Foo z;
int main() {
cout << "x = " << x << '\n';
cout << "y = " << y << '\n';
t.join();
return 0;
}
But I didn't manage to have a result different than 2 for y on multiple runs of the program.
Also, the constructor of the Foo
is always called only once.
For the test, I used MSVC Version 17.5.2.
As far as I can tell it can't happen that a variable is initialized twice and it is guaranteed that order of initialization in your example is strictly x
-> t
-> y
-> z
(in the sense of sequenced-before). That's because x
is constant-initialized, so it will be initialized before any dynamic initialization and t
, y
and z
all have ordered dynamic initialization (assuming the implementation doesn't choose to replace dynamic initialization with static initialization where possible) with definitions in the same translation unit in that order. I think the standard is pretty clear on that, at least since C++17 (see [basic.start.dynamic]/3.1.1).
"Sequenced-before" also implies that the initializations happen in the same thread. (That's only because all of your variables have ordered dynamic initialization and are defined in the same translation unit! If that wasn't the case 3.1.1 would not apply and you would not be guaranteed on which threads the initializations will happen.)
So the initialization of t
and y
will happen on the same thread and y
after t
. However the thread started by the initialization of t
will run in parallel to this thread.
Therefore you don't have any synchronization between the initialization of y
and accessing y
in cout << "y = " << y << '\n';
inside the thread. These two happen in parallel in two threads. So that is a data race causing undefined behavior.
For the same reason you also have no guarantee that y
's initialization won't write to x
while threadFunc
reads from it. This is another data race causing undefined behavior.
I think the claim "Then, sqrt(++x) in one thread may happen before or after the other thread manages to increment x" is not correct, at least in recent C++ versions. I don't see anything that would allow interleaving the individual evaluations of the expression on different threads, nor do I see anything that would allow initialization to be performed twice. The normal sequenced-before rules still apply.
Even in the C++11 standard, which has looser rules for dynamic initialization than referenced above, I don't see how that would apply. Maybe the book is basing this on an earlier draft for C++11. Before C++11 there was nothing about threads in the C++ standard and everything did depend on how the implementation would handle it. So it may have been common behavior at the time.
But as you can see above, it is still easy to cause data races regardless and the guidelines the book derives from the quoted section still apply and should be followed.