在vptr上进行数据竞赛是否明显非法?

时间:2018-06-30 19:16:31

标签: c++ constructor thread-safety language-lawyer vptr

在继续之前,请注意:这纯粹是语言律师的问题。我希望得到基于标准报价的答案。我不是在寻求有关编写C ++代码的建议。 请像我是编译器作家一样回答

在仅具有排他子对象(#)的对象的构造过程中,尤其是仅那些非虚拟基对象(也包括那些仅具有一次虚拟基类的对象),引用基类子对象的左值的动态类型“增加” :从基础类型到构造函数运行类的类型。

(#)当子对象是正好是另一个对象(可能是另一个子对象或完整对象)的直接子对象时,它是排他性的。成员和非虚拟库始终是互斥的。

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

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

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

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

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

该标准并未将多态对象(*)的行为直接定义为其动态类型的函数;该标准指定了在所谓的“生存期”(构造函数完成后)中允许使用的表达式,即派生类型最多的构造函数的内部(实际上,相同的表达式具有相同的语义),也可以在内部基类子对象构造函数...

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

(**)动态对象是这样的,它的类使用了virtual关键字;因此,它的构造函数并不简单。

因此描述中说:之后,某些事情已经完成,一旦开始,之前,其他事情,等等。某些表达式是有效的,并且如此。< / p>

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

  

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

因此很明显,将对象视为完全构造的 iff ,在构造函数调用完成与对象使用之间的关系之前发生了一件事情,在此之前发生了一件事情。对象的使用和析构函数的调用(如果有的话)。

但是它并没有说明在构造基类子对象之后,在派生类的构造过程中会发生什么:如果正在构造的多态对象使用任何动态属性,显然存在竞争条件,但是比赛条件不合法

[竞态条件是非确定性的情况,任何对互斥锁,条件变量,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(尽管什么也没解释):

Core Language Defect Report #710

1 个答案:

答案 0 :(得分:3)

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

  

[basic.life] / 1 类型T的对象的生存期是在其初始化完成后开始的。

执行shared = this;时,Base对象(更不用说Der)的生存期尚未开始。

  

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

     

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

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

  

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

因此, [class.cdtor] 仅解决了从构造函数直接或间接调用虚拟函数的情况(必须在构造函数本身运行的同一线程上)。如示例中那样,它在从另一个线程调用方法的情况下没有提及。我认为它是指 [basic.life] 控件,该示例的行为未定义。