虚拟表创建线程安全吗?

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)

mpo*_*ter 4

首先,摘录一些与此相关的标准:

\n\n

[defns.dynamic.type]

\n\n
\n

泛左值引用的最派生对象的类型\n [示例:如果p静态类型为“指向类的指针B”的指针指向派生自 的类 的对象D,则B表达式的动态类型*p为“ D”。参考文献的处理方式类似。\xe2\x80\x94结束示例]

\n
\n\n

[介绍对象] 6.7.2.1

\n\n
\n

[..] 对象有类型。有些对象是多态的;该实现生成与每个此类对象关联的信息,从而可以在程序执行期间确定该对象的类型。

\n
\n\n

[类.cdtor] 11.10.4.4

\n\n
\n

成员函数,包括虚函数,可以在构造或销毁期间调用。当从构造函数或析构函数直接或间接调用虚拟函数时,包括在类的非静态数据成员的构造或销毁期间,并且调用适用的对象是该对象(称为 x )在构造或销毁时,所调用的函数是构造函数或析构函数的类中的最终重写器,而不是在更派生的类中重写它的函数。[..]

\n
\n\n

正如您所写,它清楚地定义了构造函数/析构函数中的虚拟函数调用如何工作 - 它们取决于对象的动态类型以及与对象关联的动态类型信息,并且该信息在执行过程中发生变化。使用哪种指针来“查看对象”并不相关。考虑这个例子:

\n\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};\n
Run Code Online (Sandbox Code Playgroud)\n\n

print_type总是收到一个指向 的指针Base,但是当您创建 的实例时,Derived您将看到两行 - 一行带有“Base”,另一行带有“Derived”。动态类型是在构造函数的最开始设置的,因此您可以调用虚拟函数作为成员初始化的一部分。

\n\n

没有指定如何在何处存储此信息,但它与对象本身相关联。

\n\n
\n

[..]该实现生成与每个此类对象关联的信息[..]

\n
\n\n

为了更改动态类型,必须更新此信息。这可能是编译器引入的一些数据,但对该数据的操作仍然包含在内存模型中:

\n\n

[内存简介] 6.7.1.3

\n\n
\n

内存位置可以是标量类型的对象,也可以是具有非零宽度的相邻位\xef\xac\x81字段的最大序列。[注意:该语言的各种功能(例如引用和虚拟函数)可能涉及程序无法访问但由实现管理的其他内存位置。\xe2\x80\x94 尾注]

\n
\n\n

因此,与对象相关的信息被存储和更新在某个内存位置。但这是发生数据竞争的情况:

\n\n

[种族介绍]

\n\n
\n

[..]
\n 如果两个表达式求值之一修改内存位置,而另一个表达式求值读取或修改同一内存位置,则两个表达式求值会发生冲突。
\n [..]
\n 如果程序的执行包含两个潜在并发的冲突操作,且至少其中一个操作不是原子操作,并且两者都发生在另一个操作之前,则该程序的执行包含数据争用 [..]

\n
\n\n

动态类型的更新不是原子的,并且由于没有其他同步可以强制执行先发生顺序,因此这是一个数据竞争,因此是 UB。

\n\n

即使更新原子的,只要构造函数尚未完成,您仍然无法保证对象的状态,因此没有必要使其成为原子的。

\n\n
\n\n

更新

\n\n

从概念上讲,感觉对象在构建和销毁过程中呈现出不同的类型。然而,@LanguageLawyer 向我指出,对象的动态类型(更准确地说是引用该对象的泛左值)对应于最派生的类型,并且该类型是明确定义的并且不会改变[class.cdtor]还包含有关此细节的提示:

\n\n
\n

[..] 调用的函数是构造函数或析构函数的类中的最终重写器,而不是在更派生的类中重写它的函数。

\n
\n\n

因此,即使虚拟函数调用和 typeid 运算符的行为被定义对象采用不同的类型,但实际上情况并非如此。

\n\n

也就是说,为了实现指定的行为,必须更改对象状态中的某些内容(或至少与该对象关联的某些信息)。正如[intro.memory]​​中所指出的,这些额外的内存位置确实是内存模型的主题。所以我仍然坚持我最初的评估,即这是一场数据竞赛。

\n

  • @Gils我认为正确的观点是假设如果您的类型是多态的,构造函数将在完成时隐式修改对象。因此,即使动态类型是“Base”类型,异步函数恰好可以工作,但编写起来总是错误的。因为如果您将类设为多态,则意味着有意从它派生,这将导致异步函数中断。我想如果您将类型设置为“final”,则可能会出现例外情况。那么我想您可以在该类型的构造过程中使用异步函数。 (2认同)