内存障碍是否可以保证C#的新读取?

如果我们在C#中有以下代码:

int a = 0; int b = 0; void A() // runs in thread A { a = 1; Thread.MemoryBarrier(); Console.WriteLine(b); } void B() // runs in thread B { b = 1; Thread.MemoryBarrier(); Console.WriteLine(a); } 

MemoryBarriers确保写入指令在读取之前发生。 但是,是否可以保证在另一个线程上读取一个线程的写入? 换句话说,是否保证至少有一个线程打印1或两个线程都可以打印0

我知道已经存在几个与C#中的“ MemoryBarrier ”和MemoryBarrier相关的问题,就像这样和这个 。 但是,它们中的大多数都处理写入释放和读取 – 获取模式。 在这个问题中发布的代码,非常特定于在保持指令保持有序的情况下是否保证通过读取来查看写入。

不保证看到两个线程都写1 。 它只保证基于此规则的读/写操作顺序 :

执行当前线程的处理器不能重新排序指令,使得在调用 MemoryBarrier 之前的存储器访问MemoryBarrier调用之后的存储器访问之后执行。

所以这基本上意味着thread Athread A不会在屏障调用之前使用读取的变量b的值。 但是如果您的代码是这样的话,它仍会缓存该值:

 void A() // runs in thread A { a = 1; Thread.MemoryBarrier(); // b may be cached here // some work here // b is changed by other thread // old value of b is being written Console.WriteLine(b); } 

并行执行的竞争条件错误非常难以重现,因此我无法为您提供肯定会执行上述方案的代码,但我建议您对不同线程使用的变量使用volatile关键字 ,因为它完全按照你的意愿工作 – 给你一个变量的新读物:

 volatile int a = 0; volatile int b = 0; void A() // runs in thread A { a = 1; Thread.MemoryBarrier(); Console.WriteLine(b); } void B() // runs in thread B { b = 1; Thread.MemoryBarrier(); Console.WriteLine(a); } 

这取决于你所说的“新鲜”。 Thread.MemoryBarrier将强制通过从指定的内存位置加载变量来获取变量的第一次读取。 如果这就是你所说的“新鲜”,只不过是答案是肯定的。 大多数程序员操作的是更严格的定义,无论他们是否意识到这一点,这就是问题和混乱开始的地方。 请注意,通过volatile和其他类似机制的易失性读取不会在此定义下产生“新鲜”读取,但会在不同的定义下产生。 继续阅读以了解具体方法。

我将使用向下箭头↓表示易失性读数和向上箭头↑表示易失性写入。 把箭头想象成推开任何其他读写。 生成这些内存栅栏的代码可以自由移动,只要没有指令通过向下箭头向上并向下通过向上箭头。 但是,内存栅栏(箭头)在代码中最初声明它们的位置被锁定到位。 Thread.MemoryBarrier生成一个全栅栏屏障,因此它具有读取 – 获取和释放 – 写入语义。

 int a = 0; int b = 0; void A() // runs in thread A { register = 1 a = register ↑ // Thread.MemoryBarrier ↓ // Thread.MemoryBarrier register = b jump Console.WriteLine use register return Console.WriteLine } void B() // runs in thread B { register = 1 b = register ↑ // Thread.MemoryBarrier ↓ // Thread.MemoryBarrier register = a jump Console.WriteLine use register return Console.WriteLine } 

请记住,C#行在获得JIT编译和执行后实际上是多部分指令。 我试图在某种程度上说明这一点,但实际上Console.WriteLine的调用仍然比显示的要复杂得多,因此读取ab与它们的首次使用之间的时间相对来说可能是重要的。 因为Thread.MemoryBarrier生成一个获取栅栏,所以不允许读取浮动并通过调用。 因此,相对于Thread.MemoryBarrier调用,读取是“新鲜的”。 但是,相对于Console.WriteLine调用实际使用它时,它可能是“陈旧的”。

