12

我对以下代码示例有疑问(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的值之前放置一个内存屏障?

另外(与原始问题无关),声明一个 volatile 成员并通过引用InterlockedXX方法传递它是否合法?(编译器警告通过引用传递 volatile,所以在这种情况下我应该忽略警告吗?)

请注意,我不是在寻找“更好的做事方式”,所以请不要发布建议完全替代做事方式的答案(“改用锁”等),这个问题纯粹出于兴趣..

4

7 回答 7

5

记忆障碍对你没有特别的帮助。它们指定内存操作之间的顺序,在这种情况下,每个线程只有一个内存操作,所以没关系。一个典型的场景是非原子地写入结构中的字段,一个内存屏障,然后将结构的地址发布到其他线程。屏障保证所有 CPU 在获得结构成员的地址之前都可以看到对结构成员的写入。

你真正需要的是原子操作,即。InterlockedXXX 函数,或 C# 中的 volatile 变量。如果 Bar 中的读取是原子的,则您可以保证编译器和 cpu 都不会进行任何优化来阻止它在 Foo 中写入之前或在 Foo 中写入之后读取值,具体取决于哪个先执行。既然您说您“知道” Foo 的写入发生在 Bar 的读取之前,那么 Bar 将始终返回 true。

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

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

于 2009-11-18T19:13:30.853 回答
4

内存屏障使用的通常模式与您在关键部分的实现中所使用的模式相匹配,但为生产者和消费者分成对。例如,您的关键部分实现通常采用以下形式:

而 (!pShared->lock.testAndSet_Acquire()) ;
//(这个循环应该包括所有正常的临界区的东西,比如
// 旋转,浪费,
// pause() 指令,以及对资源的last-resort-give-up-and-blocking
// 直到锁可用。)

// 访问共享内存。

pShared->foo = 1
v = pShared-> goo

pShared->lock.clear_Release()

上面的获取内存屏障确保在成功修改锁之前可能已经启动的任何加载(pShared->goo)都被丢弃,必要时重新启动。

释放内存屏障确保在清除保护共享内存的锁字之前完成从 goo 到(本地说)变量 v 的加载。

您在典型的生产者和消费者原子标志场景中有类似的模式(您的样本很难判断这是否是您正在做的事情,但应该说明这个想法)。

假设您的生产者使用了一个原子变量来指示其他一些状态已准备好使用。你会想要这样的东西:

pShared->goo = 14

pShared->atomic.setBit_Release()

如果在生产者中没有“写入”屏障,则无法保证硬件不会在 goo 存储通过 cpu 存储队列并向上通过可见的内存层次结构之前到达原子存储(即使你有一种机制可以确保编译器按照你想要的方式排序)。

在消费者

if ( pShared->atomic.compareAndSwap_Acquire(1,1) )
{
   v = pShared->goo
}

如果没有“读取”屏障,您将不会知道在原子访问完成之前硬件还没有为您获取粘性。原子(即:使用互锁函数操作的内存,执行诸如 lock cmpxchg 之类的操作)仅相对于自身而言是“原子的”,而不是其他内存。

现在,必须提到的剩下的事情是屏障构造是高度不可移植的。您的编译器可能为大多数原子操作方法提供了 _acquire 和 _release 变体,这些是您可以使用它们的各种方式。根据您使用的平台(即:ia32),这些很可能正是没有 _acquire() 或 _release() 后缀的情况。这很重要的平台是 ia64(实际上已经死了,除了在 HP 上仍然轻微抽搐)和 powerpc。ia64 在大多数加载和存储指令(包括像 cmpxchg 这样的原子指令)上都有 .acq 和 .rel 指令修饰符。powerpc 对此有单独的说明(isync 和 lwsync 分别为您提供读写障碍)。

现在。说了这么多。你真的有充分的理由走这条路吗?正确地做这一切可能非常困难。为代码审查中的许多自我怀疑和不安全感做好准备,并确保您有大量的高并发测试,以及各种随机时序场景。除非您有充分的理由避免使用关键部分,否则不要自己编写该关键部分。

于 2009-11-19T04:43:11.260 回答
2

我不完全确定,但我认为 Interlocked.Exchange 将使用Windows API 的 InterlockedExchange 函数,它提供了一个完整的内存屏障。

该函数生成一个完整的内存屏障(或栅栏),以确保内存操作按顺序完成。

于 2009-11-18T19:20:09.563 回答
1

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

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

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

  • 信号同步对象的函数

  • 等待函数

  • 联锁功能

(来源:链接

但是你对寄存器变量不走运。如果 m_value 在 Bar 中的寄存器中,您将看不到 m_value 的更改。因此,您应该将共享变量声明为“volatile”。

于 2009-11-18T18:38:33.467 回答
1

如果m_value未标记为volatile,则没有理由认为读入的值Bar被隔离了。编译器优化、缓存或其他因素可能会重新排序读取和写入。互锁交换仅在用于适当隔离的内存引用的生态系统中时才有用。这就是标记字段的全部意义volatile。.Net 内存模型并不像某些人预期的那么简单。

于 2009-11-19T04:55:18.427 回答
0

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

我很惊讶编译器抱怨将 volatile 传递给 Interlocked.Exchange() - 您使用 Interlocked.Exchange() 的事实几乎应该强制使用 volatile 变量。

可能会看到的问题是,如果编译器对 Bar() 进行了一些重度优化并意识到没有任何改变 m_value 的值,它可以优化您的检查。这就是 volatile 关键字的作用——它会提示编译器该变量可能会在优化器的视图之外被更改。

于 2009-11-18T18:33:27.130 回答
0

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

理想情况下,您可以加入 ReadBarrier,但 CLR 似乎并不直接支持这一点。

编辑:另一种思考方式是,实际上有两种内存屏障:编译器内存屏障告诉编译器如何排序读取和写入,以及 CPU 内存屏障告诉 CPU 如何排序读取和写入。这些Interlocked函数使用 CPU 内存屏障。即使编译器将它们视为编译器内存屏障,它仍然无关紧要,因为在这种特定情况下,Bar()可能已经单独编译并且不知道其他用途m_value需要编译器内存屏障。

于 2009-11-18T22:18:28.653 回答