Interlocked和内存屏障

13

我对以下代码示例有疑问(m_value不是易失性的,并且每个线程在单独的处理器上运行)

void Foo() // executed by thread #1, BEFORE Bar() is executed
{
   Interlocked.Exchange(ref m_value, 1);
}

bool Bar() // executed by thread #2, AFTER Foo() is executed
{
   return m_value == 1;
}

在Foo()中使用Interlocked.Exchange是否保证当执行Bar()时,我会看到值为"1"的结果?(即使该值已经存在于寄存器或缓存行中?)还是我需要在读取m_value的值之前放置一个内存屏障?

此外(与原始问题无关),声明一个易失成员并通过引用传递给InterlockedXX方法是否合法?(编译器警告有关传递易失性变量的引用,那么在这种情况下应该忽略警告吗?)

请注意,我不是在寻找"更好的方法",所以请不要发布那些建议完全不同的方法来完成任务的答案(例如:"使用锁代替"等)。这个问题出于纯粹的兴趣。

7个回答

5

内存屏障并不能帮助你。它们指定了内存操作之间的顺序,在本例中,每个线程只有一个内存操作,所以并不重要。一个典型的场景是在结构体字段中非原子地写入,然后使用内存屏障将结构体的地址发布给其他线程。屏障确保所有CPU在获取其地址之前看到对结构体成员的写入。

你真正需要的是原子操作,即InterlockedXXX函数或C#中的volatile变量。如果Bar中的读取是原子的,你可以保证编译器和CPU都不会进行任何优化,防止它从Foo中写入的值或Foo之后写入的值中读取,具体取决于哪个先执行。由于你说“知道”Foo的写入发生在Bar的读取之前,因此Bar总是返回true。

如果Bar中的读取不是原子的,则可能读取部分更新的值(即垃圾),或者缓存值(来自编译器或CPU),这两种情况都可能阻止Bar返回应该返回的true。

大多数现代CPU保证字对齐读取是原子的,因此真正的技巧在于你必须告诉编译器读取是原子的。


4
通常内存栅使用的模式与临界区实现相同,但被拆分成生产者和消费者的一对。例如,您的临界区实现通常是以下形式:
while (!pShared->lock.testAndSet_Acquire()) ;
// (此循环应包括所有正常的临界区内容,如
// 自旋、浪费、
// pause() 指令以及最后的放弃并阻塞资源
// 直到锁定可用。)
// 访问共享内存。 pShared->foo = 1 v = pShared->goo pShared->lock.clear_Release()
上面的 Acquire 内存栅确保在成功修改锁之前可能已经开始的任何加载(pShared->goo)都会被取消,以便在必要时重新启动。
Release 内存栅确保 goo 到变量 v 的加载在保护共享内存的锁清除之前完成。
在典型的生产者和消费者原子标志场景中,您有一个类似的模式(通过示例很难确定是否是这样,但应该可以说明这个想法)。
假设您的生产者使用原子变量指示某些其他状态已准备好使用。你需要像这样的东西:
pShared->goo = 14

pShared->atomic.setBit_Release()
如果生产者没有“写”栅,那么您无法保证硬件不会在 goo 存储通过 CPU 存储队列并上升到可见的内存层次结构之前到达原子存储。即使您有一个确保编译器按照您想要的方式排序的机制。
在消费者中:
if ( pShared->atomic.compareAndSwap_Acquire(1,1) )
{
   v = pShared->goo 
}
如果没有“读”栅,您将不知道硬件是否在完成原子访问之前已经获取了 goo。原子(即:使用 Interlocked 函数操作的内存,如 lock cmpxchg),仅针对其本身而不是其他内存是“原子”的。
现在,必须提到的剩余事项是,栅构造高度不可移植。您的编译器可能为大多数原子操作方法提供 _acquire 和 _release 变体,并且这些是您将使用它们的方式。根据您使用的平台(即:ia32),这些很可能就是没有 _acquire() 或 _release() 后缀时得到的内容。在 ia64 平台上(实际上除了 HP 以外已经死亡,在 HP 上仍然略有动静),大多数加载和存储指令(包括原子指令,如 cmpxchg)都有 .acq 和 .rel 指令修饰符。PowerPC 有专门的指令(isync 和 lwsync 分别提供读和写栅)。

现在,说了这么多,你真的有充足的理由选择这条路吗?正确地完成这一切可能非常困难。准备好在代码审查中面对大量的自我怀疑和不安,并确保进行大量的高并发测试,包括各种随机时间场景。除非你有非常非常充分的理由避免使用关键部分,否则请使用关键部分,并且不要自己编写该关键部分。


