内存屏障是否保证在 C# 中重新读取?

Do memory barriers guarantee a fresh read in 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# 中的 "freshness" 和 MemoryBarrier 相关的问题,例如 this and this。然而,它们中的大多数都处理写-释放和读-获取模式。这个问题中发布的代码非常具体地说明了是否保证在指令保持顺序的情况下读取可以查看写入。

不能保证看到两个线程都写入 1It only guarantees the order of read/write operations 基于此规则:

The processor executing the current thread cannot reorder instructions in such a way that memory accesses prior to the call to MemoryBarrier execute after memory accesses that follow the call to MemoryBarrier.

所以这基本上意味着 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 keyword对于不同线程使用的变量,因为它完全按照您的意愿工作 - 让您重新阅读变量:

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

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

我将使用向下箭头 ↓ 表示易失性读取,使用向上箭头 ↑ 表示易失性写入。将箭头视为推开任何其他读取和写入。只要没有指令通过向下箭头向上和通过向上箭头向下,生成这些内存栅栏的代码就可以自由移动。然而,内存栅栏(箭头)被锁定在它们最初在代码中声明的位置。 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 产生一个 acquire-fence 不允许读取向上浮动并越过调用。所以读取是 "fresh" 相对于 Thread.MemoryBarrier 调用。但是,相对于 Console.WriteLine 调用实际使用它的时间,它可能是 "stale"。

现在让我们考虑一下,如果我们用 volatile 关键字替换 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.MemoryBarriervolatile 解决方案放在读取之前,则可以产生 "fresher" 读取。但是,你还能声称读取的是 "fresh" 吗?不是真的,因为当它被 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 将保证在它之后出现的读取 return 最新值 相对于 屏障。但是,当您实际做出决定或使用该信息时,它可能不再是最新值了。
  • volatile 保证读取的值 return 比之前读取的相同变量更新。但它绝不会保证该值是最新的。
  • "fresh" 的含义需要明确定义,但可能因情况和开发人员而异。没有任何意义比任何其他意义都更正确,只要它可以被正式定义和表达。
  • 这不是一个绝对的概念。您会发现根据相对于其他事物(例如内存屏障的生成或先前指令的生成)来定义 "fresh" 更有用。换句话说,"freshness"是一个相对概念,就像爱因斯坦狭义相对论中速度相对于观察者的关系。

以上答案基本正确。但是,为了对您的问题提供更简洁的解释 - "Is it guaranteed that at least one thread prints 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    |

内存重新排序可能会导致对一个变量的读and/or写操作相互移位,再次导致两个1:

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

但是,您无法使 两个 变量的读 and/or 写相互移动,因为障碍禁止这样做。所以不可能得到两个0。

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