跨同步与语句,是否可能发生复制省略?

13
在下面的示例中,如果我们暂时忽略互斥量,复制省略可能会消除两次对复制构造函数的调用。
user_type foo()
{
  unique_lock lock( global_mutex );
  return user_type(...);
}

user_type result = foo();

现在拷贝省略的规则没有提及线程,但我在想它是否应该跨越这些边界。在上述情况下,在逻辑抽象机跨线程发生的最终拷贝发生在释放互斥锁之后。然而,如果省略了这些拷贝,结果数据结构将在互斥锁内初始化,因此它在释放互斥锁之前就已经跨越线程。

我还没有想到一个具体的例子,说明拷贝省略如何真正导致竞态条件,但是内存序列中的干扰似乎可能会成为问题。有人可以明确地说它不会造成问题吗?还是有人可以举出一个确实会出问题的例子?


为了确保答案不仅仅针对特殊情况,需要注意的是,如果我有一个类似于new(&result)(foo())的语句,拷贝省略仍然允许发生。也就是说,result不需要是一个栈对象。user_type本身也可能与在线程之间共享的数据一起使用。


答案:我选择第一个回答作为最相关的讨论。基本上,由于标准规定省略可以发生,程序员只需在越过同步边界时小心处理。没有任何迹象表明这是一个有意的或意外的要求。我们仍然缺乏任何示例,显示可能出现的问题,因此它无论如何都不是问题。


我的大脑很难将“function”视为函数名称,纯粹是因为其他语言的影响。因此,我的编辑可能会有点引起争议;不过,希望你不介意。这是个好问题! - Lightness Races in Orbit
@LightnessRacesinOrbit 的争议在于你显然应该将函数称为 bar 而不是其他名称吗? - ta.speot.is
我还没有想到一个具体的例子,说明复制省略如何真正导致竞争条件。我也是这样认为的。问题在于,如果没有复制省略,析构函数两侧都会调用复制构造函数,因此在原则上使用或不使用锁来调用复制构造函数都是有效的。为了找出一个糟糕的情况,我们需要关注被复制对象的地址是否重要。例如,我们可以将&result传递给foo,并确保~unique_lock使用result的别名,以便我们的程序始终使用锁修改result - Steve Jessop
@EdA:你是不是意思是 new (*result)(foo())?鉴于 &result,我不明白它除了你所说的“堆栈对象”以外还可以是什么(实际上自动存储期与静态存储期的复杂性不容忽视)。 - Lightness Races in Orbit
我并不是指相同的结果对象,而是全局空间中的某个随机对象,或者从堆中分配的对象。 - edA-qa mort-ora-y
2个回答

5
线程与此无关,但锁的构造/析构函数顺序可能会影响您。通过查看代码执行的低级步骤(使用GCC选项-fno-elide-constructors),一次一个地进行,没有复制省略:

  1. 构造锁。
  2. 使用(...)参数构造临时用户类型。
  3. 使用步骤2中的值复制构造函数的临时返回值,类型为用户类型。
  4. 销毁步骤2中的临时对象。
  5. 销毁锁。
  6. 使用步骤3中的值复制构造用户类型结果。
  7. 销毁步骤3中的临时对象。
  8. 以后,销毁结果。

自然地,通过多个复制省略优化,它将仅仅是:

  1. 构造锁。
  2. 直接使用(...)构造result对象。
  3. 销毁锁。
  4. 以后,销毁result。

请注意,在两种情况下,具有(...)的user_type构造函数都受锁的保护。任何其他复制构造函数或析构函数调用可能不受保护。

思考:

我认为最可能引起问题的地方是在析构函数中。也就是说,如果您最初使用(...)构造的对象与其副本不同地处理任何共享资源,并且在析构函数中执行需要锁定的操作,则会出现问题。

自然而然地,这意味着您的对象首先设计得很糟糕,因为副本的行为与原始对象不同。

参考文献:

在C++11草案中,12.8.31(没有所有“移动”的类似措辞在C++98中:

当特定条件满足时,实现允许省略类对象的复制/移动构造,即使该对象的复制/移动构造函数和/或析构函数具有副作用。在这种情况下,实现将省略的复制/移动操作的源和目标视为引用同一对象的两种不同方式,并且该对象的销毁发生在那两个对象将被销毁的时间中较晚的时间点上,而不进行优化。这种称为复制省略的复制/移动操作可以在以下情况下执行(这些情况可以组合以消除多个副本):
1. 在具有类返回类型的函数中的return语句中,当表达式是非易失自动对象(函数或catch子句参数之外的其他非易失自动对象)的名称,并且具有与函数返回类型相同的cv-未限定类型时,可以通过直接将自动对象构造到函数的返回值中来省略复制/移动操作。
2. 一个函数或catch子句参数),其作用域不延伸到最内层try块的末尾(如果有),则可以通过将自动对象直接构造到异常对象中来省略从操作数到异常对象的复制/移动操作。
3. 当将尚未绑定到引用的临时类对象复制/移动到具有相同cv-未限定类型的类对象时,可以通过直接将临时对象构造到省略的复制/移动操作的目标中来省略复制/移动操作。
4. 当异常处理程序的异常声明声明与异常对象相同类型(除了cv-限定)的对象时,如果程序的含义除了执行由异常声明声明的对象的构造函数和析构函数外不会更改,则可以通过将异常声明视为对异常对象的别名来省略复制/移动操作。
在您的示例中,点1和点3合作以省略所有副本。

"destroy lock" 创建了一个内存屏障(应该是双向的)。直接构造到 "result" 会在该屏障之前更改变量,尽管逻辑代码只在屏障之后修改该变量。这似乎违反了同步顺序。 - edA-qa mort-ora-y
复制省略是否需要这样工作? 我认为答案是肯定的:“在这种情况下,实现将省略的复制/移动操作的源和目标视为只是指称同一对象的两种不同方式”,这意味着仍然适用于通常的排序规则。 - Flexo
内存栅栏与此无关。C++语言明确允许这种省略,无论构造函数/析构函数实际上做什么。如果这种省略破坏了您的代码,那么您只是有一个错误。 - rodrigo
@rodrigo,我愿意接受这一点(基本上必须这样做),但这是一个有意的特征吗?请注意,标准在其排序要求方面非常严格,允许省略来简单地绕过此问题,而不需要任何注释,这似乎很不寻常。 - edA-qa mort-ora-y
@edA-qamort-ora-y 这确实是一个不寻常的规则,因为它是“按原样”规则的例外,但它有很好的文档记录。请参见我更新答案中的参考资料。 - rodrigo

0

为确保答案不仅仅解决了特殊情况,请注意,如果我有像new (&result)( foo() )这样的语句,复制省略仍然是允许发生的(根据我的阅读)。也就是说,结果不需要是堆栈对象。user_type本身也可以使用在线程之间共享的数据。

问题在于:如果result是共享的,则即使没有省略,您也会遇到数据竞争。行为本来就是未定义的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接