4

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

MemoryBarrier我知道在 C# 中已经存在几个与“新鲜度”相关的问题,例如thisthis。但是,它们中的大多数都处理写-释放和读-获取模式。这个问题中发布的代码非常具体地说明在指令保持有序这一事实之上,是否保证读取可以查看写入。

4

3 回答 3

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 编译和执行,它们实际上就是多部分指令。我试图在某种程度上说明这一点,但实际上调用 ofConsole.WriteLine仍然比显示的要复杂得多,因此读取aorb和它们第一次使用之间的时间相对而言可能很重要。因为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你可能会问,使用的意义何在。因为连续读取会产生acquire-fence语义,所以它确实保证以后的读取会产生比前一次读取更新的值。考虑以下代码。

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保证读取将返回一个比先前读取的相同变量更新的值。它绝不保证该值是最新的。
  • “新鲜”的含义需要明确定义,但可能因情况而异,因开发人员而异。只要可以正式定义和表达,就没有任何意义比其他任何意义都正确。
  • 这不是一个绝对的概念。您会发现将“新鲜”定义为相对于其他事物(例如内存屏障的生成或先前的指令)更有用。换句话说,“新鲜度”是一个相对概念,就像爱因斯坦狭义相对论中速度与观察者的关系一样。
于 2016-10-25T19:30:07.953 回答
3

不能保证看到两个线程都写入1。它仅根据此规则保证读/写操作的顺序:

执行当前线程的处理器不能以这样一种方式重新排序指令,即在调用之前的内存访问在调用之后MemoryBarrier的内存访问之后执行。MemoryBarrier

所以这基本上意味着 a 的线程thread 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);
}
于 2016-06-27T12:25:09.737 回答
2

上述答案基本正确。但是,为了对您的问题提供更简洁的解释——“是否保证至少有一个线程打印 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。b在线程 A 上读取时a,由于线程 A 上的内存屏障,已经被写入为 1 并且对其他线程可见。但是,a不可能已在线程 B 上读取或缓存,因为尚未达到线程 B 上的内存屏障,假设它b仍然为 0。

于 2017-05-25T22:02:42.923 回答