在他关于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!!!
}
}
答案 0 :(得分:7)
在第一个示例中,Complete
是一个成员变量,可以在每个线程的寄存器中进行缓存。由于您没有使用锁定,因此对该变量的更新可能无法刷新到主存储器,而另一个线程将看到该变量的过时值。
在第二个示例中,Complete
是一个属性,实际上是在Foo对象上调用一个函数来返回一个值。我的猜测是,虽然简单变量可以缓存在寄存器中,但编译器可能并不总是以这种方式优化实际属性。
编辑:
关于自动属性的优化 - 我不认为规范在这方面有任何保证。您实质上是在考虑编译器/运行时是否能够优化getter / setter。
如果它在同一个对象上,它似乎就是这样。在另一种情况下,它似乎没有。无论哪种方式,我都不会赌它。解决这个问题的最简单方法是使用一个简单的成员变量,标记为volotile
,以确保它始终与主内存同步。
答案 1 :(得分:5)
这是因为在你提供的第一个片段中,你创建了一个lambda表达式,它关闭了布尔值complete
- 因此,当编译器重写它时,它会捕获值的副本,而不是引用。同样,在第二个中,它由于关闭Foo
对象而捕获引用而不是副本,因此当您更改基础值时,由于引用而注意到更改。
答案 2 :(得分:3)
其他答案解释了技术上正确的术语。让我看看我是否可以用英语解释它。
第一个例子说“循环直到这个变量位置为真”。新线程创建该变量位置的副本(因为它是值类型)并继续循环。如果变量碰巧是一个引用类型,它就会创建一个引用的副本,但是因为引用恰好指向了相同的内存位置。
第二个例子说“循环直到这个方法(getter)返回true”。新线程无法创建方法的副本,因此它会创建对相关类实例的引用的副本,并重复调用该实例上的getter,直到它返回true(重复读取设置的相同变量位置)在主线程中为true。)
第三个例子与第一个例子相同。闭合变量恰好是另一个类实例的成员这一事实并不重要。
答案 3 :(得分:0)
如果我们按如下方式重写程序(行为是相同的,但是避免使用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-release中,CLR JIT将while(!foo.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的值!
完成属性:
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)编译成此代码
完成是一个字段:
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
完成属性
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位一样,但如果是,则不会这样做。也许它将来会发生什么?
无论如何,这说明了行为如何依赖于平台并且可能会发生变化。我希望它有所帮助: - )