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

时间:2016-06-27 09:46:05

标签: c# multithreading volatile memory-barriers memory-fences

如果我们在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

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

3 个答案:

答案 0 :(得分:3)

无法保证两个线程都会写grantYear grantAmount name 2009 3000 besancon 2010 1000 besancon 2011 0 besancon It only guarantees the order of read/write operations基于此规则:

  

执行当前线程的处理器无法重新排序指令,以便在内存访问跟随调用后,内存访问调用之前 <{strong>>执行1 >到MemoryBarrier

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

b

并行执行的竞争条件错误很难重现,因此我无法为您提供一定能完成上述方案的代码,但我建议您使用volatile keyword对于不同线程使用的变量,因为它完全按照您的需要工作 - 为您提供变量的新读取:

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);
}

答案 1 :(得分:3)

这取决于你所说的“新鲜”。 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调用实际使用它时,它可能是“陈旧的”。

现在让我们考虑一下,如果我们使用Thread.MemoryBarrier关键字替换volatile来电,您的代码会是什么样子。

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

答案 2 :(得分:1)

以上答案基本上是正确的。但是,为您的问题提供更简洁的解释 - “是否保证至少有一个线程打印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已经被写为1并且可见到其他线程,因为线程A上的内存障碍。但是,在线程B上还没有读取或缓存a,因为线程B上的内存屏障尚未到达,因为{{1仍然是0.