6

在他关于 C# 线程的出色论文中,Joseph Albahari 提出了以下简单的程序来演示为什么我们需要使用某种形式的内存隔离来围绕由多个线程读取和写入的数据。如果您在发布模式下编译程序并在没有调试器的情况下自由运行它,程序将永远不会结束:

  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
  }

我的问题是,为什么上面程序的以下稍微修改的版本不再无限期阻塞?

class Foo
{
  public bool Complete { get; set; }
}

class Program
{
  static void Main()
  {
     var foo = new Foo();
     var t = new Thread(() =>
     {
        bool toggle = false;
        while (!foo.Complete) toggle = !toggle;
     });
     t.Start();
     Thread.Sleep(1000);
     foo.Complete = true;                  
     t.Join(); // No longer blocks indefinitely!!!
  }
}

而以下仍然无限期阻塞:

class Foo
{
  public bool Complete;// { get; set; }
}

class Program
{
  static void Main()
  {
     var foo = new Foo();
     var t = new Thread(() =>
     {
        bool toggle = false;
        while (!foo.Complete) toggle = !toggle;
     });
     t.Start();
     Thread.Sleep(1000);
     foo.Complete = true;                  
     t.Join(); // Still blocks indefinitely!!!
  }
}

如下所示:

class Program
{
  static bool Complete { get; set; }

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

4 回答 4

7

在第一个示例Complete中是一个成员变量,可以为每个线程缓存在寄存器中。由于您没有使用锁定,因此对该变量的更新可能不会刷新到主内存,并且另一个线程将看到该变量的陈旧值。

在第二个例子中, whereComplete是一个属性,你实际上是在 Foo 对象上调用一个函数来返回一个值。我的猜测是,虽然简单的变量可能会缓存在寄存器中,但编译器可能并不总是以这种方式优化实际属性。

编辑:

关于自动属性的优化 - 我认为规范在这方面没有任何保证。您基本上依赖于编译器/运行时是否能够优化 getter/setter。

在它位于同一个对象上的情况下,它似乎确实如此。在另一种情况下,似乎没有。无论哪种方式,我都不会打赌。解决此问题的最简单方法是使用简单的成员变量和标记,volotile以确保它始终与主内存同步。

于 2012-05-01T15:20:33.873 回答
5

这是因为在您提供的第一个片段中,您创建了一个封闭布尔值的 lambda 表达式complete- 因此,当编译器重写它时,它会捕获值的副本,而不是引用。同样,在第二个中,由于关闭了Foo对象,它正在捕获引用而不是副本,因此当您更改基础值时,由于引用而注意到更改。

于 2012-05-01T15:12:23.207 回答
3

其他答案以技术上正确的术语解释了发生的事情。让我看看能不能用英文解释一下。

第一个例子说“循环直到这个变量位置为真”。新线程创建该变量位置的副本(因为它是值类型)并继续循环下去。如果变量碰巧是一个引用类型,它会复制一个引用,但由于引用碰巧指向同一个内存位置,它本来可以工作的。

第二个例子说“循环直到这个方法(getter)返回真”。新线程无法创建方法的副本,因此它会创建对相关类实例的引用的副本,并在该实例上重复调用 getter 直到它返回 true(重复读取设置的相同变量位置在主线程中为真)。

第三个例子和第一个例子一样。封闭变量恰好是另一个类实例的成员这一事实无关紧要。

于 2012-05-01T15:26:16.223 回答
0

扩展Eric Petroelje 的答案

如果我们如下重写程序(行为相同,但避免使用 lambda 函数使反汇编更容易阅读),我们可以对其进行反汇编,看看“缓存寄存器中的字段值”实际上意味着什么

class Foo
{
    public bool Complete; // { get; set; }
}

class Program
{
    static Foo foo = new Foo();

    static void ThreadProc()
    {
        bool toggle = false;
        while (!foo.Complete) toggle = !toggle;

        Console.WriteLine("Thread done");
    }

    static void Main()
    {
        var t = new Thread(ThreadProc);
        t.Start();
        Thread.Sleep(1000);
        foo.Complete = true;
        t.Join();
    }
}

我们得到以下行为:

                Foo.Complete is a Field  |   Foo.Complete is a Property
x86-RELEASE  |      loops forever        |          completes  
x64-RELEASE  |        completes          |          completes  

在 x86 版本中,CLR JIT 将 while(!foo.Complete) 编译为以下代码:

Complete 是一个字段:

004f0153 a1f01f2f03      mov     eax,dword ptr ds:[032F1FF0h] # Put a pointer to the Foo object in EAX
004f0158 0fb64004        movzx   eax,byte ptr [eax+4]  # Put the value pointed to by  [EAX+4] into EAX (this basically puts the value of .Complete into EAX)
004f015c 85c0            test    eax,eax   # Is EAX zero? (is .Complete false?)
004f015e 7504            jne     004f0164  # If it is not, exit the loop
# start of loop
004f0160 85c0            test    eax,eax   # Is EAX zero? (is .Complete false?)
004f0162 74fc            je      004f0160  # If it is, goto start of loop

最后两行是问题。如果 eax 为零,那么它只会在一个无限循环中说“EAX 为零吗?”,而无需任何代码更改 eax 的值!

Complete 是一个属性:

00220155 a1f01f3a03      mov     eax,dword ptr ds:[033A1FF0h] # Put a pointer to the Foo object in EAX
0022015a 80780400        cmp     byte ptr [eax+4],0 # Compare the value at [EAX+4] with zero (is .Complete false?)
0022015e 74f5            je      00220155 # If it is, goto 2 lines up

这实际上看起来像更好的代码。虽然 JIT 已将属性 getter(否则您会看到一些call指令转到其他函数)内联到一些简单的代码中以Complete直接读取该字段,因为它不允许缓存变量,当它生成循环时,它会重复读取一遍又一遍的记忆,而不仅仅是毫无意义地读取寄存器

在 x64 版本中,64 位 CLR JIT 将 while(!foo.Complete) 编译成这段代码

Complete 是一个字段:

00140245 48b8d82f961200000000 mov rax,12962FD8h # put 12E12FD8h into RAX. 12E12FD8h is a pointer-to-a-pointer in some .NET static object table
0014024f 488b00          mov     rax,qword ptr [rax] # Follow the above pointer; puts a pointer to the Foo object in RAX
00140252 0fb64808        movzx   ecx,byte ptr [rax+8] # Add 8 to the pointer to Foo object (it now points to the .Complete field) and put that value in ECX
00140256 85c9            test    ecx,ecx # Is ECX zero ? (is the .Complete field false?)
00140258 751b            jne     00140275 # If nonzero/true, exit the loop
0014025a 660f1f440000    nop     word ptr [rax+rax]  # Do nothing!
# start of loop
00140260 48b8d82f961200000000 mov rax,12962FD8h # put 12E12FD8h into RAX. 12E12FD8h is a pointer-to-a-pointer in some .NET static object table 
0014026a 488b00          mov     rax,qword ptr [rax] # Follow the above pointer; puts a pointer to the Foo object in RAX 
0014026d 0fb64808        movzx   ecx,byte ptr [rax+8] # Add 8 to the pointer to Foo object (it now points to the .Complete field) and put that value in ECX
00140271 85c9            test    ecx,ecx # Is ECX Zero ? (is the .Complete field true?)
00140273 74eb            je      00140260 # If zero/false, go to start of loop

Complete 是一个属性

00140250 48b8d82fe11200000000 mov rax,12E12FD8h # put 12E12FD8h into RAX. 12E12FD8h is a pointer-to-a-pointer in some .NET static object table
0014025a 488b00          mov     rax,qword ptr [rax] # Follow the above pointer; puts a pointer to the Foo object in RAX
0014025d 0fb64008        movzx   eax,byte ptr [rax+8] # Add 8 to the pointer to Foo object (it now points to the .Complete field) and put that value in EAX
00140261 85c0            test    eax,eax # Is EAX 0 ? (is the .Complete field false?)
00140263 74eb            je      00140250 # If zero/false, go to the start

64 位 JIT 对属性和字段都做同样的事情,除了当它是一个字段时,它会“展开”循环的第一次迭代——这基本上if(foo.Complete) { jump past the loop code }出于某种原因在它前面放了一个。

在这两种情况下,它在处理属性时都与 x86 JIT 做类似的事情:
- 将方法内联到直接内存读取 - 它不缓存它,并且每次都重新读取值

我不确定 64 位 CLR 是否不允许像 32 位一样将字段值缓存在寄存器中,但如果是这样,那就不用担心了。也许它会在未来?

无论如何,这说明了行为是如何依赖于平台并可能发生变化的。我希望它有帮助:-)

于 2012-08-02T23:48:31.890 回答