1
非常好的答案,谢谢(仅出于兴趣...多线程和无锁编程是非常有趣的话题,所以我正在探索这个领域...[顺便问一下,你有关于这些主题的任何好书推荐吗?[除了Joe Duffy的书之外..]) - unknown
我其实不知道Joe Duffy的书...我学习这个主题的所有东西都是"在工作中"学到的。由于最初编写了我们产品的无锁读写互斥体实现和我们产品原子接口的所有内联汇编(在编译器为此提供很好的内部函数之前),因此我经常收到关于该主题的问题。我能参考的最好的资料就是这里:http://sites.google.com/site/peeterjoot/math2009/atomic.pdf......这只是我从未真正写过的一篇有关该主题的论文的参考文献:) - Peeter Joot
那个 URL 现在不只有链接,但我不能保证内容连贯。 - Peeter Joot

2
我不是完全确定,但我认为Interlocked.Exchange将使用Windows API的InterlockedExchange函数,该函数提供完整的内存屏障。
引用: 此函数生成一个完整的内存屏障(或栅栏),以确保内存操作按顺序完成。

当值被写入时,内存屏障是否在线程#1中使用并不重要,但在线程#2读取值时没有使用? - unknown
1
很重要 - 你需要在读取线程上设置屏障,否则读取线程可能会在读取m_value之前重新排序其他读取。例如: if (m_value) { Foo foo = important_shared_data; }如果您不希望提前读取important_shared_data,则需要在m_value上设置读取/获取屏障(并在写入时释放)。如果您没有其他依赖于m_value的数据,则可能根本不需要屏障 - 但是有多少数据不依赖于其他事物呢?也就是说,m_value是否无用? - tony

1

互锁交换操作保证了内存屏障。

以下同步函数使用适当的屏障以确保内存排序:

  • 进入或离开临界区的函数

  • 信号同步对象的函数

  • 等待函数

  • 互锁函数

(来源:link)

但是对于寄存器变量,你就没那么幸运了。如果 m_value 在 Bar 中是一个寄存器变量,你将看不到对 m_value 的更改。因此,应将共享变量声明为“volatile”。


这是来自其他来源的块引用吗? 如果是,引用会很有好处。 - Heath Hunnicutt
链接已添加。已从本地MSDN复制。 - Christopher
所以,你是说你的读取操作需要一个内存屏障。如果你使用interlocked进行更新并在循环中的读取操作前加上Thread.MemoryBarrier(),那就足够了,对吗? - binki

1
如果 m_value 没有标记为 volatile,那么就没有理由认为在 Bar 中读取的值是有序的。编译器优化、缓存或其他因素可能会重新排列读写操作。只有在正确使用围栏内存引用的生态系统中使用交错交换才有帮助。这就是将字段标记为 volatile 的全部意义。.Net 内存模型并不像一些人所期望的那样直截了当。

将m_value标记为volatile并通过引用传递给Interlocked方法是否可接受?(尽管编译器会发出警告..但在这种情况下似乎毫无意义) - unknown
是的,在这种情况下忽略编译器警告是安全的。将变量(通过引用)传递给除了Interlocked方法之外的任何方法都会有问题,但对于Interlocked方法来说,这是可以的。 - Jeffrey L Whitledge
1
如果变量被标记为volatile,在C#中(而不是C++),编译器会根据需要添加获取/释放屏障,因此通常不需要使用InterLocked(即Interlocked提供完整的屏障,而volatile在读取时提供获取,在写入时提供释放。这通常已经足够了)。 - tony
2
Volatile不是Interlocked.Exchange()的替代品,因为它不能确保读写操作的原子序列。Volatile只能确保操作不会被重新排序。 - Jeffrey L Whitledge
由于.NET保证引用读/写是原子的,如果您不需要读取Interlocked.Exchange()的返回值,那么volatile就足够了吗? Interlocked之所以有用,仅因为它使您能够检查先前的值或使写入有条件而无需使用lock - binki

0

Interlocked.Exchange() 应该保证值被正确地刷新到所有的 CPU - 它提供了自己的内存屏障。

我很惊讶编译器对将 volatile 变量传递给 Interlocked.Exchange() 报错 - 事实上,你使用 Interlocked.Exchange() 几乎就要求使用 volatile 变量。

你可能会遇到的问题是,如果编译器对 Bar() 进行了大量优化,并意识到没有任何东西改变了 m_value 的值,它可能会优化掉你的检查。这就是 volatile 关键字的作用 - 它会提示编译器该变量可能在优化器的视野之外被修改。


但是这个屏障是在赋值之前/之后放置的呢?(还是两者都有?) - unknown
假设它发生“与”分配。 对于读取(获取),读取将添加到读取队列中,然后CPU等待读取队列被刷新,然后再添加其他内容。 这样,后续读取(如代码中所列)不会在重要的m_value读取之前重新排序(在读取队列内)。 对于写入,首先刷新写入队列,然后排队写入m_value。这样,写入不会在写入m_value之后发生。 对于完整的屏障,两个队列都被刷新。 请注意,重要的部分是值相对于其他值的排序方式... - tony
1
@Aaron:关于“flushed to all CPUs”,仅单侧的屏障(即来自一个CPU)是不够的。读取的CPU需要有屏障以防止读取被重新排序。 - tony

0

如果您不告诉编译器或运行时,在 Bar() 之前读取 m_value,它可以并且可能会缓存 m_value 的值,并简单地使用缓存的值。如果您想确保它看到 m_value 的“最新”版本,请插入 Thread.MemoryBarrier() 或使用 Thread.VolatileRead(ref m_value)。后者比完整的内存屏障要便宜。

理想情况下,您可以插入 ReadBarrier,但 CLR 似乎不直接支持它。

编辑:另一种思考方式是,实际上有两种内存屏障:编译器内存屏障,告诉编译器如何排序读写操作和 CPU 内存屏障,告诉 CPU 如何排序读写操作。Interlocked 函数使用 CPU 内存屏障。即使编译器将它们视为编译器内存屏障,对于这种特定情况,Bar() 可能已经被单独编译,并且不知道其他使用 m_value 的用途,这需要编译器内存屏障。


为什么Thread.VolatileRead更便宜?根据Reflector,它仍然使用完整的fence(实际上调用MemoryBarrier) - unknown
我还没有查看实现,但根据文档(例如http://msdn.microsoft.com/en-us/library/aa645755(VS.71).aspx),VolatileRead只执行AcquireBarrier(即“半屏障”),而Interlocked和/或MemoryBarrier执行完整的屏障。因此,VolatileRead可能更便宜。然而,在一些/大多数Intel平台上,所有屏障都被实现为完整的屏障。 - tony
但是,处理器屏障和编译器屏障之间有区别。在这种特定情况下,您至少需要一个编译器屏障。 - MSN

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