Gil*_*ils 12 c++ race-condition object-lifetime language-lawyer
请让我开始,我知道从构造函数/析构函数中调用虚函数是一种不好的做法。然而,这样做的行为,虽然它可能令人困惑或不是用户所期望的,但仍然是明确定义的。
struct Base
{
Base()
{
Foo();
}
virtual ~Base() = default;
virtual void Foo() const
{
std::cout << "Base" << std::endl;
}
};
struct Derived : public Base
{
virtual void Foo() const
{
std::cout << "Derived" << std::endl;
}
};
int main(int argc, char** argv)
{
Base base;
Derived derived;
return 0;
}
Output:
Base
Base
Run Code Online (Sandbox Code Playgroud)
现在,回到我真正的问题。如果用户从不同线程的构造函数中调用虚函数会发生什么。有竞争条件吗?它是未定义的吗?或者换句话说。编译器设置 vtable 是线程安全的吗?
例子:
struct Base
{
Base() :
future_(std::async(std::launch::async, [this] { Foo(); }))
{
}
virtual ~Base() = default;
virtual void Foo() const
{
std::cout << "Base" << std::endl;
}
std::future<void> future_;
};
struct Derived : public Base
{
virtual void Foo() const
{
std::cout << "Derived" << std::endl;
}
};
int main(int argc, char** argv)
{
Base base;
Derived derived;
return 0;
}
Output:
?
Run Code Online (Sandbox Code Playgroud)
首先,摘录一些与此相关的标准:
\n\n\n\n\n\n\n\n\n泛左值引用的最派生对象的类型\n [示例:如果
\np静态类型为“指向类的指针B”的指针指向派生自 的类 的对象D,则B表达式的动态类型*p为“D”。参考文献的处理方式类似。\xe2\x80\x94结束示例]
\n\n\n\n\n[..] 对象有类型。有些对象是多态的;该实现生成与每个此类对象关联的信息,从而可以在程序执行期间确定该对象的类型。
\n
\n\n\n成员函数,包括虚函数,可以在构造或销毁期间调用。当从构造函数或析构函数直接或间接调用虚拟函数时,包括在类的非静态数据成员的构造或销毁期间,并且调用适用的对象是该对象(称为 x )在构造或销毁时,所调用的函数是构造函数或析构函数的类中的最终重写器,而不是在更派生的类中重写它的函数。[..]
\n
正如您所写,它清楚地定义了构造函数/析构函数中的虚拟函数调用如何工作 - 它们取决于对象的动态类型以及与对象关联的动态类型信息,并且该信息在执行过程中发生变化。使用哪种指针来“查看对象”并不相关。考虑这个例子:
struct Base {\n Base() {\n print_type(this);\n }\n\n virtual ~Base() = default;\n\n static void print_type(Base* obj) {\n std::cout << "obj has type: " << typeid(*obj).name() << std::endl;\n }\n};\n\nstruct Derived : public Base {\n Derived() {\n print_type(this);\n }\n};\nRun Code Online (Sandbox Code Playgroud)\n\nprint_type总是收到一个指向 的指针Base,但是当您创建 的实例时,Derived您将看到两行 - 一行带有“Base”,另一行带有“Derived”。动态类型是在构造函数的最开始设置的,因此您可以调用虚拟函数作为成员初始化的一部分。
没有指定如何或在何处存储此信息,但它与对象本身相关联。
\n\n\n\n\n[..]该实现生成与每个此类对象关联的信息[..]
\n
为了更改动态类型,必须更新此信息。这可能是编译器引入的一些数据,但对该数据的操作仍然包含在内存模型中:
\n\n\n\n\n\n\n内存位置可以是标量类型的对象,也可以是具有非零宽度的相邻位\xef\xac\x81字段的最大序列。[注意:该语言的各种功能(例如引用和虚拟函数)可能涉及程序无法访问但由实现管理的其他内存位置。\xe2\x80\x94 尾注]
\n
因此,与对象相关的信息被存储和更新在某个内存位置。但这是发生数据竞争的情况:
\n\n\n\n\n\n\n[..]
\n
\n 如果两个表达式求值之一修改内存位置,而另一个表达式求值读取或修改同一内存位置,则两个表达式求值会发生冲突。
\n [..]
\n 如果程序的执行包含两个潜在并发的冲突操作,且至少其中一个操作不是原子操作,并且两者都发生在另一个操作之前,则该程序的执行包含数据争用 [..]
动态类型的更新不是原子的,并且由于没有其他同步可以强制执行先发生顺序,因此这是一个数据竞争,因此是 UB。
\n\n即使更新是原子的,只要构造函数尚未完成,您仍然无法保证对象的状态,因此没有必要使其成为原子的。
\n\n更新
\n\n从概念上讲,感觉对象在构建和销毁过程中呈现出不同的类型。然而,@LanguageLawyer 向我指出,对象的动态类型(更准确地说是引用该对象的泛左值)对应于最派生的类型,并且该类型是明确定义的并且不会改变。[class.cdtor]还包含有关此细节的提示:
\n\n\n\n\n[..] 调用的函数是构造函数或析构函数的类中的最终重写器,而不是在更派生的类中重写它的函数。
\n
因此,即使虚拟函数调用和 typeid 运算符的行为被定义为对象采用不同的类型,但实际上情况并非如此。
\n\n也就是说,为了实现指定的行为,必须更改对象状态中的某些内容(或至少与该对象关联的某些信息)。正如[intro.memory]中所指出的,这些额外的内存位置确实是内存模型的主题。所以我仍然坚持我最初的评估,即这是一场数据竞赛。
\n