-4

在进一步讨论之前,请注意:这纯粹是一个语言律师问题。我希望根据标准报价得到答案。我不是在寻找有关编写 C++ 代码的建议。请像我是编译器作者一样回答

在构造仅具有独占子对象 (#) 的对象期间,尤其是那些仅具有非虚拟基类的对象(也具有仅命名一次的虚拟基类的对象),引用基类子对象的左值的动态类型“增加”:它会从基类的类型到构造函数运行的类的类型。

(#)当一个子对象恰好是另一个对象(可能是另一个子对象或完整对象)的直接子对象时,它是独占的。成员和非虚拟基地总是互斥的。

在销毁期间,类型会减少(直到该子对象的析构函数主体的末尾,子对象消失并且不再具有动态类型)。

[在构建具有共享基类子对象的对象期间(即在具有至少一个虚拟基的不同基子对象的类中),基子对象的动态类型可以暂时“消失”。我不想在这里讨论这样的课程。]

真正的问题是:如果在另一个线程中增加对象的动态类型会发生什么?

问题的标题是标准 C++ question,使用非标准术语 (vptr) 表示,这可能看起来自相矛盾。原因是:

  • 没有要求根据 vptr 实现多态性,但它(几乎?)总是如此。对象中的一个(或多个)vptr 表示多态对象的动态类型。
  • 数据竞争是根据对内存位置的读/写操作定义的。
  • 标准文本通常使用“仅用于说明”的非标准元素来定义标准特征。(那么,为什么不使用 vptr “仅用于展示”?)

该标准没有将多态对象 (*) 的行为直接定义为动态类型的函数;该标准规定了在所谓的“生命周期”内(在构造函数完成之后)允许哪些表达式,在最派生类型的构造函数的主体内(完全相同的表达式允许具有相同的语义),也在基类子对象构造函数...

(*) 多态或动态对象的动态行为(**) 包括:多态对象的虚拟调用、派生到基本转换、向下转换 (static_castdynamic_cast) typeid

(**) 动态对象是其类使用 virtual 关键字的对象;由于这个原因,它的构造函数并不是微不足道的。

所以描述说:某事完成之后,一旦某事开始,其他事之前,等等,某个表达式是有效的并且会做某事。

构造和销毁的规范是在线程成为标准 C++ 的一部分之前编写的。那么线程标准化带来了什么变化呢?有一句话定义了线程行为(规范部分)[basic.life] /11

在本小节中,“之前”和“之后”指的是“发生在之前”关系([intro.multithread])。

所以很明显,如果在构造函数调用的完成和对象的使用之间存在发生之前的关系,并且在对象的使用和析构函数的调用之前发生关系,则对象被视为完全构造的(如果它被调用)。

但是它没有说明在构造派生类期间会发生什么,在构造了基类子对象之后:显然,如果任何动态属性用于正在构造的多态对象,则存在竞争条件,但竞争条件并非非法

[竞争条件是一种非确定性的情况,任何对互斥锁、条件变量、rwlocks 的有意义使用、信号量的多次使用、其他同步设备的多次使用以及原子原语的所有使用都会引入竞争条件,至少在原子对象的修改顺序级别。低级别的非确定性是否会导致不可预测的高级行为取决于原语的使用方式。]

然后标准草案继续说:

[ 注意:因此,如果在一个线程中构造的对象在没有充分同步的情况下从另一个线程引用,则会导致未定义的行为。——尾注]

“充分同步”在哪里定义?

缺乏“充分同步”是否相当于常规数据竞赛的道德等价物:vptr 上的数据竞赛,或者通俗地说,动态类型上的数据竞赛?

为简单起见,我希望将问题的范围限制为单一继承,至少作为第一步。(无论如何,该标准对具有多重继承的对象的构造感到非常困惑。)

这是语言律师问题,所以我不感兴趣:

  • 是否建议使用正在另一个线程中构建的对象(可能建议);
  • 如何使用同步来可靠地修复该竞争条件;
  • 编译器供应商是否希望支持这样的用例(他们可能不会也不会);
  • 这是否可能在任何现实世界的实现中可靠地工作(它可能不会在当前实现的非平凡情况下可靠地工作)。

编辑:前面的例子没有说明问题,而是分散了注意力。它在聊天部分引起了非常有趣但完全不相关的讨论。

这是一个更简洁的示例,不会导致相同的问题:

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();
}

使用消费者/生产者逻辑:

  • 线程 A:new Der2;
  • 线程 B:use_shared();

供参考,原始示例:

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();
}

消费者/生产者逻辑:

  • 线程 A:new Der;
  • 线程 B:use_shared();

尚不清楚在构造函数this执行期间是否可以被另一个线程使用Base,这是一个有趣的问题,但与派生构造函数在另一个线程中运行时使用基类子对象的问题无关。

附加信息

作为参考,“激发”当前措辞的 DR(尽管这没有解释任何内容):

核心语言缺陷报告 #710

4

1 回答 1

3

我对该标准的解读是存在数据竞争,因此存在未定义的行为,但该标准非常间接地解决了它。

[basic.life]/1类型对象的生命周期T开始于......它的初始化完成。

shared = this;执行时,Base对象的生命周期,更不用说Der,还没有开始。

[basic.life]/6在对象的生命周期开始之前,但在分配对象将占用的存储空间之后......任何表示对象将或曾经位于的存储位置地址的指针都可能是使用但仅限于有限的方式。对于正在构建或销毁的对象,请参阅[class.cdtor]。否则...... [t]如果......指针用于访问非静态数据成员或调用对象的非静态成员函数,则程序具有未定义的行为。

[basic.life]/11在本节中,“之前”和“之后”指的是“发生在之前”关系(4.7)。[注意:因此,如果在一个线程中构造的对象在没有充分同步的情况下从另一个线程引用,则会导致未定义的行为。——尾注]

因此, [basic.life]的默认位置是对对象方法的调用不会发生——在其初始化完成后会表现出未定义的行为。但是[class.cdtor]可能还有更多话要说。

[class.cdtor]/3成员函数,包括虚函数(13.3),可以在构造或销毁(15.6.2)期间调用。当从构造函数或析构函数直接或间接调用虚函数时...

因此,[class.cdtor]仅解决从构造函数直接或间接调用虚函数的情况(必须在构造函数本身运行的同一线程上)。在从另一个线程调用方法的情况下,如示例中所示,它是沉默的。我认为它的意思是[basic.life]控件,并且示例的行为是未定义的。

于 2018-06-30T21:21:16.200 回答