易失性内存屏障演示?

6

我正在尝试了解如何应用这个栅栏。

我有这段代码(会无限制地阻塞):

static void Main()
{
    bool complete = false;
    var t = new Thread(() => {
        bool toggle = false;
        while(!complete) toggle = !toggle;
    });
    t.Start();
    Thread.Sleep(1000);
    complete = true;
    t.Join(); // Blocks indefinitely
}

使用 volatile bool _complete; 可以解决这个问题。

获取栅栏:

获取栅栏可以防止其他读写操作在栅栏之前被移动。

但是,如果用箭头 来说明它 (将箭头头部想象成将一切推开。)

现在代码可能会像这样:

 var t = new Thread(() => {
            bool toggle = false;
            while( !complete ) 
                    ↓↓↓↓↓↓↓     // instructions can't go up before this fence.  
               {
                 toggle = !toggle;
                }
        });

我不理解这幅图是如何解决这个问题的。
我知道现在while(!complete)读取真实值了,但它与complete = true;和栅栏的位置有什么关系呢?
2个回答

18

complete设为volatile有两个作用:

  • 防止C#编译器或JIT对complete的值进行缓存优化。

  • 它引入了一个屏障,告诉处理器需要取消其他读写操作中涉及预取读取或延迟写入的缓存优化,以确保一致性。

让我们先考虑第一个。JIT完全有权利看到循环体:

    while(!complete) toggle = !toggle;

不修改complete,因此循环开始时complete的任何值都将永远保持不变。 因此,Jitter可以生成代码,就好像您编写了:

    if (!complete) while(true) toggle = !toggle;

或者更有可能:
    bool local = complete; 
    while(local) toggle = !toggle;

complete成为volatile可以防止两种优化。
但是你想要的是volatile的第二个效果。假设你的两个线程在不同的处理器上运行。每个处理器都有自己的处理器缓存,这是主内存的副本。假设这两个处理器都复制了一个complete为false的主内存。当一个处理器的缓存将complete设置为true时,如果complete不是volatile,那么“切换”处理器不需要注意到这一点;它有自己的缓存,在其中complete仍然为false,每次回到主内存会很昂贵。
complete标记为volatile消除了此优化。如何消除是处理器的实现细节。也许在每次volatile写入时,写入都被写入主内存,每个其他处理器都丢弃其缓存。或者可能有其他策略。处理器选择如何进行操作取决于制造商。
关键是,任何时候使字段成为volatile并读取或写入它,您都会大大干扰编译器、JIT和处理器优化代码的能力。首先尝试不使用volatile字段;使用更高级别的构造,并且不要在线程之间共享内存。
我正在尝试将句子“acquire-fence防止其他读/写被移动到fence之前…”可视化。该指令之前不应该有什么内容?
考虑指令可能是无益的。与其考虑一堆“指令”,不如只专注于读写的顺序。其他都是无关紧要的。
假设您有一个内存块,并且其中一部分被复制到了两个缓存中。出于性能原因,您主要从缓存中读取和写入。不时地,您会将缓存与主内存重新同步。这对读写序列有什么影响?
假设我们希望对单个整数变量执行此操作:
1. 处理器Alpha将0写入主内存。 2. 处理器Bravo从主内存读取0。 3. 处理器Bravo将1写入主内存。 4. 处理器Alpha从主内存读取1。
假设实际发生的是:
  • 处理器Alpha向缓存中写入0,并与主内存同步。
  • 处理器Bravo从主内存同步缓存并读取0。
  • 处理器Bravo向缓存中写入1,并将缓存与主内存同步。
  • 处理器Alpha从其缓存中读取0 -- 即旧值。

这究竟有什么不同呢?

  1. 处理器Alpha将0写入主内存。
  2. 处理器Bravo从主内存读取0。
  3. 处理器Alpha从主内存读取0。
  4. 处理器Bravo将1写入主内存。

它们没有区别。缓存可以将"写-读-写-读"转化为"写-读-读-写"。它将一个读操作向后移动,在这种情况下,等价地将一个写操作向前移动。

这个例子只涉及对一个位置进行的两次读写操作,但是您可以想象一种场景,其中对许多位置进行了许多读写操作。处理器可以广泛地将读操作向后移动,并将写操作向前移动。关于哪些操作是合法的以及哪些操作是不合法的具体规则因处理器而异。

一个栅栏是一种屏障,阻止读取向后移动或写入向前越过它。所以如果我们有:

  1. 处理器 Alpha 将 0 写入主内存。
  2. 处理器 Bravo 从主内存读取 0。
  3. 处理器 Bravo 将 1 写入主内存。放置栅栏。
  4. 处理器 Alpha 从主内存读取 1。

无论处理器使用什么缓存策略,现在都不允许将读取4移动到栅栏之前的任何点。同样,也不允许将写入3向后移动到栅栏之后的任何点。处理器如何实现栅栏由其自己决定。


