序列点是否会阻止代码在关键部分边界重新排序?

时间:2009-10-23 16:46:57

标签: c++ multithreading concurrency locking

假设有一个基于锁的代码,如下所示,其中互斥体用于防止不适当的并发读写

mutex.get() ; // get a lock.

T localVar = pSharedMem->v ; // read something
pSharedMem->w = blah ; // write something.
pSharedMem->z++ ;      // read and write something.

mutex.release() ; // release the lock.

如果假设生成的代码是按程序顺序创建的,则仍然需要适当的硬件内存屏障,如isync,lwsync,.acq,.rel。我假设这个问题是互斥实现负责这个部分,提供一个保证pSharedMem读取和写入都发生在“get”之后,“之前”发布()[但周围的读取和写入可以进入关键部分,因为我期望是互斥实现的标准]。我还假设在适当的情况下在互斥体实现中使用volatile访问,但是volatile不用于受互斥体保护的数据(理解为什么volatile似乎不是受互斥保护的数据的必要数据实际上是这个问题)。

我想了解阻止编译器移动关键区域之外的pSharedMem访问的原因。在C和C ++标准中,我看到有一个序列点的概念。我发现标准文档中的大部分序列点文本都是不可理解的,但如果我要猜测它是什么,那么声明代码不应该在有未知副作用的调用的点上重新排序。这是它的主旨吗?如果是这种情况,编译器在这里有什么样的优化自由度呢?

随着编译器进行棘手的优化,例如配置文件驱动的过程间内联(甚至跨文件边界),即使是未知副作用的概念也会变得模糊。

在这里以自包含的方式解释这个问题可能超出了一个简单问题的范围,因此我乐于指向引用(最好是在线并针对凡人程序员而不是编译器编写者和语言设计者)。

编辑:(回应Jalf的回复)

由于你提到的CPU重新排序问题,我提到了像lwsync和isync这样的内存屏障指令。我碰巧在与编译器工作者相同的实验室工作(至少对于我们的平台之一),并且已经与内在函数的实现者交谈过,我碰巧知道至少对于xlC编译器__isync()和__lwsync()(其余的原子内在函数)也是代码重新排序的障碍。在我们的自旋锁实现中,编译器可以看到这一点,因为我们的关键部分的这一部分是内联的。

但是,假设您没有使用自定义构建锁实现(就像我们碰巧那样,这可能不常见),并且只调用了一个通用接口,例如pthread_mutex_lock()。在那里,编译器不会通知原型。我从未见过它表明代码不起作用

pthread_mutex_lock( &m ) ;
pSharedMem->someNonVolatileVar++ ;
pthread_mutex_unlock( &m ) ;

pthread_mutex_lock( &m ) ;
pSharedMem->someNonVolatileVar++ ;
pthread_mutex_unlock( &m ) ;
除非将变量更改为volatile,否则

将不起作用。该增量将在每个背靠背代码块中具有加载/增量/存储序列,并且如果第一个增量的值在第二个寄存器中保留,则将无法正确运行。

pthread_mutex_lock()的未知副作用似乎可以保护这种背靠背增量示例不正常行为。

我在谈论自己的结论,在线程环境中这样的代码序列的语义并没有真正严格地涵盖在C或C ++语言规范中。

5 个答案:

答案 0 :(得分:11)

简而言之,只要C ++虚拟机上的可观察行为没有改变,就允许编译器根据需要重新排序或转换程序。 C ++标准没有线程概念,因此这个虚构的VM只运行一个线程。在这样一个假想的机器上,我们不必担心其他线程看到的内容。只要更改不会改变当前线程的结果,所有代码转换都是有效的,包括重新排序跨序列点的内存访问。

  

理解为什么volatile似乎不是受互斥保护的数据的要求数据实际上是这个问题的一部分

Volatile确保一件事,只有一件事:每次都会从内存中读取一个volatile变量的读取 - 编译器不会认为该值可以缓存在寄存器中。同样,写入将写入内存。编译器不会将它保留在寄存器中“暂时将其写入存储器”。

