不使用volatile、内存屏障和锁机制,如何保证执行顺序?

6
我有一个问题,关于编译器改变执行顺序的问题。我正在尝试通过用信号机制(通过信号量)替换临界区来提高多线程程序(C语言)的性能。
我需要保证这里的执行顺序,并且一直在进行一些研究。我看到了许多关于函数内部执行顺序的问题,但对于一个函数内部的函数没有太多的讨论。
基于“序列点”规则#4,以下代码块是否保证在进入func2之前先评估*p->a,因为func2将p作为输入(假设编译器遵守此处定义的调度点规则)?https://en.wikipedia.org/wiki/Sequence_point rules #4
func1 (struct *p) {
  p->a = x;  
  func2 (p);
}

func2 (struct *p) {
  p->b = y;
  releaseSemaphore(s);
}

非常关键的一点是,只有在设置了p->a之后才能设置p->b,因为另一个线程正在循环处理各种请求,并通过检查p->b是否设置来确定有效请求。释放信号量只会在任务处于空闲状态(并等待信号量)时触发任务,但如果它正在忙于处理其他请求,则稍后会检查p->b,我们无法保证仅在该线程处于空闲状态时调用func1


1
由于p不是原子性的,因此这句话:“只有在设置了p->a之后才设置p->b非常关键,因为另一个线程正在循环处理各种请求,并通过p->b是否设置来识别有效请求。”看起来很像数据竞争。 - 2501
如果您的编译器支持C11,您可以使用原子操作,例如:线程1在p->b上进行存储释放操作,而线程2在p->b上进行加载获取操作。 - ninjalj
2个回答

2
不,序列点排序不会跨越线程边界。这正是为什么我们需要首先保证内存排序的原因。对于执行代码的线程来说,序列点排序总是得到保证(除了as-if-rule)。任何其他线程可能以任意顺序观察该线程的写入。这意味着即使线程#1可以验证它按照某种顺序执行写入,线程#2仍然可能以不同的顺序观察到它们。这也是为什么仅使用volatile还不够的原因。
从技术上讲,这可以通过缓存来解释。线程#1的写入可能首先进入写缓冲区,在那里它们对线程#2仍然是不可见的。只有当写缓冲区被刷新回主内存后,它们才变得可见,并且硬件被允许在刷新之前重新排序写入。
请注意,平台允许重新排序写入并不意味着它会这样做。这是危险的部分。在一个平台上完美运行的代码在移植到另一个平台时可能突然出现问题。使用适当的内存排序保证可以确保代码在任何地方都能正常工作。

1
此外,“序列点”一词已被弃用,更现代和精确的术语是“先于顺序”。 - Erik Alapää
谢谢,缓存方面的解释对于阐述这一点非常有帮助。 - Dan Z

1

只要不通过其他翻译单元的函数调用,实现可以更改排序方式。

这种重新排序与多线程无关,即它在单线程和多线程程序中都会执行。

如果函数func2与func1在同一个翻译单元中,则执行可以像这样完成:

func1 (struct *p) 
{
    func2 (p);
    p->a = x;  
}

如果你想要防止这样的重排序,使用volatile。(请注意,这是为了防止上述提到的重排序,而不是其他同步目的。对于这些目的,您将需要使用原子基元。)


1(引自:ISO/IEC 9899:201x 5.1.2.3 程序执行 10)
或者,实现可能会在每个翻译单元内执行各种优化,以便当跨越翻译单元边界进行函数调用时,实际语义与抽象语义一致。

2(引自:ISO/IEC 9899:201x 6.7.3 类型限定符 7)
具有易变类型限定符的对象可能以未知于实现的方式被修改或具有其他未知的副作用。因此,任何引用这种对象的表达式都必须严格按照抽象机器中描述的规则进行评估,如5.1.2.3所述。此外,在每个序列点上,该对象中最后存储的值应与抽象机器所指定的值一致,除非被前面提到的未知因素修改。


感谢您对同一翻译单元内重新排序的见解。每天都能学到新东西,可惜我只能选择一个答案 :) - Dan Z
@DanZ 这种重新排序当然必须保持程序的可观察行为不变。在这种情况下可以这样做,因为编译器看到只有函数func2中修改了成员b,而根本没有访问成员a。如果不是这种情况,如果函数func2中也读取了成员a,那么对成员a的写入就不能移到该函数下面。 - 2501

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