现在让我们考虑一下,如果我们用volatile关键字替换Thread.MemoryBarrier调用,你的代码Thread.MemoryBarrier

 volatile int a = 0; volatile int b = 0; void A() // runs in thread A { register = 1 ↑ // volatile write a = register register = b ↓ // volatile read jump Console.WriteLine use register return Console.WriteLine } void B() // runs in thread B { register = 1 ↑ // volatile write b = register register = a ↓ // volatile read jump Console.WriteLine use register return Console.WriteLine } 

你能发现变化吗? 如果你眨眼,那你就错过了。 比较两个代码块之间的箭头(内存栅栏)的排列。 在第一种情况( Thread.MemoryBarrier )中,不允许在存储器屏障之前的时间点发生读取。 但是,在第二种情况下( volatile ),读取可以无限期地冒泡(因为有向下箭头将它们推开)。 在这种情况下,可以做出一个合理的论证,即如果放在读取之前, Thread.MemoryBarrier可以产生“更新鲜”的读取而不是volatile解决方案。 但是,你仍然可以声称阅读是“新鲜的”吗? 不是真的,因为当它被Console.WriteLine使用时,它可能不再是最新值了。

那么你可能会问到使用volatile的重点是什么。 因为连续读取产生了获取栅栏语义,所以它确保后续读取产生比先前读取更新的值。 请考虑以下代码。

 volatile int a = 0; void A() { register = a; ↓ // volatile read Console.WriteLine(register); register = a; ↓ // volatile read Console.WriteLine(register); register = a; ↓ // volatile read Console.WriteLine(register); } 

密切关注这里可能发生的事情。 行register = a表示读取。 注意放置↓箭头的位置。 因为它是在读取之后放置的,所以没有任何东西阻止实际读取浮动。 它实际上可以在之前的Console.WriteLine调用之前浮动。 因此,在这种情况下,无法保证Console.WriteLine正在使用a的最新值。 但是,它保证使用比上次调用时更新的值。 简而言之,这是它的用处。 这就是为什么你看到很多无锁代码在while循环中旋转,确保先前读取的volatile变量等于当前读取,然后再假设它的预期操作成功。

我想在结论中提出几个要点。

  • Thread.MemoryBarrier将保证在它之后出现的读取将返回对于屏障的最新值。 但是,当您实际做出决定或使用该信息时,它可能不再是最新的价值。
  • volatile保证read将返回一个比先前读取的同一个变量更新的值。 它在任何时候都不能保证价值是最新的。
  • “新鲜”的含义需要明确定义,但可能因情况而异,开发人员与开发人员不同。 只要它可以被正式定义和表达,没有任何意义比任何其他更正确。
  • 这不是一个绝对的概念。 你会发现定义“新鲜”更有用的是相对于其他东西,比如生成内存屏障或先前的指令。 换句话说,“新鲜度”是一个相对的概念,就像爱因斯坦的狭义相对论中的速度与观察者的关系一样。

以上答案基本上是正确的。 但是,为您的问题提供更简洁的解释 – “是否保证至少有一个线程打印1?” – 是的,这对内存屏障保证了这一点。

考虑下面的表示,其中---代表一个记忆障碍。 指令可以向后或向前移动,但它们可能不会越过障碍。

如果在完全相同的时间调用AB方法,则可以得到两个1:

 | Thread A | Thread B | | | | | a = 1 | b = 1 | | ------------ | ------------ | | read b | read a | | | | 

然而,在可能性中,它们将被分开,给出0和1:

 | Thread A | Thread B | | | | | a = 1 | | | ------------ | | | read b | | | | | | | b = 1 | | | ------------ | | | read a | 

内存重新排序可能会导致其中一个变量的读取和/或写入操作相互移位,再次导致两个1:

 | Thread A | Thread B | | | | | a = 1 | | | ------------ | | | | b = 1 | | | | | read b | | | | ------------ | | | read a | 

但是,由于障碍禁止,因此无法将两个变量的读取和/或写入相互转移。 因此,不可能获得两个0。

以上面的第二个例子为例,其中b被读为0.当在线程A上读取b时,由于线程A上的内存屏障, a已经被写为1并且对其他线程可见。 a在线程B上还没有被读取或缓存,因为线程B上的内存屏障尚未到达,因为b仍为0。