但这就是全部。当发生写入时,将执行写入,并且当发生读取时,将执行读取。但是,当进行此读/写操作时,它不能保证的任何内容。正如通常那样,编译器可以按照它认为合适的方式对操作进行重新排序(只要它不会改变当前线程中的可观察行为,即虚构的C ++ CPU所知道的行为)。所以挥发性并不能真正解决问题。另一方面,它提供了我们真正需要的保证。我们不需要立即写出每个写入变量,我们只想确保在跨越边界之前将它们写出来。如果它们在那之前被缓存就好了 - 同样,一旦我们越过临界区边界,后续的写入可以再次缓存我们关心的所有 - 直到我们下次越过边界。因此,volatile提供了一个我们不需要的过强保证,但不提供我们 需要的那个(读/写不会被重新排序)

因此,要实现关键部分,我们需要依赖编译器魔术。我们必须告诉它“好吧,暂时忘记C ++标准,我不关心如果你严格遵循它会允许的优化。你不能重新排序任何内存跨越这个边界“。

关键部分通常通过特殊的编译器内在函数(基本上是编译器可以理解的特殊函数)实现,其中1)强制编译器避免重新排序该内部函数,以及2)使其发出必要的指令以获取CPU为了尊重相同的边界(因为CPU也重新排序指令,并且没有发出内存屏障指令,我们冒险CPU执行相同的重新排序,我们只是阻止编译器这样做)

答案 1 :(得分:4)

不,序列点不会阻止重新排列操作。控制优化的主要,最广泛的规则是强加于所谓的可观察行为的要求。根据定义,可观察行为是对volatile变量的读/写访问以及对库I / O函数的调用。这些事件必须以相同的顺序发生,并产生与“规范”执行程序中相同的结果。其他所有内容都可以由编译器完全自由地重新排列和优化,以任何方式认为合适,完全忽略序列点所施加的任何排序。

当然,大多数编译器都试图不做任何过度的重新排列。但是,近年来,您提到的问题已成为现代编译器的一个实际问题。许多实现提供了额外的特定于实现的机制,允许用户在进行优化重新排列时要求编译器不要跨越某些边界。

正如您所说,由于受保护的数据未声明为volatile,因此正式地说,访问权限可以移到受保护区域之外。如果您将数据声明为volatile,则应该可以防止这种情况发生(假设互斥锁访问权限也是volatile)。

答案 2 :(得分:2)

让我们看看下面的例子:

my_pthread_mutex_lock( &m ) ;
someNonVolatileGlobalVar++ ;
my_pthread_mutex_unlock( &m ) ;

函数my_pthread_mutex_lock()只是调用pthread_mutex_lock()。通过使用my_pthread_mutex_lock(),我确信编译器不知道它是一个同步函数。对于编译器来说,它只是一个函数,对我来说,它是一个可以轻松重新实现的同步函数。 因为someNonVolatileGlobalVar是全局的,所以我预计编译器不会在关键部分之外移动someNonVolatileGlobalVar ++。实际上,由于可观察行为,即使在单线程情况下,编译器也不知道之前的函数和该指令之后的函数是否正在修改全局变量。因此,为了使可观察行为保持正确,它必须在写入时保持执行顺序。 我希望pthread_mutex_lock()和pthread_mutex_unlock()也执行硬件内存屏障,以防止硬件将此指令移出临界区。

我是对的吗?

如果我写:

my_pthread_mutex_lock( &m ) ;
someNonVolatileGlobalVar1++ ;
someNonVolatileGlobalVar2++ ;
my_pthread_mutex_unlock( &m ) ;

我不知道这两个变量中的哪一个首先递增,但这通常不是问题。

现在,如果我写:

someGlobalPointer = &someNonVolatileLocalVar;
my_pthread_mutex_lock( &m ) ;
someNonVolatileLocalVar++ ;
my_pthread_mutex_unlock( &m ) ;

someLocalPointer = &someNonVolatileGlobalVar;
my_pthread_mutex_lock( &m ) ;
(*someLocalPointer)++ ;
my_pthread_mutex_unlock( &m ) ;

编译器是否正在做一个真正的开发人员期望的事情?

答案 3 :(得分:1)

例如,当';'时出现C / C ++序列点遇到了。此时必须发生之前所有操作的所有副作用。但是,我很确定通过“副作用”意味着操作是语言本身的一部分(比如z在'z ++'中递增)而不是在更低/更高级别的效果(就像操作系统实际上做的那样)关于操作完成后的内存管理,线程管理等。)

那回答你的问题吗?我的观点实际上只是AFAIK序列点的概念与你所指的副作用并没有任何关系。

HTH

答案 4 :(得分:0)

请参阅[linux-kernel] /Documentation/memory-barriers.txt

中的内容