首页 > 解决方案 > 虚拟表创建线程安全吗?

问题描述

请让我开始,我知道从构造函数/析构函数中调用虚函数是一种不好的做法。然而,这样做的行为,尽管它可能会令人困惑或不是用户所期望的,但仍然是明确定义的。

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

现在,回到我真正的问题。如果用户从不同线程的构造函数中调用虚函数会发生什么。有比赛条件吗?它是未定义的吗?或者换句话说。由编译器设置 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:
?

标签: c++language-lawyerrace-conditionobject-lifetime

解决方案


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

[defns.dynamic.type]

glvalue 所指的最派生对象的类型 [示例:如果p静态类型为“指向类的指针”的指针指向派生自B的类的对象,则表达式的动态类型为“ ”。参考文献的处理方式类似。——结束示例]DB*pD

[intro.object] 6.7.2.1

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

[class.cdtor] 11.10.4.4

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

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

struct Base {
  Base() {
    print_type(this);
  }

  virtual ~Base() = default;

  static void print_type(Base* obj) {
      std::cout << "obj has type: " << typeid(*obj).name() << std::endl;
  }
};

struct Derived : public Base {
  Derived() {
    print_type(this);
  }
};

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

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

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

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

[intro.memory] ​​6.7.1.3

内存位置要么是标量类型的对象,要么是相邻位域的最大序列,所有位域的宽度均非零。[注意:语言的各种特性,例如引用和虚函数,可能涉及程序无法访问但由实现管理的额外内存位置。——尾注]

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

[介绍.races]

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

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

即使更新原子的,只要构造函数还没有完成,你仍然无法保证对象的状态,所以没有必要让它成为原子的。


更新

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

[..] 调用的函数是构造函数或析构函数类中的最终覆盖者,而不是在派生更多的类中覆盖它。

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

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


推荐阅读