【问题标题】:Is virtual table creation thread safe?虚拟表创建线程安全吗?
【发布时间】:2020-09-28 23:03:22
【问题描述】:

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

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:
?

【问题讨论】:

  • vtable 本身通常是一个静态结构,但是需要在对象内部设置 vtable 指针。但当然,这些都是不能依赖的实现细节。
  • 我认为没有理由认为这是线程安全的。
  • 由于异步函数的行为在构造函数结束时会发生变化,因此行为取决于该函数的时间与没有同步的构造函数的时间,这意味着必须存在竞争条件。这里肯定违反了语言规则,但我不确定是哪一个。它不能像对 vtable 指针的竞赛那样简单,因为语言无法识别该指针。这是一个实现细节。必须有更高的语言概念或要求被违反。
  • @Gils 首先,可能有许多其他原因导致 UB 不符合竞争条件。其次,vtable 和 vtable 指针的概念是实现细节,语言标准没有提及或要求。所以它不可能明确地保证与 vtables 相关的任何事情。关于 vtables 的保证必须从关于多态性的规则和其他规则中推断出来。
  • @Gils 这是你的规则:The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior. 除非你能找到一条规则说类对象的构造是原子的,否则你就违反了这条规则。

标签: c++ language-lawyer race-condition object-lifetime


【解决方案1】:

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

[defns.dynamic.type]

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

[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

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

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

[intro.races]

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

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

即使更新 是原子的,只要构造函数尚未完成,您仍然无法保证对象的状态,因此将其设为原子毫无意义。


更新

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

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

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

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

【讨论】:

  • 不错的答案,但仍有一些不太适合我的想法。如果存在 UB,客户端代码中究竟是什么原因导致的?如果没有 Derived 类,则一切都定义良好(因为动态类型没有变化)。从 Derived 实现者那里,也没有错,他只是从 Base 派生出来的……太天真了……所以如果我们想为这样的 UB 制定一个简单的规则,会不会是这样:“如果基类构造函数调用一个虚函数,从这样的类派生会导致 UB。这就是你的建议吗?
  • @Gils 我认为正确的观点是假设如果您的类型是多态的,则构造函数将在完成时隐式修改对象。所以异步函数总是写错,即使动态类型是Base 类型时它恰好可以工作。因为如果您使您的类具有多态性,则意味着有从它派生的意图,这将导致异步函数中断。我猜如果你输入了final 类型可能是个例外。那么我想你可以在该类型的构造过程中使用你的异步函数。
  • @FrançoisAndrieux 好吧...final 和“基类”有点矛盾。无论如何,我看不出有人这样做的任何理由。但是让我改进建议的语句:“多态类不应该在其构造函数/析构函数中异步调用虚成员函数,这样做可能会导致 UB”
  • @Gils 我相信根据目前所展示的内容,该声明是很好的建议和准确的。但在实践中,在我看来,从构造函数中启动引用this 的异步函数可能不是很好的设计,更不用说从成员初始化列表中了。即使它有效并且合法,它也很脆弱,并且有比正常情况更高的机会在未来引起难以发现的问题。如果您确实使用该设计,则需要保持警惕。
  • @Gils 对,您不会将final 添加到基类型中,但是任何其他多态类型仍然会遇到此问题,除非它们是叶类型(不是从)。在这些情况下,您可以使用final 来确保问题不会发生,以防止任何人继承它,从而触发问题。这就是我评论最后一部分的意思。
【解决方案2】:

我相信[class.base.init]/16

可以为正在构建的对象调用成员函数(包括虚成员函数)。同样,正在构造的对象可以是typeid 运算符或dynamic_­cast 的操作数。但是,如果这些操作在所有 mem-initializers 之前在 ctor-initializer 中执行(或在从 ctor-initializer 直接或间接调用的函数中) 对于基类已经完成,程序有未定义的行为。

应该回答这个问题。但是,它有缺陷。解决方法是

但是,如果这些操作在 ctor-initializer 中执行(或在直接或间接从 ctor-initializer 调用的函数中)before s> 不是在基类的所有mem-initializers都完成后,程序有未定义的行为。

目前,该段落说只有在基类的 mem-initializers 完成之前调用成员函数时,行为才被定义,但不包括您的情况:当调用既不会在基类初始化完成之前发生,也不会在调用之前发生基类初始化完成。

【讨论】:

  • 您是真正的律师。我仍在试图弄清楚你想说什么:)
  • 我非常努力地理解这一段,但我仍然不确定它是否完全匹配。如果我理解正确,[class.base.init]/16 说只要基类完成,就可以为正在构建的对象调用成员函数是合法的。从基地的角度来看,没有错。它没有基类,因此无法阻止异步调用 Foo。但随后出现了派生自 Base 的 Derived。 Derive 对 Base 的实现一无所知。怎么能只从Base派生出UB呢?
  • @Gils 它如何仅通过从 Base 派生来创建 UB? 这只是本段的字面意思 :-)。甚至struct B { B() { f(); } void f() {} }; struct D : B { D() : B() {} } d; 也是本段 POV 中的 UB。从D::D()ctor-initializer 调用B::B() 函数,其中B::f()Bmem-initializer 完成之前调用。
  • 在这里感谢您的帮助...老实说,我真的很努力。换句话说,这一段说您可以从构造函数(正在构建的对象)调用成员函数,但不允许从这样的对象继承。我做对了吗?
  • 该段落并未在包括B() { f(); } UB 在内的早期评论中作为示例。成员函数f 被“调用一个对象”,该对象的类型为B,并且是另一个D 类型的对象的子对象。它不是“调用”D 类型的对象。