这不是由优化标志控制的相同优化吗?(非常感谢您的回答。) - Royi Namir
C#编译器和Jitter在关闭优化时会变得不那么激进,但处理器对此一无所知。栅栏是用于禁用处理器优化的。正是芯片本身进行了危险的优化,需要关闭它。 - Eric Lippert
Eric,我觉得有个问题。我来告诉你为什么。我们知道acquire-fence是一种内存屏障,在该屏障之前不允许进行其他读写操作;而release-fence则是一种内存屏障,在该屏障之后不允许进行其他读写操作。因此,在你最后的示例中,屏障不会在你所说的位置。根据https://dev59.com/H2gv5IYBdhLWcg3wY_82#10637264,你的最后一个示例将看起来像`1...2... ↑write ↓Read`,这两个操作可以交换(https://dev59.com/H2gv5IYBdhLWcg3wY_82),这是volatile的一个问题。(继续) - Royi Namir
添加volatile关键字是否解决了我的问题是因为防止抖动优化还是因为屏障?如果我不使用优化标志运行它,程序会结束(因为没有像您解释的那样的代码优化)。但是,如果我使用优化标志运行它,程序永远不会结束。 - Royi Namir
1
@RoyiNamir:应用程序中的每个层都可以自由优化(编译器、Jitter或硬件)。当您使用volatile或任何内存屏障生成器时,您正在告诉所有层约束指令移动优化。所以回答你的问题...是的,volatile关键字告诉Jitter防止“提升”优化。读取线程上的获取栅栏编译器停止优化的通知! - Brian Gideon
显示剩余5条评论

5

像我在关于内存屏障的大部分回答中一样,我将使用箭头符号来表示获取屏障(volatile read)↓和释放屏障(volatile write)↑。请记住,没有其他读取或写入可以超越箭头头部(尽管它们可以超越尾部)。

让我们先分析写线程。我假设complete声明为volatile1Thread.StartThread.SleepThread.Join将生成完整的屏障,这就是为什么我在每个调用的两侧都有向上和向下的箭头的原因。

// full fence from Thread.Start
t.Start();
↓                   // full fence from Thread.Start// full fence from Thread.Sleep
Thread.Sleep(1000);
↓                   // full fence from Thread.Sleep// release fence from volatile write to complete
complete = true;
↑                   // full fence from Thread.Join
t.Join();
↓                   // full fence from Thread.Join

重要的一点是要注意,阻止写入到complete继续向下传递的是Thread.Join调用。这里的效果是使写入立即提交到主内存。并不是complete本身的易失性导致它被刷新到主内存。而是Thread.Join调用以及它生成的内存屏障导致了这种行为。
现在我们将分析读取线程。由于while循环,这有点棘手,但让我们从这里开始。
bool toggle = false;
register1 = complete;
↓                           // half fence from volatile read
while (!register1)
{
  bool register2 = toggle;
  register2 = !register2;
  toggle = register2;
  register1 = complete;
  ↓                         // half fence from volatile read
}

也许我们可以更好地理解它,如果我们展开循环。出于简洁起见,我只会展示前4次迭代。
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓

现在我们已经展开了循环,我认为您可以看到任何可能的complete读取移动都将受到严重限制。2 是的,它可能会被编译器或硬件稍微移动一下,但它基本上被锁定在每次迭代中读取。请记住,complete的读取仍然可以移动,但它创建的屏障不会随之移动。该屏障被锁定在原地。这就是导致常被称为“新鲜读取”的行为的原因。如果省略complete上的volatile,则编译器将可以使用一种称为“提升”的优化技术。这是从循环外提取或提升内存地址的读取操作。在没有volatile的情况下,该优化是合法的,因为所有complete的读取都可以向上漂浮(或提升),直到它们最终全部位于循环之外。此时,编译器将把它们全部合并成一个一次性读取,就在开始循环之前。3 让我现在总结几个重要点。
  • 调用Thread.Join会导致对complete的写入提交到主内存,以便工作线程最终可以获取。在编写线程上,complete的易失性不重要(这可能令大多数人感到惊讶)。
  • 易失性读取complete所生成的获取栅栏阻止了该读取从循环外提升,从而创建了“新鲜读取”行为。在读取线程上,complete的易失性有很大的影响(这可能对大多数人来说是显而易见的)。
  • “提交的写入”和“新鲜读取”不是由易失性读写直接引起的。但是,在循环的情况下,它们是间接后果,几乎总是发生的。

1将写入线程上的complete标记为volatile是不必要的,因为x86写入已经具有volatile语义,更重要的是因为它创建的屏障不会引起“提交写入”行为。

2请记住,读取和写入可以通过箭头的尾部移动,但箭头被固定在原地。这就是为什么你不能将所有的读取数据都冒泡到循环外面。

3提升优化还必须确保线程的实际行为与程序员最初的意图一致。在这种情况下,编译器很容易看出,在该线程上从未写入complete


@RoyiNamir:从 Thread.JoinThread.Sleep 生成的内存屏障是从这些方法的非托管实现中注入的。因此,在反编译时可能看不到它们的证据。请参见我在 此处 给出的内存屏障生成器列表。 - Brian Gideon
如果省略 volatile,由于 Thread.Join 调用,写入仍将提交到主内存。被优化掉的是读取操作。这就是为什么它不会结束。我并不是想暗示 Thread.Join 可以解决无限运行的问题。 - Brian Gideon
谢谢Brian(一如既往) - Royi Namir
@BrianGideon,你的意思是如果我们移除了Thread.Join调用,那么对complete的写入可能不会被提交到主内存?因为没有障碍来防止写入被延迟,对吗?但是,C#又是如何保证对volatile字段的所有写入都会立即提交的呢? - dcastro
@dcastro:基本上是的。然而,这大多是理论上的。JIT编译器可以决定将写操作提交到主内存,即使规范并不要求它这样做。此外,在这种特定情况下,删除Thread.Join将导致线程立即结束。当线程结束时,所有写操作都会自动提交。因此,对于您的问题,技术上是的,但实际上该写操作可能会立即发生。 - Brian Gideon
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接