两个线程之间的共享变量与共享属性的行为不同

6
在他对C#中的线程处理的优秀论述中,Joseph Albahari提出了以下简单程序,以展示为什么我们需要在被多个线程读写的数据周围使用某种形式的内存栅栏。如果您在无调试器的Release模式下编译并自由运行该程序,则该程序永远不会结束:
  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!!!
  }
}

你的问题标题比需要涵盖的内容更广泛。并不是所有的代码都像这样简单。 - Steve Townsend
你比较了这两个程序的IL吗? - Oded
我确实比较了IL,但没有看到任何可以提示我解释的东西。 - dmg
4个回答

7
在第一个示例中,Complete是一个成员变量,每个线程都可以在寄存器中缓存它。由于您没有使用锁定,对该变量的更新可能不会刷新到主内存,并且其他线程将看到该变量的旧值。
在第二个示例中,Complete是一个属性,您实际上正在调用Foo对象上的函数来返回一个值。我猜想,虽然简单变量可以在寄存器中缓存,但编译器可能并不总是以这种方式优化实际属性。
编辑:
关于自动属性的优化-我认为规范中没有任何保证。您实质上是在赌注编译器/运行时是否能够优化getter/setter。在它在同一对象上的情况下,似乎确实是这样。在其他情况下,似乎并不是这样。无论哪种情况,我都不会打赌。解决此问题的最简单方法是使用一个简单的成员变量并将其标记为volotile,以确保它始终与主内存同步。

刚刚我添加的最后一个例子怎么样? - dmg
@dmg - 我编辑了我的回答。由于规范没有对此做出任何保证,因此这取决于打赌编译器可能会或可能不会优化自动属性。 - Eric Petroelje
似乎正在发生这种情况。如果Complete属性属于这个类,则会被优化掉,但如果它属于另一个类,则不会被优化。 - dmg

5
这是因为在你提供的第一个代码段中,你创建了一个闭包来引用布尔值complete - 因此,当编译器重写代码时,它捕获的是该值的副本,而不是引用。同样,在第二个代码段中,由于闭包了Foo对象,它捕获的是引用而不是副本,因此当你更改底层值时,由于引用,所以更改被注意到了。

你能解释一下complete是如何被按值捕获的吗?我本来以为它会被按引用捕获,因为这通常是在lambda表达式中发生的。 - Kendall Frey
1
"bool" 是一个值数据类型,因此无法通过引用进行捕获。 - Tejs
我刚刚添加了另一个代码片段。编译器是否以与本地布尔变量相同的方式优化公共成员字段Complete,但如果将公共成员字段替换为属性,则无法执行相同的优化呢? - dmg
有许多编译器优化可以进行。我强烈建议下载像Telerik的JustDecompile或.NET Reflector这样的工具,并检查输出代码。这可以高度启示编译器实际发出了什么。 - Tejs
int也是如此。有许多捕获变量的例子。例如:https://dev59.com/DXVC5IYBdhLWcg3whBaj - Kendall Frey

3
其他答案从技术上正确地解释了发生了什么。让我用英语来解释一下。
第一个例子的意思是“循环直到该变量位置为true”。新线程创建了该变量位置的副本(因为它是值类型),并继续永久循环。如果该变量恰好是引用类型,它将会复制引用,但由于该引用恰好指向相同的内存位置,所以它也可以工作。
第二个例子的意思是“循环直到该方法(getter)返回true”。新线程无法创建方法的副本,因此它创建了对相关类实例的引用的副本,并反复调用该实例上的getter,直到它返回true(重复读取在主线程中设置为true的同一变量位置)。
第三个例子与第一个例子相同。封闭变量恰好是另一个类实例的成员不相关。

所以我猜在第四个例子中,编译器会优化掉对静态get属性的调用,并将其视为变量的副本? - dmg
在第四个例子中(抱歉,直到现在才看到),我不确定发生了什么。我的怀疑是它正在内联getter,导致变量的副本,但我不确定。我原本以为这不会阻塞。 - Chris Shain

0

为了扩展Eric Petroelje的答案

如果我们将程序重写如下(行为相同,但避免使用lambda函数使其更易于阅读dissassembly),我们可以对其进行反汇编并查看“将字段的值缓存在寄存器中”的实际含义。

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-release 版本中,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-release中,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位CLR那样允许将字段值缓存在寄存器中,但如果可以,它并没有费心去这样做。也许将来会这样做?

无论如何,这说明行为是平台相关的,并且可能会发生变化。希望这有所帮助 :-)


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