【解决方案3】:

是的。

严格来说,没有。您可能可以通过一些努力构建一个恶意示例,该示例至少在形式上不是线程安全的。但在实践中它仍然是线程安全的。

除了试图完全恶意地令人讨厌,特别是在你的问题的上下文中,它绝对是

一个对象要么根本不存在,要么正在构造,要么完全构造。唯一有点尴尬的状态是正在构建的状态。
有时不鼓励在部分构造的对象上从构造函数调用虚函数,但这样做是完全合法的,只要知道其中的含义。也就是说,同一个对象在不同的​​时间是不同的东西。

至于你的具体例子:你正在做的是,你调用一个函数,你捕获this,当时它引用Base类型的对象。线程最终运行时将使用什么类型的对象是毫无疑问的,因为它必须使用 this 的那个副本,仅此而已。

该标准并未准确定义如何使用继承和 vtable 以及所有这些(但这只是未指定,而不是未定义的行为)。在实践中,它通常是一个指向正在更新的静态结构的指针,并且在大多数架构(所有合理架构,就此而言)无论如何这都是原子操作。
因此,即使需要某种原子性,它实际上也应该存在。

不过,它甚至都不需要。捕获发生在一个线程中,即当前正在构造对象的线程,并且对象可能处于的状态只有一个,而且毫无疑问是什么。

【讨论】:

  • 感谢您的回答。但我不确定我是否完全理解你的建议。您是否建议从另一个线程调用 Base::Foo ?而且不会有多态性?只是因为“this”指向 Base 时捕获了“this”指针?
  • 还有一种“被破坏”的状态,在此期间对象的生命周期已经结束,但在某些方面仍然可以被析构函数使用。
  • "...毫无疑问它是什么" 毫无疑问在捕获发生时,但事实并非如此关于这个问题的部分。当产生的线程取消引用捕获的指针时,存在很大的疑问。不知道当时可能有什么动态类型。
  • @FrançoisAndrieux:您确实意识到您正在复制this 指针,不是吗?你意识到它不像void* 之类的。对象在完成构造后可能具有的动态类型具有 不同 this 指针(因此是不同的类型)。生成的线程仍将具有“旧”指针,因此只能访问有效的基本子对象,并且对象类型将(通过该指针)是捕获时的类型。对谁来说什么是什么是绝对没有问题的。
  • @Damon 你忽略了这样一个事实,即捕获的指针指向一个在指针被捕获之后发生变化的对象。并且由于您捕获了一个指针,您将要访问 updated 对象 - 并且绝对清楚该对象当时处于哪个状态。即使您将其视为Base,同时底层对象已变为Derived这一事实确​​实很重要。假设您在Base 中有一个返回typeid(this) 的非虚函数。结果将取决于对象的状态,无论您将其视为Base* 还是Derived*
【解决方案4】:

发件人:https://isocpp.org/wiki/faq/strange-inheritance#calling-virtuals-from-ctors

您可以在构造函数中调用虚函数,但要小心。它可能无法达到您的预期。在构造函数中,虚拟调用机制被禁用,因为尚未发生从派生类的覆盖。对象是从基础构建的,“在派生之前基础”。

如果在异步函数被调用时“构造阶段”尚未完成,它将调用调用对象的函数。

编译器设置vtable,线程安全吗?

据我了解,它不是线程安全的,但除了分配器和初始化器之外,没有人应该修改该内存位置

【讨论】:

  • 在调用 async 时,Base 已经定义好了。从 Base 的角度来看,调用它的成员函数是合法的。 Base 不会修改 vtable。它实际上是修改它的 Derived 构造函数(由编译器隐式)
  • @interjay 当前正在初始化的类型,如果我们有animal->mammal->cat,并且我们在哺乳动物的构造函数中调用虚函数而不是调用哺乳动物的函数而不是猫的函数.
  • @DavidSchwartz vtable 不是标准的一部分,我没有明确修改它。它不是我的代码的一部分,它是编译器为实现多态性而生成的代码的一部分。我没有打破这条规则,我没有创造竞争条件……编译器做到了。 (很抱歉从其他答案中复制/粘贴我的 cmets)
  • @DavidSchwartz 这不是真的。 Foo 不会访问任何尚未初始化的东西(当然除了 vtable - 我无法控制它)。 AFAIK,访问已经从构造函数(甚至从不同的线程)构造的成员变量是完全合法的。
  • @DavidSchwartz 我认为有一个案例可以证明,这个构造函数在这里改变这个对象的方式是否可以与Foo 竞争。在调用std::async 之后,构造函数可见的唯一接触是future_ 成员,Foo 成员函数不读取该成员。我理解构造函数必然会修改对象的印象,但对于平凡的构造函数来说并不总是如此,在这种情况下,这不是要通过的障碍。为了进行比赛,更改需要发生在可能与衍生线程的访问冲突的地方。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-12-21
  • 1970-01-01
  • 2012-02-17
  • 1970-01-01
  • 2016-07-28
  • 2017-04-29
相关资源
最近更新 更多