Before you go further, a note: this is purely a language lawyer question. I wish to get answers based on standard quotes. I am not looking for advice on writing C++ code. Please answer as if I was a compiler writer.
During construction of an object with only exclusive subobjects (#), notably those only non virtual bases (also those with a virtual base class named only once), the dynamic type of an lvalue referring to a base class subobject "increases": it goes from type of the base to type of the class of constructor running.
(#) A subobject is exclusive when it is a direct subobject of exactly one other object (which may be another subobject or a complete object). A member and a non virtual base are always exclusive.
During destruction, the type decreases (until the end of the body of the destructor of that subobject, where the subobject is gone and has no dynamic type anymore).
[During construction of an object with shared base class subobjects (that is in a class with distinct base subobjects with at least a virtual base), the dynamic type of a base subobject can "disappear" temporarily. I'm do not wish to discuss such classes here.]
The real question is: What happens if the dynamic type of the object is increased in another thread?
The title of the question, which is standard C++ question, is expressed using a non standard term (vptr), which may look contradicting. The reasons are:
- There is no requirement that polymorphism is implemented in term of vptr, but it (almost?) always is. The one (or many) vptr in an object represent the dynamic type of a polymorphic object.
- Data races are defined in term of read/write operations to a memory location.
- The standard text often uses non standard elements "for exposition only" to define standard features. (So, why not use the vptr "for exposition only"?)
The standard does not define the behavior of polymorphic objects (*) directly as a function of their dynamic type; the standard specifies which expressions are allowed during the so-called "lifetime" (after the constructor has completed), inside the body of the constructor of the most derived type (exactly the same expressions are allowed with the same semantic), also inside the base class subobject constructors...
(*) Dynamic behavior of polymorphic or dynamic objects(**) include: virtual calls, derived to base conversions, down casts (static_cast
or dynamic_cast
), typeid
of a polymorphic object.
(**) A dynamic object is one such that its class uses the virtual keyword; its constructor is not trivial for that reason.
So the description says: After something has finished, as soon as something started, before something else, etc. some expression is valid and does such and such.
The specification of construction and destruction was written before threads were part of standard C++. So what was the change with the standardization of threads? There is one sentence with defines threading behavior (the normative part) [basic.life]/11:
In this subclause, “before” and “after” refer to the “happens before” relation ([intro.multithread]).
So it's clear that an object is seen as fully constructed iff there is an happen before relation between the completion of the invocation of the constructor and the use of the object, and also an happen before that use of the object and the invocation of the destructor (if it's invoked at all).
But it doesn't say what happens during the construction of derived classes, after a base class subobject has been constructed: obviously there is a race condition if any dynamic property is used for a polymorphic object under construction, but race conditions are not illegal.
[A race condition is a case of non-determinism, and any meaningful use of a mutex, condition variable, rwlocks, many uses of semaphores, many uses of other synchronisation devices, and all uses of atomic primitives introduce a race condition at least at the level of the modification order on the atomic object. Whether that low level non-determinism results on unpredictable high level behavior depends on the way the primitives are used.]
Then the standard draft goes on to say:
[ Note: Therefore, undefined behavior results if an object that is being constructed in one thread is referenced from another thread without adequate synchronization. — end note ]
Where is "adequate synchronization" defined?
Is the lack of "adequate synchronization" the moral equivalent of a regular data race: a data race on the vptr, or in standard speak, a data race on the dynamic type?
For simplicity, I wish to restrict the scope of the question to single inheritance, at least as a first step. (The standard is awfully confused about the construction of objects with multiple inheritance anyway.)
This is language lawyer question so I'm not interested in:
- whether using an object that is in the process of being constructed in another thread is advisable (it's probably not advisable);
- how to use synchronization to reliably fix that race condition;
- whether compiler vendors wish to support such a use case (they probably do not and will not);
- whether that could possibly work reliably in any real world implementation (it probably will not reliably work in non trivial cases with current implementation).
EDIT: The previous example, instead of illustrating the issue, was a distraction. It caused a very interesting but completely irrelevant discussion in the chat section.
Here is a cleaner example that will not cause the same issue:
atomic<Base1*> shared;
struct Base1 {
virtual void f() {}
};
struct Base2 : Base1 {
virtual void f() {}
Base2 () { shared = (Base1*)this; }
};
struct Der2 : Base2 {
virtual void f() {}
};
void use_shared() {
Base1 *p;
while (! (p = shared.get()));
p->f();
}
With the consumer/producer logic:
- Thread A:
new Der2;
- Thread B:
use_shared();
For reference, original example:
atomic<Base*> shared;
struct Base {
virtual void f() {}
Base () { shared = this; }
};
struct Der : Base {
virtual void f() {}
};
void use_shared() {
Base *p;
while (! (p = shared.get()));
p->f();
}
Consumer/producer logic:
- Thread A:
new Der;
- Thread B:
use_shared();
It wasn't clear that this
could be used by another thread during the execution of Base
constructor, which is an interesting issue but irrelevant to the issue of using a base class subobject while a derived constructor runs in another thread.
Additional information
For reference, the DR that "motivated" the current phrasing (although that explains nothing):